diff options
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) + } +} |
