aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat.go29
-rw-r--r--internal/casino/blackjack.go78
-rw-r--r--internal/casino/cards.go31
-rw-r--r--internal/casino/config_validate.go89
-rw-r--r--internal/casino/correct_casino_refund_bank_test.go101
-rw-r--r--internal/casino/correct_casino_room_validate_test.go36
-rw-r--r--internal/casino/credits.go16
-rw-r--r--internal/casino/credits_test.go13
-rw-r--r--internal/casino/hardening_casino_disconnect_test.go102
-rw-r--r--internal/casino/hardening_casino_locking_test.go84
-rw-r--r--internal/casino/hardening_casino_refund_test.go145
-rw-r--r--internal/casino/hardening_casino_rng_test.go75
-rw-r--r--internal/casino/hardening_casino_validate_test.go181
-rw-r--r--internal/casino/machine.go184
-rw-r--r--internal/casino/manager.go47
-rw-r--r--internal/casino/polish_refund_forfeit_test.go24
-rw-r--r--internal/casino/slots.go31
-rw-r--r--internal/casino/types.go2
18 files changed, 1155 insertions, 113 deletions
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
}