aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/hardening_casino_locking_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 05:31:09 -0400
committerhistoria <[not public]>2026-09-18 05:31:09 -0400
commit153acb7f644d755c45533b7c5311e3ffa47e8973 (patch)
tree7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/casino/hardening_casino_locking_test.go
parent87d08f0151e643951769e323995f1c5ed89ee200 (diff)
downloadthehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/casino/hardening_casino_locking_test.go')
-rw-r--r--internal/casino/hardening_casino_locking_test.go84
1 files changed, 84 insertions, 0 deletions
diff --git a/internal/casino/hardening_casino_locking_test.go b/internal/casino/hardening_casino_locking_test.go
new file mode 100644
index 0000000..080d941
--- /dev/null
+++ b/internal/casino/hardening_casino_locking_test.go
@@ -0,0 +1,84 @@
+package casino
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+// rebet is called through Manager.Rebet and reads the seat map; it must take
+// the table lock like every other game method, or a future caller off the
+// owner goroutine would race the live seat state.
+
+func TestRebetTakesTheTableLock(t *testing.T) {
+ blackjack := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ blackjack.join("alice")
+ assertRebetWaitsForLock(t, "blackjack", &blackjack.mu, func() { blackjack.rebet("alice") })
+
+ baccarat := newBaccaratTable(1, TableConfig{ID: "baccarat", Game: GameBaccarat}, newTestRand())
+ baccarat.join("alice")
+ assertRebetWaitsForLock(t, "baccarat", &baccarat.mu, func() { baccarat.rebet("alice") })
+}
+
+func assertRebetWaitsForLock(t *testing.T, name string, mu *sync.Mutex, rebet func()) {
+ t.Helper()
+ mu.Lock()
+ done := make(chan struct{})
+ go func() {
+ rebet()
+ close(done)
+ }()
+ select {
+ case <-done:
+ mu.Unlock()
+ t.Fatalf("%s rebet read the seat map without the table lock", name)
+ case <-time.After(50 * time.Millisecond):
+ }
+ mu.Unlock()
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatalf("%s rebet did not return after the lock was released", name)
+ }
+}
+
+// A lone connected player is deliberately never timed out by the action
+// window. The absolute turn ceiling must therefore end that turn, or a
+// hung-but-connected client holds the hand, the seat, and the stake forever
+// (leave refuses mid-round).
+
+func TestLoneBettorTurnCeilingSettlesTheHand(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack,
+ Blackjack: BlackjackConfig{ActionTicks: 2}}, newTestRand())
+ table.shoe = tableauShoe(
+ card{rank: "10"}, card{rank: "7"}, // dealer 17
+ card{rank: "10"}, card{rank: "6"}, // alice 16
+ )
+ wallet := &testWallet{credits: 100}
+ table.join("alice")
+ table.bet("alice", "", 10, wallet)
+ if table.phase != blackjackPlaying {
+ t.Fatalf("setup: expected a dealt hand (phase=%s)", table.phase)
+ }
+
+ // The action timer alone would have expired after two ticks, but the
+ // lone bettor gets the full grace period: 10x the action window.
+ ceiling := table.Rules.ActionTicks * loneTurnGrace
+ for i := 0; i < ceiling-1; i++ {
+ table.tick()
+ }
+ if table.phase != blackjackPlaying {
+ t.Fatalf("lone bettor's hand ended before the turn ceiling (phase=%s, ceiling=%d)", table.phase, ceiling)
+ }
+
+ events := table.tick()
+ if table.phase != blackjackWaiting {
+ t.Fatalf("turn ceiling did not settle the hand: phase=%s events=%+v", table.phase, events)
+ }
+ if !hasMessage(events, "idle too long") {
+ t.Fatalf("missing turn-ceiling message: %+v", events)
+ }
+ if len(wallet.open) != 0 {
+ t.Fatalf("stake still committed after the ceiling stood the hand: %+v", wallet.open)
+ }
+}