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 --- internal/casino/baccarat.go | 29 +++- internal/casino/blackjack.go | 78 +++++++-- internal/casino/cards.go | 31 +++- internal/casino/config_validate.go | 89 +++++++++- internal/casino/correct_casino_refund_bank_test.go | 101 +++++++++++ .../casino/correct_casino_room_validate_test.go | 36 ++++ internal/casino/credits.go | 16 -- internal/casino/credits_test.go | 13 -- .../casino/hardening_casino_disconnect_test.go | 102 ++++++++++++ internal/casino/hardening_casino_locking_test.go | 84 ++++++++++ internal/casino/hardening_casino_refund_test.go | 145 ++++++++++++++++ internal/casino/hardening_casino_rng_test.go | 75 +++++++++ internal/casino/hardening_casino_validate_test.go | 181 ++++++++++++++++++++ internal/casino/machine.go | 184 ++++++++++++++++----- internal/casino/manager.go | 47 +++++- internal/casino/polish_refund_forfeit_test.go | 24 +++ internal/casino/slots.go | 31 +++- internal/casino/types.go | 2 +- 18 files changed, 1155 insertions(+), 113 deletions(-) create mode 100644 internal/casino/correct_casino_refund_bank_test.go create mode 100644 internal/casino/correct_casino_room_validate_test.go create mode 100644 internal/casino/hardening_casino_disconnect_test.go create mode 100644 internal/casino/hardening_casino_locking_test.go create mode 100644 internal/casino/hardening_casino_refund_test.go create mode 100644 internal/casino/hardening_casino_rng_test.go create mode 100644 internal/casino/hardening_casino_validate_test.go create mode 100644 internal/casino/polish_refund_forfeit_test.go (limited to 'internal/casino') diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index f4f5fbd..231a011 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -173,6 +173,8 @@ func (t *baccaratTable) action(name, action string) []Event { } func (t *baccaratTable) rebet(name string) (string, int) { + t.mu.Lock() + defer t.mu.Unlock() if p := t.players.get(name); p != nil && p.lastBet > 0 { return p.lastSpot, p.lastBet } @@ -182,8 +184,13 @@ func (t *baccaratTable) rebet(name string) (string, int) { // deal plays out the hand by the punto banco tableau, settles every wager, // and resets the table for the next round. func (t *baccaratTable) deal() []Event { - // The cut-card reshuffle happens between rounds, never mid-hand. + // The cut-card reshuffle and the round-coverage guarantee both happen at + // this boundary, before a single card of the coup is dealt (deal() is + // synchronous, so a wager here has never had a card dealt for it): + // draw() rebuilds an exhausted shoe in place, which mid-coup could deal + // a card the coup already holds. t.shoe.shuffleIfDue() + t.shoe.ensureForRound(baccaratRoundCards) player, banker := playTableau(t.shoe) playerTotal, bankerTotal := baccaratTotal(player), baccaratTotal(banker) round := t.event("New baccarat hand!", true) @@ -265,19 +272,20 @@ func (t *baccaratTable) resetRound() { } } -// markDisconnected removes the player from the table, forfeiting any wager -// they committed (deal only settles seated bettors). An open betting window -// resolves or resets on the next tick now that they no longer hold it open. +// markDisconnected removes the player from the table. A committed wager is +// REFUNDED rather than forfeited: deal() is synchronous, so a seated bettor +// whose wager is still outstanding has had no card dealt for it (the coup +// only starts once every connected seat has bet) and nothing is revealed by +// returning the stake. An open betting window then resolves or resets on the +// next tick now that they no longer hold it open. func (t *baccaratTable) markDisconnected(name string) []Event { t.mu.Lock() defer t.mu.Unlock() if !t.players.has(name) { return nil } - // A disconnected player forfeits the round in flight: resolve the - // stakes now so startup recovery does not refund them later. if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { - p.Wallet.ResolveStakes() + p.Wallet.RefundOutstanding() } t.players.remove(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} @@ -395,9 +403,11 @@ func baccaratResultMessages(name, result string, payout int) (string, string) { } } -// refundAll resolves every committed wager at this table (graceful +// refundAll refunds every committed wager at this table (graceful // shutdown/eviction): the wallet returns every outstanding stake exactly -// once. +// once. It then RESETS the round, so a refunded round can never settle (and +// pay) afterwards: calling it twice, or letting the table keep running, +// cannot pay the same round twice. func (t *baccaratTable) refundAll() []Event { t.mu.Lock() defer t.mu.Unlock() @@ -419,6 +429,7 @@ func (t *baccaratTable) refundAll() []Event { fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund), fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund))) } + t.resetRound() return events } diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 0a0551c..7b30699 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -72,12 +72,22 @@ type blackjackTable struct { phase blackjackPhase window bettingWindow timer countdown + ceiling countdown activePlayer int activeHand int dealer blackjackHand shoe *shoe } +// loneTurnGrace multiplies the action window into an absolute per-turn +// ceiling. A lone connected player is deliberately never timed out by the +// action timer (a solo player's think time must not end the hand), but +// "never" cannot mean forever: a hung-but-connected client would hold the +// hand, the seat, and the stake open with no automatic resolution, since +// "leave" refuses mid-round. After this many ticks on one turn the table +// stands the hand for the player and plays on. +const loneTurnGrace = 10 + func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTable { cfg = cfg.Normalize() rules := cfg.Blackjack.Normalize() @@ -209,6 +219,18 @@ func (t *blackjackTable) tick() []Event { t.activePlayer++ return t.finishHand() } + // The absolute turn ceiling bounds every turn, including a lone + // connected player's: the action timer below deliberately never + // expires for a solo player, so without this a hung-but-connected + // client would hold the stake and the phase open forever. + t.ceiling.tick() + if t.ceiling.expired() { + active.hands[t.activeHand].stood = true + events := []Event{t.private(name, "You've been idle too long; standing.")} + // Restart the action timer so the next hand gets a full window. + t.timer.start(t.Rules.ActionTicks) + return append(events, t.finishHand()...) + } if active.Connected && t.bettorCount() <= 1 { // A lone connected player is never timed out. A disconnected // active player always is, so the table can never stall. @@ -395,8 +417,13 @@ func splitCompatible(a, b card, unlikeTens bool) bool { } func (t *blackjackTable) startRound() []Event { - // The cut-card reshuffle happens between rounds, never mid-hand. + // The cut-card reshuffle and a best-effort round-coverage reshuffle + // happen at this boundary, before a single card of the round is dealt, + // so a boundary reshuffle cannot redeal a card the round already holds. + // draw() can still rebuild an exhausted shoe mid-round (a deliberate + // shuffle-through), so this is a heuristic, not a guarantee. t.shoe.shuffleIfDue() + t.shoe.ensureForRound(t.roundCardCeiling()) t.dealer = blackjackHand{cards: []card{t.shoe.draw(), t.shoe.draw()}} for _, p := range t.players.ordered() { if !p.HasBet { @@ -440,10 +467,23 @@ func (t *blackjackTable) beginPlay() { t.activePlayer, t.activeHand = 0, 0 } +// roundCardCeiling is the heuristic number of cards this table's round is +// assumed to consume (see blackjackRoundCards): the dealer's hand plus every +// seat's maximum number of split hands. startRound uses it to decide whether +// to reshuffle at the round boundary. It is NOT a certified maximum (an +// all-ace hand can exceed the per-hand estimate), so the shoe is not +// guaranteed to hold it and draw() may still rebuild an exhausted shoe +// mid-round (a deliberate shuffle-through). +func (t *blackjackTable) roundCardCeiling() int { + return blackjackRoundCards(t.Config.MaxPlayers, t.Rules.MaxSplitHands) +} + func (t *blackjackTable) prompt() []Event { // Every freshly-prompted turn gets a full action window; rejected inputs - // and timeouts can never extend the deadline. + // and timeouts can never extend the deadline. The turn ceiling restarts + // with the window for the same reason. t.timer.start(t.Rules.ActionTicks) + t.ceiling.start(t.Rules.ActionTicks * loneTurnGrace) name := t.activeName() if name == "" { return t.settle() @@ -669,6 +709,7 @@ func (t *blackjackTable) resetRound() { t.phase = blackjackWaiting t.window.close() t.timer.stop() + t.ceiling.stop() for _, p := range t.players.ordered() { p.HasBet, p.Wager, p.hands, p.insurance, p.insuranceStakeID, p.declinedInsurance = false, Wager{}, nil, 0, "", false } @@ -685,6 +726,8 @@ func (t *blackjackTable) resolveHandStakes(wallet Wallet, hand *blackjackHand) { } func (t *blackjackTable) rebet(name string) (string, int) { + t.mu.Lock() + defer t.mu.Unlock() if p := t.players.get(name); p != nil && p.lastBet > 0 { return "", p.lastBet } @@ -753,10 +796,13 @@ func blackjackResultMessages(name, result string, payout int) (string, string) { } } -// markDisconnected removes the player from the table, forfeiting any wager -// they committed (settle only pays seated bettors). Removing a seat shifts -// the seat indices the active turn points into, so mid-round play is fixed -// up and handed to the next bettor (or settled) immediately. +// markDisconnected removes the player from the table. A wager is forfeited +// only when a hand is actually in flight (cards are face up, so a free +// refund would hand back information the round carries); a stake placed in +// the betting window has revealed nothing yet and is REFUNDED, so a +// transient disconnect cannot destroy an undelt wager. Removing a seat +// shifts the seat indices the active turn points into, so mid-round play is +// fixed up and handed to the next bettor (or settled) immediately. func (t *blackjackTable) markDisconnected(name string) []Event { t.mu.Lock() defer t.mu.Unlock() @@ -768,10 +814,16 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { if index < 0 { return nil } - // A disconnected player forfeits the round in flight: resolve the - // stakes now so startup recovery does not refund them later. if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { - p.Wallet.ResolveStakes() + if t.phase == blackjackPlaying || t.phase == blackjackInsurance { + // A disconnected player forfeits the round in flight: resolve the + // stakes now so startup recovery does not refund them later. + p.Wallet.ResolveStakes() + } else { + // No card has been dealt for this stake yet, so refund it: nothing + // the bettor paid for has been revealed or played. + p.Wallet.RefundOutstanding() + } } t.players.remove(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} @@ -801,9 +853,12 @@ func (t *blackjackTable) hasParticipant(name string) bool { return t.players.has(name) } -// refundAll resolves every committed wager at this table (graceful +// refundAll refunds every committed wager at this table (graceful // shutdown/eviction): the wallet returns every outstanding stake — opening -// bet, doubles, splits, and insurance — exactly once. +// bet, doubles, splits, and insurance — exactly once. It then RESETS the +// round, so a refunded round can never settle (and pay) afterwards: calling +// it twice, or letting the table keep running, cannot pay the same round +// twice. func (t *blackjackTable) refundAll() []Event { t.mu.Lock() defer t.mu.Unlock() @@ -825,6 +880,7 @@ func (t *blackjackTable) refundAll() []Event { fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund), fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund))) } + t.resetRound() return events } diff --git a/internal/casino/cards.go b/internal/casino/cards.go index b1c2cbb..27da8aa 100644 --- a/internal/casino/cards.go +++ b/internal/casino/cards.go @@ -68,12 +68,17 @@ func (s *shoe) shuffle() { s.rng.Shuffle(len(s.cards), func(i, j int) { s.cards[i], s.cards[j] = s.cards[j], s.cards[i] }) } +// remaining reports the cards left in the shoe. +func (s *shoe) remaining() int { return len(s.cards) } + func (s *shoe) draw() card { if len(s.cards) == 0 { - // The shoe ran dry mid-round (only possible when the configured - // deck count is very small relative to the table): rebuild it - // rather than panic. The cut-card reshuffle itself happens - // between rounds via shuffleIfDue, never per draw. + // Shuffle-through: if the shoe runs out mid-round it is rebuilt from + // all decks, so a card already dealt this round can reappear. This + // matches real blackjack, where an exhausted shoe is shuffled through + // mid-round; ensureForRound makes it rare by reshuffling at round + // boundaries, and this keeps a directly-constructed table from + // panicking. s.shuffle() } card := s.cards[len(s.cards)-1] @@ -81,6 +86,24 @@ func (s *shoe) draw() card { return card } +// ensureForRound reshuffles when the shoe is unlikely to cover the round that +// is about to be dealt. It MUST be called at a round boundary, before any card +// of that round is dealt: a rebuild there cannot redeal a card the current +// round already holds, which is exactly what draw()'s exhausted-shoe +// shuffle-through does. need is a heuristic estimate of the cards one round +// can consume (see blackjackRoundCards/baccaratRoundCards), not a certified +// maximum, so coverage is best-effort. +func (s *shoe) ensureForRound(need int) { + if s.decks <= 0 { + // No deck composition to refill from: this is a synthetic shoe with + // an explicit card script (tests), which must not be replaced. + return + } + if len(s.cards) < need { + s.shuffle() + } +} + // shuffleIfDue reshuffles the shoe once its remaining cards have fallen // to the shuffleAt fraction (the cut card). It must be called at round // boundaries only — shuffling between individual draws would let cards diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go index 8e5e7b5..ee8e8c9 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -25,7 +25,10 @@ func (c MachineConfig) Validate() error { if !finiteNonnegative(c.Payout) || c.Payout > 1 { return fmt.Errorf("payout must be finite and between 0 and 1 (0 uses the default)") } - return nil + // The machine's effective profile is the built-in one scaled to the + // configured RTP; ValidateRTP certifies that profile, so it must be a + // geometry the runtime evaluator can actually play. + return machineSlotProfile(c).Validate() } func (c TableConfig) Validate() error { @@ -47,6 +50,18 @@ func (c TableConfig) Validate() error { if !finiteNonnegative(b.BlackjackPayout) || !finiteNonnegative(b.InsurancePayout) { return fmt.Errorf("blackjack_payout and insurance_payout must be finite and nonnegative") } + rules := b.Normalize() + if rules.AllowsSurrender() && c.Normalize().MinBet < minimumSurrenderBet { + return fmt.Errorf("surrender needs min_bet >= %d: a surrendered hand returns half the bet, which rounds to zero below that", minimumSurrenderBet) + } + // Shoe coverage is NOT a configuration floor. blackjackRoundCards is + // only a boundary heuristic (an all-ace hand can hold more than + // eleven cards), so a hard floor would reject tables that play fine + // and, because a room is refused whole on any error, would darken + // every casino game in it. shoe.ensureForRound reshuffles at the + // round boundary and draw()'s exhausted-shoe rebuild is a deliberate + // shuffle-through instead. + return nil case GameBaccarat: b := c.Baccarat if err := validateShoe(b.Decks, b.ShuffleAt); err != nil { @@ -55,10 +70,11 @@ func (c TableConfig) Validate() error { if !finiteNonnegative(b.TiePayout) || !finiteNonnegative(b.BankerPayout) { return fmt.Errorf("tie_payout and banker_payout must be finite and nonnegative") } + // Non-blocking for the same reason as blackjack above. + return nil default: return fmt.Errorf("unsupported table game %q", c.Game) } - return nil } func validateIdentityAndBets(id string, minBet, maxBet int) error { @@ -93,8 +109,58 @@ func validateShoe(decks int, shuffleAt float64) error { return nil } +// minimumSurrenderBet is the smallest wager a surrendered hand can return +// half of: half of a one-credit bet truncates to zero, which would consume +// the whole stake. +const minimumSurrenderBet = 2 + +// blackjackRoundCards is the heuristic per-round card budget the shoe uses +// to decide whether to reshuffle before a round: an estimated eleven cards +// per hand times the most hands the table can have in play (the dealer's +// hand plus every seat's maximum number of split hands). It is NOT a +// certified maximum — an all-ace hand can exceed eleven cards — so it only +// sizes the best-effort shoe.ensureForRound boundary reshuffle and must not +// be turned back into a hard configuration floor. +func blackjackRoundCards(maxPlayers, maxSplitHands int) int { + return 11 * (1 + maxPlayers*maxSplitHands) +} + +// baccaratRoundCards is the maximum number of cards one baccarat coup can +// consume: three per hand (baccarat's fixed tableau never draws more). +const baccaratRoundCards = 6 + +// Validate checks a slot profile the machine can actually play. The runtime +// evaluator (generateSlotSpinWithProfile, matchingLength, stoppedScatters and +// evaluateSlot) is pinned to the SlotSpin grid of three rows by five reels; +// CalculateRTP/ValidateRTP are generic over Rows/Reels/Ways, so a profile +// with another geometry would be certified by the RTP gate and then played as +// a different game. Rejecting the geometry here keeps that gate meaningful. +func (p SlotProfile) Validate() error { + profile := p.Normalize() + if profile.Rows != slotGridRows || profile.Reels != slotGridReels { + return fmt.Errorf("unsupported slot geometry %dx%d: the runtime evaluator supports %dx%d only", + profile.Rows, profile.Reels, slotGridRows, slotGridReels) + } + return nil +} + +// machineSlotProfile builds the profile a machine configured by c plays: the +// built-in paytable, scaled to the configured RTP. One source of truth for +// validation and for newMachine. +func machineSlotProfile(c MachineConfig) SlotProfile { + profile := DefaultHuffAndPuffProfile() + if c.Payout > 0 { + profile = ScaleToRTP(profile, c.Payout) + } + return profile.Normalize() +} + // ValidateRoomConfigs checks the shared room-local ID namespace as well as // individual definitions. Legacy slots under casino_tables remain supported. +// +// It also enforces one table/machine per game per room: a room is addressed +// by game name ("play blackjack"), so a second entry for the same game (or +// the same machine variant) could never be reached. func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error { seen := make(map[string]bool) var errs []error @@ -107,16 +173,31 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error { errs = append(errs, fmt.Errorf("%s %q: %w", kind, id, err)) } } + addressable := make(map[string]string) + checkAddressable := func(key, id string) { + if owner, ok := addressable[key]; ok { + errs = append(errs, fmt.Errorf("duplicate casino game %q in one room (%q and %q): only one table or machine per game name is addressable", key, owner, id)) + return + } + addressable[key] = id + } for _, c := range tables { if c.Game == GameSlots { + // A slots "table" is instantiated as the room's slots machine. legacy := MachineConfig{ID: c.ID, Game: GameSlots, MinBet: c.MinBet, MaxBet: c.MaxBet} check("legacy slots", c.ID, legacy.Validate()) - } else { - check("table", c.ID, c.Validate()) + checkAddressable(strings.ToLower(string(GameSlots)), c.ID) + continue } + check("table", c.ID, c.Validate()) + checkAddressable(strings.ToLower(string(c.Game)), c.ID) } for _, c := range machines { check("machine", c.ID, c.Validate()) + checkAddressable(strings.ToLower(string(c.Game)), c.ID) + if c.Variant != "" { + checkAddressable(strings.ToLower(c.Variant), c.ID) + } } return errors.Join(errs...) } diff --git a/internal/casino/correct_casino_refund_bank_test.go b/internal/casino/correct_casino_refund_bank_test.go new file mode 100644 index 0000000..fd90ae1 --- /dev/null +++ b/internal/casino/correct_casino_refund_bank_test.go @@ -0,0 +1,101 @@ +package casino + +import "testing" + +// Free-spin winnings live only in Machine.featureBank until the feature ends, +// so a graceful shutdown (or room eviction) that zeroes the bank without +// paying it would destroy winnings that had already landed. refundAll must +// settle the bank through the wallet in addition to refunding the triggering +// stake, and must keep the stake refundable (not double-paid) when either +// side of that settlement fails. + +// TestMachineRefundAllPreservesBankedFeatureWinnings: a nonzero banked bonus +// survives RefundAll while the triggering stake is refunded exactly once. +func TestMachineRefundAllPreservesBankedFeatureWinnings(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand()) + wallet := &testWallet{credits: 100} + m.join("alice") + m.beginSpinLocked(10, wallet, "", "") + m.spin = &SlotSpin{Payout: 25, ScatterCount: 3} + m.finishSpin() // triggers the feature: 25 banked, the stake stays open + if m.featureBank != 25 || !m.inFreeSpins { + t.Fatalf("setup: featureBank=%v inFreeSpins=%v, want 25/true", m.featureBank, m.inFreeSpins) + } + if wallet.paid != 0 { + t.Fatalf("setup: a payout was journaled mid-feature (%d)", wallet.paid) + } + + events := m.refundAll() + // 10 for the refunded stake plus the 25 banked credits. + if wallet.paid != 35 { + t.Fatalf("refundAll paid %d, want 35 (10 stake + 25 banked winnings)", wallet.paid) + } + if got := wallet.credits + wallet.paid; got != 125 { + t.Fatalf("banked winnings were destroyed: balance %d, want 125", got) + } + if !hasMessage(events, "25") { + t.Fatalf("missing banked-winnings settlement message: %+v", events) + } + if len(wallet.open) != 0 { + t.Fatalf("stake still outstanding after shutdown: %+v", wallet.open) + } +} + +// TestMachineRefundAllFailedBankedPayoutKeepsStakeRefunded: if the banked +// settlement cannot be persisted, the triggering stake is still refunded +// exactly once (not paid twice) and the failure is reported rather than +// silently swallowing the win. +func TestMachineRefundAllFailedBankedPayoutKeepsStakeRefunded(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand()) + wallet := &failingPayoutWallet{testWallet{credits: 100}} + m.join("alice") + m.beginSpinLocked(10, wallet, "", "") + m.featureBank = 25 + + events := m.refundAll() + if wallet.paid != 10 { + t.Fatalf("stake refund paid %d, want 10 once", wallet.paid) + } + if len(wallet.open) != 0 { + t.Fatalf("stake still outstanding: %+v", wallet.open) + } + if !hasMessage(events, "could not be persisted") { + t.Fatalf("a failed banked payout was not reported: %+v", events) + } +} + +// failingRefundWallet rejects every refund, mirroring the production wallet's +// persistence-failure path: the stakes stay open for recovery. +type failingRefundWallet struct { + testWallet +} + +func (w *failingRefundWallet) RefundOutstanding() (int, int, error) { + return 0, 0, ErrWalletPersistence +} + +// TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery pins the +// load-bearing stakeFailed guard in refundAll: resetSeat must not resolve +// (forfeit) the stake RefundOutstanding left open, or the recovery refund +// contract is broken. Deleting `m.stakeFailed = err != nil` fails this test. +func TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand()) + wallet := &failingRefundWallet{testWallet{credits: 100}} + m.join("alice") + m.beginSpinLocked(10, wallet, "", "") + if len(wallet.open) != 1 { + t.Fatalf("setup: the stake must be open, got %+v", wallet.open) + } + m.featureBank = 25 + + events := m.refundAll() + if len(wallet.open) != 1 { + t.Fatalf("a failed refund forfeited the open stake; want it preserved for recovery: %+v", wallet.open) + } + if wallet.paid != 0 { + t.Fatalf("a failed refund paid %d; want 0", wallet.paid) + } + if !hasMessage(events, "recorded for recovery") { + t.Fatalf("the failed refund was not reported: %+v", events) + } +} diff --git a/internal/casino/correct_casino_room_validate_test.go b/internal/casino/correct_casino_room_validate_test.go new file mode 100644 index 0000000..319a6b6 --- /dev/null +++ b/internal/casino/correct_casino_room_validate_test.go @@ -0,0 +1,36 @@ +package casino + +import "testing" + +// Removing the shoe-coverage configuration floor must not disturb legitimate +// rooms: a room whose geometry validated before still validates, and one the +// over-strict floor previously rejected (but that plays fine) now validates +// too — so the room is neither darkened nor blocked from the admin editor. + +func TestRoomWithShippedGeometryStillValidates(t *testing.T) { + // The shipped intro room (data/rooms/intro/2001.yaml): blackjack, + // baccarat, and a slots machine in one room. + tables := []TableConfig{ + {ID: "blackjack", Game: GameBlackjack, MaxPlayers: 6, BettingTicks: 20, + MinBet: 1, MaxBet: 1000, + Blackjack: BlackjackConfig{Decks: 8, ShuffleAt: 0.25, BlackjackPayout: 1.5}}, + {ID: "baccarat", Game: GameBaccarat, MaxPlayers: 6, BettingTicks: 20, + MinBet: 1, MaxBet: 1000, + Baccarat: BaccaratConfig{Decks: 8, ShuffleAt: 0.25}}, + } + machines := []MachineConfig{{ + ID: "huff_puff_01", Game: GameSlots, Variant: "huff_and_even_more_puff", + MinBet: 1, MaxBet: 1000, Payout: 0.97, + }} + if err := ValidateRoomConfigs(tables, machines); err != nil { + t.Fatalf("the shipped casino room geometry no longer validates: %v", err) + } + + // A six-deck eight-player blackjack table that the old 11*(1+P*S) floor + // rejected (needs 363 cards, holds 312) now validates. + small := []TableConfig{{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 8, + Blackjack: BlackjackConfig{Decks: 6}}} + if err := ValidateRoomConfigs(small, nil); err != nil { + t.Fatalf("a previously-rejected but playable room still fails validation: %v", err) + } +} diff --git a/internal/casino/credits.go b/internal/casino/credits.go index 2b4e2d7..8b7cb7a 100644 --- a/internal/casino/credits.go +++ b/internal/casino/credits.go @@ -30,22 +30,6 @@ var ( ErrWalletPersistence = errors.New("wallet persistence failure") ) -// AddCredits adds two balances with an explicit cap, reporting overflow. -// A negative addend is rejected (payouts never decrease a balance). -func AddCredits(a, b int) (int, error) { - if b < 0 { - return a, errors.New("negative credit add") - } - sum := a + b - if b > 0 && sum < a || a > 0 && sum < b { - return MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, MaxCredits) - } - if sum > MaxCredits { - return MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, MaxCredits) - } - return sum, nil -} - // MulPayout scales an amount by a payout multiplier and converts to whole // credits (floor, like a house paying in chips). Overflow past MaxCredits // is an error, not a silent wrap. A non-finite or negative multiplier is diff --git a/internal/casino/credits_test.go b/internal/casino/credits_test.go index cff19c9..eed12fa 100644 --- a/internal/casino/credits_test.go +++ b/internal/casino/credits_test.go @@ -4,19 +4,6 @@ import ( "testing" ) -func TestAddCreditsChecked(t *testing.T) { - sum, err := AddCredits(5, 7) - if err != nil || sum != 12 { - t.Fatalf("AddCredits(5,7) = %d, %v", sum, err) - } - if sum, err := AddCredits(MaxCredits, 1); err == nil || sum != MaxCredits { - t.Fatalf("overflow add: %d, %v", sum, err) - } - if _, err := AddCredits(MaxCredits, -1); err == nil { - t.Fatal("negative add must be rejected") - } -} - func TestMulPayoutBoundsAndConverts(t *testing.T) { payout, err := MulPayout(10, 1.5) if err != nil || payout != 15 { 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) + } +} diff --git a/internal/casino/hardening_casino_locking_test.go b/internal/casino/hardening_casino_locking_test.go new file mode 100644 index 0000000..080d941 --- /dev/null +++ b/internal/casino/hardening_casino_locking_test.go @@ -0,0 +1,84 @@ +package casino + +import ( + "sync" + "testing" + "time" +) + +// rebet is called through Manager.Rebet and reads the seat map; it must take +// the table lock like every other game method, or a future caller off the +// owner goroutine would race the live seat state. + +func TestRebetTakesTheTableLock(t *testing.T) { + blackjack := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) + blackjack.join("alice") + assertRebetWaitsForLock(t, "blackjack", &blackjack.mu, func() { blackjack.rebet("alice") }) + + baccarat := newBaccaratTable(1, TableConfig{ID: "baccarat", Game: GameBaccarat}, newTestRand()) + baccarat.join("alice") + assertRebetWaitsForLock(t, "baccarat", &baccarat.mu, func() { baccarat.rebet("alice") }) +} + +func assertRebetWaitsForLock(t *testing.T, name string, mu *sync.Mutex, rebet func()) { + t.Helper() + mu.Lock() + done := make(chan struct{}) + go func() { + rebet() + close(done) + }() + select { + case <-done: + mu.Unlock() + t.Fatalf("%s rebet read the seat map without the table lock", name) + case <-time.After(50 * time.Millisecond): + } + mu.Unlock() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("%s rebet did not return after the lock was released", name) + } +} + +// A lone connected player is deliberately never timed out by the action +// window. The absolute turn ceiling must therefore end that turn, or a +// hung-but-connected client holds the hand, the seat, and the stake forever +// (leave refuses mid-round). + +func TestLoneBettorTurnCeilingSettlesTheHand(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, + Blackjack: BlackjackConfig{ActionTicks: 2}}, newTestRand()) + table.shoe = tableauShoe( + card{rank: "10"}, card{rank: "7"}, // dealer 17 + card{rank: "10"}, card{rank: "6"}, // alice 16 + ) + wallet := &testWallet{credits: 100} + table.join("alice") + table.bet("alice", "", 10, wallet) + if table.phase != blackjackPlaying { + t.Fatalf("setup: expected a dealt hand (phase=%s)", table.phase) + } + + // The action timer alone would have expired after two ticks, but the + // lone bettor gets the full grace period: 10x the action window. + ceiling := table.Rules.ActionTicks * loneTurnGrace + for i := 0; i < ceiling-1; i++ { + table.tick() + } + if table.phase != blackjackPlaying { + t.Fatalf("lone bettor's hand ended before the turn ceiling (phase=%s, ceiling=%d)", table.phase, ceiling) + } + + events := table.tick() + if table.phase != blackjackWaiting { + t.Fatalf("turn ceiling did not settle the hand: phase=%s events=%+v", table.phase, events) + } + if !hasMessage(events, "idle too long") { + t.Fatalf("missing turn-ceiling message: %+v", events) + } + if len(wallet.open) != 0 { + t.Fatalf("stake still committed after the ceiling stood the hand: %+v", wallet.open) + } +} diff --git a/internal/casino/hardening_casino_refund_test.go b/internal/casino/hardening_casino_refund_test.go new file mode 100644 index 0000000..7b98be8 --- /dev/null +++ b/internal/casino/hardening_casino_refund_test.go @@ -0,0 +1,145 @@ +package casino + +import ( + "math/rand" + "testing" +) + +// RefundAll (graceful shutdown / room eviction) must not leave a refunded +// round alive: a round that settles after its stake was returned pays the +// same round twice and mints credits. + +// TestRefundAllDiscardsTheRefundedMachineRound: the review's repro — bet, +// refund everything, then keep ticking. The machine must not pay the spin +// whose stake the refund returned, and a second RefundAll must not refund +// again. +func TestRefundAllDiscardsTheRefundedMachineRound(t *testing.T) { + m := NewManager(1) + cfg := MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 50} + m.JoinMachine(1, cfg, "alice") + wallet := &testWallet{credits: 100} + if events := m.SetMachineBet(1, "slots", "alice", 10, wallet); len(events) == 0 { + t.Fatal("setting a bet produced no events") + } + // Force a paying spin, so a live round WOULD pay after the refund. + machine := m.machines[tableKey(1, "slots")] + machine.mu.Lock() + machine.spin = &SlotSpin{Payout: 20} + machine.mu.Unlock() + + m.RefundAll() + if wallet.paid != 10 { + t.Fatalf("refund paid %d, want the 10-credit stake", wallet.paid) + } + // Drive the machine to (what used to be) the end of its spin. + for i := 0; i < 60; i++ { + m.Tick() + } + if got := wallet.credits + wallet.paid; got != 100 { + t.Fatalf("refunded spin paid again: balance %d, want 100 (10 refunded once, no 20-credit payout)", got) + } + if machine.phase != machineReady { + t.Fatalf("refunded machine kept its round: phase=%s", machine.phase) + } + + // A second shutdown (or eviction) must not double-pay either. + m.RefundAll() + if got := wallet.credits + wallet.paid; got != 100 { + t.Fatalf("second RefundAll double-paid: balance %d", got) + } +} + +// TestRefundAllDiscardsTheRefundedTableRound: same rule at a card table. The +// refunded round is reset, so the later settlement/split path cannot pay the +// stake the refund already returned. +func TestRefundAllDiscardsTheRefundedTableRound(t *testing.T) { + m := NewManager(1) + cfg := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 2, BettingTicks: 5} + m.Join(1, cfg, "alice") + m.Join(1, cfg, "bob") + wallet := &testWallet{credits: 100} + // Only alice bets: the window stays open with no cards dealt. + m.Bet(1, "bj", "alice", "", 10, wallet) + + m.RefundAll() + table := m.Table(1, "bj").game.(*blackjackTable) + if table.phase != blackjackWaiting || table.players.get("alice").HasBet { + t.Fatalf("refunded table kept its round: phase=%s hasBet=%v", table.phase, table.players.get("alice").HasBet) + } + // The window can no longer deal (the wager is gone) and nothing pays. + for i := 0; i < 20; i++ { + m.Tick() + } + if got := wallet.credits + wallet.paid; got != 100 { + t.Fatalf("refunded table round paid again: balance %d, want 100", got) + } +} + +// Free-spin bookkeeping: recovery reads ANY journaled payout for the player +// as proof that the round resolved (Wallet.PayCredits carries no stake +// linkage), so a feature that paid each spin as it landed would make a crash +// mid-bonus silently keep the triggering stake. The machine must journal +// nothing until the feature — and with it the round — completes. + +// TestSlotsFeatureWinningsAreBankedNotJournaled: the triggering stake stays +// outstanding and no payout at all is made while free spins remain. +func TestSlotsFeatureWinningsAreBankedNotJournaled(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) + wallet := &testWallet{credits: 100} + m.join("alice") + m.beginSpinLocked(10, wallet, "", "") + m.spin = &SlotSpin{Payout: 25, ScatterCount: 3} + events := m.finishSpin() + if !hasMessage(events, "trigger") { + t.Fatalf("setup: feature did not trigger: %+v", events) + } + if wallet.paid != 0 { + t.Fatalf("a payout was journaled mid-feature (%d credits): recovery would read it as the round resolving", wallet.paid) + } + if len(wallet.open) != 1 { + t.Fatalf("the triggering stake must stay outstanding through the feature, open=%+v", wallet.open) + } + if m.featureBank != 25 { + t.Fatalf("featureBank=%v, want the trigger spin's 25 credits banked", m.featureBank) + } + + // A free spin wins too and is banked as well. + m.spin = &SlotSpin{Payout: 40} + m.freeSpins = 1 + m.finishSpin() + if wallet.paid != 0 || len(wallet.open) != 1 { + t.Fatalf("mid-feature spin paid or resolved the stake: paid=%d open=%+v", wallet.paid, wallet.open) + } + if m.featureBank != 65 { + t.Fatalf("featureBank=%v, want 65 banked", m.featureBank) + } +} + +// TestSlotsFeaturePaysOneSettlementThenResolvesTheStake: the banked feature +// winnings are paid in a single settlement when the last free spin stops, and +// only then is the triggering stake resolved. +func TestSlotsFeaturePaysOneSettlementThenResolvesTheStake(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) + wallet := &testWallet{credits: 100} + m.join("alice") + m.beginSpinLocked(10, wallet, "", "") + // The last free spin: the feature (and the round) resolves with it. + m.inFreeSpins = true + m.freeSpins = 0 + m.featureBank = 25 + m.spin = &SlotSpin{Payout: 40, ScatterCount: 3} + + events := m.finishSpin() + if wallet.paid != 65 { + t.Fatalf("feature settlement paid %d, want one 65-credit payout", wallet.paid) + } + if !hasMessage(events, "The free spins pay 65 credits.") { + t.Fatalf("missing feature settlement message: %+v", events) + } + if len(wallet.open) != 0 { + t.Fatalf("stake still outstanding after the feature completed: %+v", wallet.open) + } + if m.inFreeSpins || m.freeSpins != 0 || m.featureBank != 0 { + t.Fatalf("feature state leaked: inFreeSpins=%v freeSpins=%d bank=%v", m.inFreeSpins, m.freeSpins, m.featureBank) + } +} diff --git a/internal/casino/hardening_casino_rng_test.go b/internal/casino/hardening_casino_rng_test.go new file mode 100644 index 0000000..2d44441 --- /dev/null +++ b/internal/casino/hardening_casino_rng_test.go @@ -0,0 +1,75 @@ +package casino + +import "testing" + +// RNG seams: outcomes must not be replayable from a clock, while tests keep +// a deterministic, injectable seed. + +func TestNewManagerSeedIsADeterministicSeam(t *testing.T) { + cfg := MachineConfig{ID: "slots", Game: GameSlots} + same := []*Machine{ + NewManager(7).EnsureMachine(1, cfg), + NewManager(7).EnsureMachine(1, cfg), + } + other := NewManager(8).EnsureMachine(1, cfg) + + first := machineDraws(same[0]) + if got := machineDraws(same[1]); !sameDraws(first, got) { + t.Fatalf("the same seed produced different slot streams: %v vs %v", first, got) + } + if got := machineDraws(other); sameDraws(first, got) { + t.Fatalf("different seeds produced the same slot stream: %v", got) + } +} + +func TestNewManagerEntropySeedsFromCryptoRand(t *testing.T) { + if seed := entropySeed(); seed < 0 { + t.Fatalf("entropy seed %d is negative", seed) + } + seen := make(map[int64]bool) + for i := 0; i < 4; i++ { + m := NewManagerEntropy() + if m.seed < 0 { + t.Fatalf("NewManagerEntropy seed %d is negative", m.seed) + } + seen[m.seed] = true + } + if len(seen) < 2 { + t.Fatalf("NewManagerEntropy reused one seed across %d managers", len(seen)) + } +} + +// Each game gets its own stream seeded from the manager's: no two tables or +// machines may share reel results. +func TestManagerGamesKeepIndependentStreams(t *testing.T) { + m := NewManager(3) + first := m.EnsureMachine(1, MachineConfig{ID: "slots_a", Game: GameSlots}) + second := m.EnsureMachine(1, MachineConfig{ID: "slots_b", Game: GameSlots}) + if first == nil || second == nil { + t.Fatal("machines were not created") + } + if sameDraws(machineDraws(first), machineDraws(second)) { + t.Fatal("two machines share one RNG stream") + } +} + +// machineDraws consumes n values from a game's own stream. +func machineDraws(m *Machine) []int64 { + draws := make([]int64, 8) + for i := range draws { + draws[i] = m.rng.Int63() + } + return draws +} + +func sameDraws(a, b []int64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} 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") + } +} diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 844c5e9..cf0ccb9 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -30,11 +30,16 @@ type Machine struct { inFreeSpins bool spinBet int payoutRemainder float64 - profile SlotProfile - rng *rand.Rand - suspense bool - autoBet bool - autoCountdown int + // featureBank holds the winnings of a paid round whose free-spin feature + // is still running. They are paid as ONE settlement when the feature + // ends: see finishSpin for why no payout may be journaled while the + // triggering stake is outstanding. + featureBank float64 + profile SlotProfile + rng *rand.Rand + suspense bool + autoBet bool + autoCountdown int // stakeFailed marks the in-flight stake whose payout could not be // persisted: the stake record stays OPEN as recovery evidence, so the // seat must not resolve (forfeit) it when the player leaves. @@ -43,12 +48,7 @@ type Machine struct { func newMachine(roomID int, cfg MachineConfig, rng *rand.Rand) *Machine { cfg = cfg.Normalize() - profile := DefaultHuffAndPuffProfile() - if cfg.Payout > 0 { - profile = ScaleToRTP(profile, cfg.Payout) - } - profile = profile.Normalize() - return &Machine{RoomID: roomID, Config: cfg, phase: machineReady, profile: profile, rng: rng} + return &Machine{RoomID: roomID, Config: cfg, phase: machineReady, profile: machineSlotProfile(cfg), rng: rng} } func (m *Machine) join(name string) []Event { @@ -184,11 +184,11 @@ func (m *Machine) tick() []Event { m.reel++ message := fmt.Sprintf("Reel %d stops...", m.reel) events := m.publicSlot(message, m.reel) - if m.reel < 5 && stoppedScatters(m.spin.Grid, m.reel, m.profile.FreeSpinTrigger) >= 2 && !m.suspense { + if m.reel < slotGridReels && stoppedScatters(m.spin.Grid, m.reel, m.profile.FreeSpinTrigger) >= 2 && !m.suspense { m.suspense = true events = append(events, m.public("Two scatters! The remaining reels slow down...")...) } - if m.reel < 5 { + if m.reel < slotGridReels { return events } m.phase = machinePayout @@ -224,6 +224,18 @@ func (m *Machine) autoBetTick() []Event { } } +// finishSpin resolves the spin that just stopped: it pays that spin's +// winnings, advances or starts the free-spin feature, and resolves the +// triggering stake once the whole round (feature included) is over. +// +// A free-spin feature pays out as ONE settlement when it ends. While the +// feature runs its winnings are banked in memory and nothing is journaled: +// the triggering stake stays OUTSTANDING until the feature completes, and +// recovery reads ANY payout record for the player as proof that the round +// resolved (a PayCredits record carries no stake linkage, see the Wallet +// contract). Paying a free spin as it landed would therefore make a crash +// mid-feature settle the stake instead of refunding it, contradicting the +// refund-on-crash contract that a graceful shutdown honors. func (m *Machine) finishSpin() []Event { events := []Event{} result := m.spin.Payout + m.payoutRemainder @@ -238,9 +250,26 @@ func (m *Machine) finishSpin() []Event { } credits := int(math.Floor(result)) m.payoutRemainder = result - float64(credits) + + // A spin belongs to a feature when it is a free spin or when it just + // triggered one; the feature (and with it the round) resolves on the + // last free spin. + triggered := m.spin.ScatterCount >= 3 && m.freeSpins == 0 && !m.inFreeSpins + inFeature := m.inFreeSpins || triggered + featureEnds := m.inFreeSpins && m.freeSpins == 0 + payNow := credits + switch { + case inFeature && !featureEnds: + m.featureBank += float64(credits) + payNow = 0 + case featureEnds: + payNow = credits + int(m.featureBank) + m.featureBank = 0 + } + payFailed := false - if credits > 0 && m.player.Wallet != nil { - if err := m.player.Wallet.PayCredits(credits); err != nil { + if payNow > 0 && m.player.Wallet != nil { + if err := m.player.Wallet.PayCredits(payNow); err != nil { // The payout could not be made durable; recovery will re-apply // the owed amount from the open journal record. Do not announce // the win as paid, stop any autobet chain, and keep the stake @@ -254,10 +283,19 @@ func (m *Machine) finishSpin() []Event { events = append(events, failedEvents...) } } - if credits > 0 && !payFailed { - winEvents := m.private(fmt.Sprintf("\nYou win %d credits.", credits)) + if payNow > 0 && !payFailed { + message := fmt.Sprintf("\nYou win %d credits.", payNow) + switch { + case inFeature && !featureEnds: + // Banked, not spent: say so, or the player would read the win + // as already in their balance. + message = fmt.Sprintf("\nYou win %d credits, paid when the free spins end.", payNow) + case featureEnds: + message = fmt.Sprintf("\nThe free spins pay %d credits.", payNow) + } + winEvents := m.private(message) winEvents[0].Color = "casino_payout" - if credits > m.spinBet { + if payNow > m.spinBet { winEvents[0].Color = "casino_big_win" } events = append(events, winEvents...) @@ -271,7 +309,7 @@ func (m *Machine) finishSpin() []Event { // free spin are ignored, matching the CalculateRTP model (the feature is // a fixed number of automatic spins). Retriggering only on a paid spin // keeps the machine's actual return aligned with that model. - if m.spin.ScatterCount >= 3 && m.freeSpins == 0 && !m.inFreeSpins { + if triggered { m.freeSpins = m.profile.FreeSpinCount m.inFreeSpins = true events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...) @@ -335,6 +373,7 @@ func (m *Machine) resetSeat() { m.freeSpins = 0 m.inFreeSpins = false m.payoutRemainder = 0 + m.featureBank = 0 m.autoBet, m.autoCountdown = false, 0 m.stakeFailed = false } @@ -388,10 +427,10 @@ func (m *Machine) publicSlot(message string, stopped int) []Event { Message: message, Color: "casino_action", Public: true, Slot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}} } -func stoppedScatters(grid [3][5]string, reels int, trigger string) int { +func stoppedScatters(grid [slotGridRows][slotGridReels]string, reels int, trigger string) int { count := 0 - for row := 0; row < 3; row++ { - for reel := 0; reel < reels && reel < 5; reel++ { + for row := 0; row < slotGridRows; row++ { + for reel := 0; reel < reels && reel < slotGridReels; reel++ { if grid[row][reel] == trigger { count++ } @@ -429,16 +468,23 @@ func (m *Machine) menuEvents(prefix string) []Event { return m.privateMenu(prefix) } -// refundAll refunds any outstanding stake at graceful shutdown/eviction. -// The wallet resolves every outstanding stake exactly once; a completed -// spin has no outstanding stake and is not refunded again. A stake whose -// payout failed to persist (stakeFailed) is NOT returned here when an open -// payout record covers it — the journaled winnings prove the round -// resolved, so the stake was consumed by playing it and recovery re-applies -// the owed payout instead of double-paying the round. A bonus in progress -// keeps its triggering stake outstanding with no payout journaled, so a -// shutdown during the feature returns the stake (the remaining free-spin -// EV is forfeit, never the wager). Locked. +// refundAll refunds any outstanding stake at graceful shutdown/eviction and +// then DISCARDS the round (the machine closes with the process). The wallet +// resolves every outstanding stake exactly once; a completed spin has no +// outstanding stake and is not refunded again. A stake whose payout failed +// to persist (stakeFailed) is NOT returned here when an open payout record +// covers it — the journaled winnings prove the round resolved, so the stake +// was consumed by playing it and recovery re-applies the owed payout instead +// of double-paying the round. A bonus in progress keeps its triggering stake +// outstanding with no payout journaled, so a shutdown during the feature +// returns the stake (only the still-unplayed free spins' EV is forfeit); the +// winnings that have already landed are banked in featureBank and are paid +// through the wallet as a settlement, so a graceful shutdown preserves them +// exactly as the pre-banking code did. Locked. +// +// Discarding the round is what makes the call safe to repeat: a refunded +// round can never settle (and pay its spin) afterwards, so neither a second +// RefundAll nor a stray tick can pay the same round twice. func (m *Machine) refundAll() []Event { m.mu.Lock() defer m.mu.Unlock() @@ -446,22 +492,72 @@ func (m *Machine) refundAll() []Event { return nil } name, wallet := m.player.Name, m.player.Wallet + // Free-spin winnings that have already landed live only in featureBank + // until the feature ends (see finishSpin), so discarding the round must + // settle them or a graceful shutdown silently destroys them. Refund the + // outstanding triggering stake FIRST: the settlement that pays the bank + // below carries no stake linkage, so a journaled settlement with the + // stake still open would read at recovery as covering (and consuming) + // that stake, suppressing its refund. chips, credits, err := wallet.RefundOutstanding() refund := chips + credits - if err != nil || refund <= 0 { - if err != nil { - return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, - PrivateTo: name, Player: name, - PrivateMessage: "Shutdown refund could not be persisted; the stake is recorded for recovery.", - Color: "casino_action", Public: false}} + bank := int(m.featureBank) + bankFailed := false + if err == nil && bank > 0 { + // Only settle the bank once the refund is settled: on a failed refund + // the stake stays open for recovery, and a settlement payout would + // make recovery treat it as consumed instead of refunded. + if payErr := wallet.PayCredits(bank); payErr != nil { + bankFailed = true } - return nil } - return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, - PrivateTo: name, Player: name, - Message: fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund), - PrivateMessage: fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund), - Color: "casino_action", Public: true}} + // A refund that could not be made durable leaves the stakes open for + // recovery to complete; the round is still discarded, and marking the + // stake as failed keeps resetSeat from resolving (forfeiting) it. This is + // LOAD-BEARING, not dead code: on the RefundOutstanding error path the + // stakes are left open, and resetSeat's guard is the only thing that + // stops it from resolving them and erasing the recovery evidence. + m.stakeFailed = err != nil + m.resetSeat() + var events []Event + switch { + case err != nil: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + PrivateMessage: "Shutdown refund could not be persisted; the stake is recorded for recovery.", + Color: "casino_action", Public: false}) + case refund > 0: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + Message: fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund), + PrivateMessage: fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund), + Color: "casino_action", Public: true}) + } + // Announce the banked free-spin settlement, or its loss. resetSeat has + // already zeroed featureBank by this point, so a failed refund does NOT + // keep the bank in memory: the banked winnings are forfeited and only the + // trigger stake is recovered. Report that forfeiture explicitly (a + // stake-linked settlement that survives a failed shutdown refund is not + // implemented) instead of dropping it silently. + switch { + case bankFailed: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they are recorded for recovery.", bank), + Color: "casino_action", Public: false}) + case err != nil && bank > 0: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + PrivateMessage: fmt.Sprintf("Your %d credits of banked free-spin winnings were forfeited because the shutdown refund could not be persisted.", bank), + Color: "casino_action", Public: false}) + case err == nil && bank > 0: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + Message: fmt.Sprintf("%s's %d free-spin credits pay out as the casino closes.", name, bank), + PrivateMessage: fmt.Sprintf("The house settles your %d banked free-spin winnings for the shutdown.", bank), + Color: "casino_action", Public: true}) + } + return events } // idle reports whether no player is seated: only an idle machine may have diff --git a/internal/casino/manager.go b/internal/casino/manager.go index 4798217..8b87147 100644 --- a/internal/casino/manager.go +++ b/internal/casino/manager.go @@ -1,6 +1,8 @@ package casino import ( + crand "crypto/rand" + "encoding/binary" "fmt" "math/rand" "reflect" @@ -14,13 +16,43 @@ type Manager struct { tables map[string]*Table machines map[string]*Machine rng *rand.Rand + // seed is the seed the manager's stream was created with, so a test can + // assert that the entropy path really drew a fresh one. + seed int64 } -func NewManager(seed int64) *Manager { +// NewManager returns a Manager with DETERMINISTIC per-game RNG streams +// derived from seed. It is the test seam: a fixed seed must give fixed +// shoes, reels, and spins. Production uses NewManagerEntropy. +func NewManager(seed int64) *Manager { return newManager(seed) } + +// NewManagerEntropy returns a Manager whose per-game RNG streams are seeded +// from crypto/rand. Casino outcomes must not be reconstructible from the +// clock: a wall-clock seed lets anyone who knows (or brute-forces) the +// startup nanosecond replay every shoe and reel stream, so a certified +// 0.97-RTP machine becomes an unlimited-credit one. The streams themselves +// stay math/rand (unbiased Intn/Shuffle) and each game keeps its own. +func NewManagerEntropy() *Manager { return newManager(entropySeed()) } + +// entropySeed draws a fresh 63-bit seed from crypto/rand. A broken entropy +// source is fatal instead of silently degrading to a reconstructible clock +// seed: the casino's game integrity depends on it. +func entropySeed() int64 { + var b [8]byte + if _, err := crand.Read(b[:]); err != nil { + panic(fmt.Sprintf("casino entropy seed: %v", err)) + } + // A non-negative 63-bit value: math/rand accepts any int64 seed, and a + // non-negative seed prints cleanly in diagnostics. + return int64(binary.LittleEndian.Uint64(b[:]) >> 1) +} + +func newManager(seed int64) *Manager { return &Manager{ tables: make(map[string]*Table), machines: make(map[string]*Machine), rng: rand.New(rand.NewSource(seed)), + seed: seed, } } @@ -294,9 +326,16 @@ func (m *Manager) MachineBusy(name string) bool { } // RefundAll resolves every outstanding wager by refunding committed stakes -// to their players. Graceful shutdown uses this: rounds cannot settle, so -// no player may lose a stake to the shutdown itself. Refunds go through the -// wallet (durable, journaled) and return the refund events for delivery. +// to their players and then closing each game's round. Graceful shutdown and +// room eviction use this: rounds cannot settle, so no player may lose a +// stake to the shutdown itself. Refunds go through the wallet (durable, +// journaled) and the refund events are returned for delivery. +// +// It is deliberately NOT a way to clear live tables: every game discards +// the round it refunded (seats keep their last bet only for "rebet", but +// phases, hands, spins, and timers are reset), so a refunded round cannot +// settle later and a second call refunds nothing. A caller that wants the +// games to keep playing must not use this. func (m *Manager) RefundAll() []Event { var events []Event for _, table := range m.snapshotTables() { diff --git a/internal/casino/polish_refund_forfeit_test.go b/internal/casino/polish_refund_forfeit_test.go new file mode 100644 index 0000000..1408fcd --- /dev/null +++ b/internal/casino/polish_refund_forfeit_test.go @@ -0,0 +1,24 @@ +package casino + +import "testing" + +// Regression: refundAll's comment claimed a failed refund kept featureBank in +// memory, but resetSeat zeroes it before the settlement switch runs, so +// already-landed free-spin winnings are forfeited. The forfeiture must be +// reported explicitly rather than dropped silently. + +func TestMachineRefundAllReportsForfeitedBankOnFailedRefund(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand()) + wallet := &failingRefundWallet{testWallet{credits: 100}} + m.join("alice") + m.beginSpinLocked(10, wallet, "", "") + m.featureBank = 25 + + events := m.refundAll() + if m.featureBank != 0 { + t.Fatalf("featureBank = %v after refundAll, want 0 (resetSeat cleared it)", m.featureBank) + } + if !hasMessage(events, "forfeited") { + t.Fatalf("the forfeited bank was not reported: %+v", events) + } +} diff --git a/internal/casino/slots.go b/internal/casino/slots.go index 075050c..64caf85 100644 --- a/internal/casino/slots.go +++ b/internal/casino/slots.go @@ -23,12 +23,22 @@ type SlotProfile struct { } type SlotSpin struct { - Grid [3][5]string + Grid [slotGridRows][slotGridReels]string Payout float64 ScatterCount int Wins []SlotWin } +// The runtime slot evaluator is pinned to this grid: SlotSpin/SlotSnapshot +// hold it, generateSlotSpinWithProfile fills it, and matchingLength and +// stoppedScatters evaluate it. CalculateRTP/ValidateRTP are generic over +// SlotProfile.Rows/Reels/Ways, so SlotProfile.Validate rejects any other +// geometry (see config_validate.go) to keep the RTP gate honest. +const ( + slotGridRows = 3 + slotGridReels = 5 +) + type SlotWin struct { Symbol string Length int @@ -72,10 +82,12 @@ func (p SlotProfile) Normalize() SlotProfile { } func generateSlotSpinWithProfile(rng *rand.Rand, bet int, profile SlotProfile) *SlotSpin { + // SlotProfile.Normalize supplies the defaults; Validate rejects a + // geometry this grid cannot hold, so the bounds are exactly the grid. p := profile.Normalize() spin := &SlotSpin{} - for reel := 0; reel < p.Reels && reel < 5; reel++ { - for row := 0; row < p.Rows && row < 3; row++ { + for reel := 0; reel < p.Reels && reel < slotGridReels; reel++ { + for row := 0; row < p.Rows && row < slotGridRows; row++ { symbol := weightedSymbol(rng, p.Symbols) spin.Grid[row][reel] = symbol if symbol == p.FreeSpinTrigger { @@ -110,7 +122,7 @@ func weightedSymbol(rng *rand.Rand, symbols []SlotSymbol) string { return symbols[len(symbols)-1].ID } -func evaluateSlot(grid [3][5]string, bet int, profile SlotProfile) (float64, []SlotWin) { +func evaluateSlot(grid [slotGridRows][slotGridReels]string, bet int, profile SlotProfile) (float64, []SlotWin) { p := profile.Normalize() if bet <= 0 { return 0, nil @@ -128,12 +140,12 @@ func evaluateSlot(grid [3][5]string, bet int, profile SlotProfile) (float64, []S return total, wins } -func matchingLength(grid [3][5]string, symbol string) (int, int) { +func matchingLength(grid [slotGridRows][slotGridReels]string, symbol string) (int, int) { ways := 1 length := 0 - for reel := 0; reel < 5; reel++ { + for reel := 0; reel < slotGridReels; reel++ { count := 0 - for row := 0; row < 3; row++ { + for row := 0; row < slotGridRows; row++ { if grid[row][reel] == symbol { count++ } @@ -204,6 +216,11 @@ func binomial(n, k int) float64 { } func ValidateRTP(profile SlotProfile, tolerance float64) error { + // A profile the runtime cannot play must never be certified: its RTP + // would describe a different game than the one actually paid. + if err := profile.Validate(); err != nil { + return err + } if profile.TargetRTP <= 0 { return nil } diff --git a/internal/casino/types.go b/internal/casino/types.go index 49e2cc4..080e60a 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -268,7 +268,7 @@ type Event struct { } type SlotSnapshot struct { - Grid [3][5]string + Grid [slotGridRows][slotGridReels]string StoppedReels int } -- cgit v1.2.3