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) } // Slots are machines only: a slots entry under casino_tables is rejected // outright rather than treated as a machine. if legacy := ValidateRoomConfigs( []TableConfig{{ID: "slots_table", Game: GameSlots}}, nil, ); legacy == nil { t.Fatal("slots table accepted; slots must be declared under casino_machines") } // 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") } }