diff options
| author | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
| commit | 153acb7f644d755c45533b7c5311e3ffa47e8973 (patch) | |
| tree | 7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/casino/hardening_casino_locking_test.go | |
| parent | 87d08f0151e643951769e323995f1c5ed89ee200 (diff) | |
| download | thehouseoficarus-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.go | 84 |
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) + } +} |
