diff options
Diffstat (limited to 'internal/casino/hardening_casino_validate_test.go')
| -rw-r--r-- | internal/casino/hardening_casino_validate_test.go | 181 |
1 files changed, 181 insertions, 0 deletions
diff --git a/internal/casino/hardening_casino_validate_test.go b/internal/casino/hardening_casino_validate_test.go new file mode 100644 index 0000000..1d27d54 --- /dev/null +++ b/internal/casino/hardening_casino_validate_test.go @@ -0,0 +1,181 @@ +package casino + +import ( + "strings" + "testing" +) + +// Slot geometry: CalculateRTP/ValidateRTP are generic over Rows/Reels/Ways +// while the runtime evaluator is pinned to the 3x5 grid, so a profile the +// runtime cannot play must be rejected rather than certified. + +func TestSlotProfileGeometryIsValidated(t *testing.T) { + if err := DefaultHuffAndPuffProfile().Validate(); err != nil { + t.Fatalf("the built-in 3x5 profile was rejected: %v", err) + } + // The Ways model may differ from Rows^Reels (evaluateSlot divides by it), + // but the grid may not: a 4-row profile would be certified by the RTP + // gate and then played as a 3-row game. + wide := SlotProfile{ + Rows: 4, Reels: 5, Ways: 256, TargetRTP: 3, + Symbols: []SlotSymbol{{ID: "x", Weight: 1, Payout: map[int]float64{3: 1, 4: 2, 5: 3}}}, + } + if err := ValidateRTP(wide, 1000); err == nil { + t.Fatal("ValidateRTP certified a profile the runtime evaluator cannot play") + } + if err := (MachineConfig{ID: "slots", Game: GameSlots}).Validate(); err != nil { + t.Fatalf("valid slots machine rejected: %v", err) + } + // A profile that only the RTP model supports must not become a machine. + if err := wide.Validate(); err == nil || !strings.Contains(err.Error(), "geometry") { + t.Fatalf("unsupported geometry accepted: %v", err) + } +} + +// Shoe coverage is a boundary heuristic, NOT a hard configuration floor. The +// old bound (11 cards per hand x hands) is not a true maximum — an all-ace +// hand can exceed eleven cards — and enforcing it rejected working tables (a +// one-deck eight-player table; a six-deck eight-player table that holds 312 +// cards against the old 363-card floor). Because a room is refused whole on +// any validation error, that floor silently darkened every casino game in a +// room and blocked the room from being saved. Validation no longer rejects +// on coverage: shoe.ensureForRound reshuffles at the round boundary and +// draw()'s exhausted-shoe rebuild is a deliberate shuffle-through. + +func TestShoeCoverageIsHeuristicNotAHardFloor(t *testing.T) { + // The shipped eight-player geometry still validates at six decks (312 + // cards) even though the old bound demanded 363. + sixDecksEightPlayers := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 8, + Blackjack: BlackjackConfig{Decks: 6}} + if err := sixDecksEightPlayers.Validate(); err != nil { + t.Fatalf("a six-deck eight-player table was rejected: %v", err) + } + // A one-deck shoe, eight players: previously rejected, now plays with a + // boundary reshuffle and draw()'s in-place rebuild. + oneDeck := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 8, + Blackjack: BlackjackConfig{Decks: 1}} + if err := oneDeck.Validate(); err != nil { + t.Fatalf("a one-deck eight-player table was rejected: %v", err) + } + // The common hand-edited room: a single-seat one-deck table. + small := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 1, + Blackjack: BlackjackConfig{Decks: 1}} + if err := small.Validate(); err != nil { + t.Fatalf("a one-deck single-seat table was rejected: %v", err) + } + if err := (TableConfig{ID: "bac", Game: GameBaccarat, Baccarat: BaccaratConfig{Decks: 1}}).Validate(); err != nil { + t.Fatalf("one-deck baccarat rejected: %v", err) + } + + // The boundary reshuffle still fires with the heuristic round size + // (the dealer plus every seat's maximum split hands), and the heuristic + // value is unchanged. + if got, want := blackjackRoundCards(8, 4), 363; got != want { + t.Fatalf("blackjackRoundCards(8,4) = %d, want %d", got, want) + } + need := blackjackRoundCards(1, 1) + s := newShoe(1, 0.25, newTestRand()) + s.cards = s.cards[:need-1] // one card short of the heuristic round + s.ensureForRound(need) + if s.remaining() != 52 { + t.Fatalf("shoe holding %d cards was not reshuffled for a %d-card round", need-1, need) + } + + // draw() on an exhausted shoe rebuilds in place (shuffle-through) rather + // than panicking — the reason coverage cannot be a hard guarantee. + empty := &shoe{decks: 1, rng: newTestRand()} + if card := empty.draw(); card.rank == "" { + t.Fatal("draw() on an exhausted shoe returned an empty card") + } +} + +// Surrender returns half the bet with integer truncation, so a table whose +// minimum wager is one credit would consume the whole stake on a surrender. + +func TestSurrenderRequiresATwoCreditMinimum(t *testing.T) { + surrender := true + oneCredit := TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 1, + Blackjack: BlackjackConfig{Surrender: &surrender}} + if err := oneCredit.Validate(); err == nil { + t.Fatal("surrender accepted with a one-credit minimum; half a credit rounds to zero") + } + // min_bet 0 normalizes to 1, so it is rejected too. + if err := (TableConfig{ID: "bj", Game: GameBlackjack, + Blackjack: BlackjackConfig{Surrender: &surrender}}).Validate(); err == nil { + t.Fatal("surrender accepted with the default one-credit minimum") + } + twoCredits := TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 2, + Blackjack: BlackjackConfig{Surrender: &surrender}} + if err := twoCredits.Validate(); err != nil { + t.Fatalf("surrender with a two-credit minimum rejected: %v", err) + } + // A table without surrender keeps its one-credit minimum. + if err := (TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 1}).Validate(); err != nil { + t.Fatalf("plain one-credit table rejected: %v", err) + } +} + +// The shoe refills at a round boundary, never between draws: an exhausted +// shoe rebuilt mid-round would deal cards the round already holds. + +func TestShoeRefillsOnlyAtRoundBoundaries(t *testing.T) { + need := blackjackRoundCards(1, 1) + shoe := newShoe(1, 0.25, newTestRand()) + shoe.cards = shoe.cards[:need-1] // one card short of a maximum round + shoe.ensureForRound(need) + if shoe.remaining() != 52 { + t.Fatalf("shoe holding %d cards was not refilled for a %d-card round", need-1, need) + } + + before := shoe.remaining() + shoe.ensureForRound(need) + if shoe.remaining() != before { + t.Fatalf("a shoe that already covers the round was reshuffled: %d -> %d", before, shoe.remaining()) + } + + // A shoe without a deck composition is an explicit card script (tests); + // it must never be replaced by a fresh shuffled deck. + scripted := tableauShoe(card{rank: "A"}) + scripted.ensureForRound(need) + if scripted.remaining() != 1 { + t.Fatalf("scripted shoe was replaced: %d cards remain", scripted.remaining()) + } +} + +// A room addresses one table/machine per game name ("play blackjack"), so a +// second entry for the same game is unreachable content, not a second table. + +func TestRoomRejectsASecondEntryForOneGame(t *testing.T) { + twoBlackjackTables := []TableConfig{ + {ID: "bj1", Game: GameBlackjack}, + {ID: "bj2", Game: GameBlackjack}, + } + err := ValidateRoomConfigs(twoBlackjackTables, nil) + if err == nil || !strings.Contains(err.Error(), "duplicate casino game") { + t.Fatalf("two blackjack tables accepted: %v", err) + } + // Distinct games, and a machine whose variant name is also addressable. + ok := ValidateRoomConfigs( + []TableConfig{{ID: "blackjack", Game: GameBlackjack}, {ID: "baccarat", Game: GameBaccarat}}, + []MachineConfig{{ID: "slots", Game: GameSlots}}, + ) + if ok != nil { + t.Fatalf("one entry per game rejected: %v", ok) + } + // A legacy slots table and a slots machine both answer to "slots". + legacy := ValidateRoomConfigs( + []TableConfig{{ID: "legacy_slots", Game: GameSlots}}, + []MachineConfig{{ID: "slots", Game: GameSlots}}, + ) + if legacy == nil || !strings.Contains(legacy.Error(), "duplicate casino game") { + t.Fatalf("legacy slots table plus a slots machine accepted: %v", legacy) + } + // Two machines of the same variant are equally unreachable. + twoVariants := ValidateRoomConfigs(nil, []MachineConfig{ + {ID: "slots_a", Game: GameSlots, Variant: "huff_and_even_more_puff"}, + {ID: "slots_b", Game: GameSlots, Variant: "huff_and_even_more_puff"}, + }) + if twoVariants == nil { + t.Fatal("two machines of one variant accepted") + } +} |
