From 153acb7f644d755c45533b7c5311e3ffa47e8973 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 05:31:09 -0400 Subject: fix: harden engine across casino, economy, concurrency, security, and content --- .../casino/hardening_casino_disconnect_test.go | 102 +++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 internal/casino/hardening_casino_disconnect_test.go (limited to 'internal/casino/hardening_casino_disconnect_test.go') diff --git a/internal/casino/hardening_casino_disconnect_test.go b/internal/casino/hardening_casino_disconnect_test.go new file mode 100644 index 0000000..6d25581 --- /dev/null +++ b/internal/casino/hardening_casino_disconnect_test.go @@ -0,0 +1,102 @@ +package casino + +import "testing" + +// A disconnect must only forfeit a hand that is actually in flight. A stake +// placed in the betting window has had no card dealt for it, so refunding it +// reveals nothing; destroying it there would let an ordinary transient TCP +// drop swallow an undelt wager. + +// TestBlackjackPreDealDisconnectRefundsStake: alice bets, the betting window +// is open (bob has not bet, no cards dealt), alice's socket drops. Her stake +// comes back, the wager record is resolved, and the round that follows pays +// nothing on top of the refund. +func TestBlackjackPreDealDisconnectRefundsStake(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2, BettingTicks: 3}, newTestRand()) + table.join("alice") + table.join("bob") + wallet := &testWallet{credits: 100} + table.bet("alice", "", 10, wallet) + if table.phase != blackjackBetting { + t.Fatalf("setup: expected the betting window (phase=%s)", table.phase) + } + if wallet.credits != 90 { + t.Fatalf("setup: stake not committed (credits=%d)", wallet.credits) + } + + events := table.markDisconnected("alice") + if !hasMessage(events, "alice stands up") { + t.Fatalf("missing disconnect event: %+v", events) + } + if got := wallet.credits + wallet.paid; got != 100 { + t.Fatalf("pre-deal disconnect returned %d of 100; the undelt stake must be refunded", got) + } + if len(wallet.open) != 0 { + t.Fatalf("refunded stake left outstanding: %+v", wallet.open) + } + + // The window resolves with bob sitting out; nothing may pay the + // refunded round afterwards. + for i := 0; i < 10; i++ { + table.tick() + } + if got := wallet.credits + wallet.paid; got != 100 { + t.Fatalf("refunded pre-deal round paid again: balance %d", got) + } + if table.phase != blackjackWaiting { + t.Fatalf("table did not reset after the window expired: phase=%s", table.phase) + } +} + +// TestBlackjackMidHandDisconnectForfeitsStake: the documented mid-hand +// forfeit policy is unchanged — cards are face up, so the stake stays with +// the house. +func TestBlackjackMidHandDisconnectForfeitsStake(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2}, newTestRand()) + table.shoe = tableauShoe( + card{rank: "10"}, card{rank: "7"}, // dealer + card{rank: "10"}, card{rank: "8"}, // alice 18 + card{rank: "10"}, card{rank: "6"}, // bob 16 + ) + wallet := &testWallet{credits: 100} + table.join("alice") + table.join("bob") + table.bet("alice", "", 10, wallet) + table.bet("bob", "", 10, &testWallet{credits: 100}) + if table.phase != blackjackPlaying { + t.Fatalf("setup: expected a dealt hand (phase=%s)", table.phase) + } + + table.markDisconnected("alice") + if wallet.credits != 90 || wallet.paid != 0 { + t.Fatalf("mid-hand disconnect refunded a dealt hand: credits=%d paid=%d, want 90/0 (forfeit)", wallet.credits, wallet.paid) + } +} + +// TestBaccaratBettingWindowDisconnectRefundsStake: baccarat deals +// synchronously once every connected seat has bet, so an outstanding wager +// there has never had a card dealt for it either. +func TestBaccaratBettingWindowDisconnectRefundsStake(t *testing.T) { + table := newBaccaratTable(1, TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2, BettingTicks: 3}, newTestRand()) + table.join("alice") + table.join("bob") + wallet := &testWallet{credits: 100} + table.bet("alice", baccaratSpotPlayer, 10, wallet) + if table.phase != baccaratBetting { + t.Fatalf("setup: expected the betting window (phase=%s)", table.phase) + } + + table.markDisconnected("alice") + if got := wallet.credits + wallet.paid; got != 100 { + t.Fatalf("pre-deal baccarat disconnect returned %d of 100; want the stake refunded", got) + } + if len(wallet.open) != 0 { + t.Fatalf("refunded baccarat stake left outstanding: %+v", wallet.open) + } + for i := 0; i < 10; i++ { + table.tick() + } + if got := wallet.credits + wallet.paid; got != 100 { + t.Fatalf("refunded pre-deal coup paid again: balance %d", got) + } +} -- cgit v1.2.3