aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/fix_regressions_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-19 01:55:34 -0400
committerhistoria <[not public]>2026-09-19 01:55:34 -0400
commitcc33072eced3d5460512c09c42ec4531c807d7af (patch)
treeb1b451fa87fa389cb8343a3645372f3b43f5523e /internal/casino/fix_regressions_test.go
parent2c38386484cdb507c4687caceb37e0b963e33594 (diff)
downloadthehouseoficarus-cc33072eced3d5460512c09c42ec4531c807d7af.tar.gz
fix: casino stake preservation, world state reconciliation, casino game registration
Diffstat (limited to 'internal/casino/fix_regressions_test.go')
-rw-r--r--internal/casino/fix_regressions_test.go121
1 files changed, 121 insertions, 0 deletions
diff --git a/internal/casino/fix_regressions_test.go b/internal/casino/fix_regressions_test.go
new file mode 100644
index 0000000..dc615ec
--- /dev/null
+++ b/internal/casino/fix_regressions_test.go
@@ -0,0 +1,121 @@
+package casino
+
+// Regressions for the money-safety fixes: table settlement must consume only
+// its own round's stakes (never the player-wide ledger), a shutdown/eviction
+// refund must still return a stake left pending after the round reset, and a
+// disconnect during the slot payout window must settle a revealed win.
+
+import "testing"
+
+// TestBlackjackSecondRoundDoesNotResolveFailedPayoutStake: round 1's payout
+// cannot be persisted, leaving its stake open for recovery. Round 2 then
+// loses; the table must resolve only round 2's stake, not call the
+// player-wide ResolveStakes and erase round 1's refund evidence.
+func TestBlackjackSecondRoundDoesNotResolveFailedPayoutStake(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand())
+ // Draw order: round 1 dealer 10,7 (17) vs alice 10,8 (18, win);
+ // round 2 dealer 10,10 (20) vs alice 10,8 (18, loss).
+ table.shoe = tableauShoe(
+ card{rank: "10"}, card{rank: "7"}, card{rank: "10"}, card{rank: "8"},
+ card{rank: "10"}, card{rank: "10"}, card{rank: "10"}, card{rank: "8"},
+ )
+ wallet := &failingPayoutWallet{testWallet{credits: 100}}
+ table.join("alice")
+
+ table.bet("alice", "", 10, wallet)
+ table.action("alice", "stand")
+ if len(wallet.open) != 1 {
+ t.Fatalf("setup: a failed payout must leave round 1's stake open, open=%+v", wallet.open)
+ }
+ firstID := wallet.open[0].ID
+
+ table.bet("alice", "", 10, wallet)
+ table.action("alice", "stand")
+ if len(wallet.open) != 1 || wallet.open[0].ID != firstID {
+ t.Fatalf("round 2 erased the failed-payout stake: open=%+v, want only %q", wallet.open, firstID)
+ }
+}
+
+// TestBlackjackShutdownRefundsFailedPayoutAfterReset: once the round that
+// held the stake has reset (HasBet cleared), a shutdown/eviction refund must
+// still return the pending stake rather than skip the seated player.
+func TestBlackjackShutdownRefundsFailedPayoutAfterReset(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand())
+ table.shoe = tableauShoe(
+ card{rank: "10"}, card{rank: "7"}, card{rank: "10"}, card{rank: "8"},
+ )
+ wallet := &failingPayoutWallet{testWallet{credits: 100}}
+ table.join("alice")
+ table.bet("alice", "", 10, wallet)
+ table.action("alice", "stand")
+ if table.phase != blackjackWaiting {
+ t.Fatalf("setup: the round should have reset (phase=%v)", table.phase)
+ }
+ if len(wallet.open) != 1 {
+ t.Fatalf("setup: a failed payout must leave the stake open, open=%+v", wallet.open)
+ }
+
+ table.refundAll()
+ if len(wallet.open) != 0 {
+ t.Fatalf("shutdown refund skipped the reset round's pending stake: open=%+v", wallet.open)
+ }
+ // The failed payout means the stake was never consumed, so the refund
+ // makes the player whole at 100 (the pre-fix code skipped the seated
+ // player and left them at 90).
+ if got := wallet.credits + wallet.paid; got != 100 {
+ t.Fatalf("refund returned balance %d, want 100 (stake returned once)", got)
+ }
+}
+
+// TestRoomAllowsDistinctSlotVariants: a room may host several variants of one
+// game (each addressed by its variant), while two variant-less machines of the
+// same game remain an unreachable duplicate.
+func TestRoomAllowsDistinctSlotVariants(t *testing.T) {
+ slotVariants["test_second_variant"] = func() SlotProfile { return DefaultHuffAndPuffProfile() }
+ defer delete(slotVariants, "test_second_variant")
+
+ err := ValidateRoomConfigs(nil, []MachineConfig{
+ {ID: "slots_a", Game: GameSlots, Variant: "huff_and_even_more_puff"},
+ {ID: "slots_b", Game: GameSlots, Variant: "test_second_variant"},
+ })
+ if err != nil {
+ t.Fatalf("two distinct slot variants rejected: %v", err)
+ }
+
+ err = ValidateRoomConfigs(nil, []MachineConfig{
+ {ID: "slots_a", Game: GameSlots},
+ {ID: "slots_b", Game: GameSlots},
+ })
+ if err == nil {
+ t.Fatal("two variant-less machines of one game accepted despite being unaddressable")
+ }
+}
+
+// TestSlotsDisconnectDuringPayoutWindowSettlesRevealedWin: once every reel has
+// stopped the result is visible to the player, so a disconnect during the
+// cosmetic payout delay must pay the shown win and consume the stake rather
+// than forfeit both.
+func TestSlotsDisconnectDuringPayoutWindowSettlesRevealedWin(t *testing.T) {
+ machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand())
+ wallet := &testWallet{credits: 100}
+ machine.join("alice")
+ machine.setBet("alice", 10, wallet)
+ machine.spin = &SlotSpin{Payout: 20} // deterministic winning spin
+ for i := 0; i < 20 && machine.phase != machinePayout; i++ {
+ machine.tick()
+ }
+ if machine.phase != machinePayout {
+ t.Fatalf("setup: expected the payout window (phase=%v)", machine.phase)
+ }
+
+ machine.markDisconnected("alice")
+ if wallet.paid != 20 {
+ t.Fatalf("revealed win was not paid on disconnect: paid=%d, want 20", wallet.paid)
+ }
+ if len(wallet.open) != 0 {
+ t.Fatalf("resolved round left a stake outstanding: open=%+v", wallet.open)
+ }
+ if got := wallet.credits + wallet.paid; got != 110 {
+ t.Fatalf("balance after payout = %d, want 110", got)
+ }
+}