aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/hardening_casino_validate_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/hardening_casino_validate_test.go')
-rw-r--r--internal/casino/hardening_casino_validate_test.go181
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")
+ }
+}