diff options
| author | historia <[not public]> | 2026-09-18 15:16:44 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 15:16:44 -0400 |
| commit | 7eab582d8a8ee2af1a6baae7baf0696c7005de2e (patch) | |
| tree | 902bbc8c6e39c9f62f779ca23267714ec420b9f5 /internal/casino | |
| parent | 153acb7f644d755c45533b7c5311e3ffa47e8973 (diff) | |
| download | thehouseoficarus-7eab582d8a8ee2af1a6baae7baf0696c7005de2e.tar.gz | |
fix: harden triggers, economy, casino, world data, and admin/net paths
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/baccarat_test.go | 4 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 35 | ||||
| -rw-r--r-- | internal/casino/cards.go | 8 | ||||
| -rw-r--r-- | internal/casino/config_validate.go | 31 | ||||
| -rw-r--r-- | internal/casino/credits.go | 6 | ||||
| -rw-r--r-- | internal/casino/hardening_fixes_batch2_test.go | 108 | ||||
| -rw-r--r-- | internal/casino/slots.go | 3 | ||||
| -rw-r--r-- | internal/casino/types.go | 5 |
8 files changed, 187 insertions, 13 deletions
diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go index 522f875..c5565b1 100644 --- a/internal/casino/baccarat_test.go +++ b/internal/casino/baccarat_test.go @@ -108,8 +108,8 @@ func TestBaccaratSettlement(t *testing.T) { if result, payout := settleBaccaratBet(baccaratSpotBanker, 100, 4, 6, rules); result != "win" || payout != 195 { t.Fatalf("banker win: result=%s payout=%d, want win/195 (5%% commission)", result, payout) } - if result, payout := settleBaccaratBet(baccaratSpotBanker, 1, 4, 6, rules); result != "win" || payout != 1 { - t.Fatalf("banker win on bet 1: result=%s payout=%d, want win/1 (commission rounds up)", result, payout) + if result, payout := settleBaccaratBet(baccaratSpotBanker, 1, 4, 6, rules); result != "win" || payout != 2 { + t.Fatalf("banker win on bet 1: result=%s payout=%d, want win/2 (a winning stake never pays zero)", result, payout) } if result, payout := settleBaccaratBet(baccaratSpotTie, 100, 7, 7, rules); result != "win" || payout != 900 { t.Fatalf("tie win: result=%s payout=%d, want win/900 (8:1)", result, payout) diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 7b30699..16a480c 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -75,8 +75,14 @@ type blackjackTable struct { ceiling countdown activePlayer int activeHand int - dealer blackjackHand - shoe *shoe + // turnName/turnHand identify the current turn so the absolute turn + // ceiling is restarted only when the turn actually changes. A third party + // joining or leaving mid-round re-prompts the same turn and must not + // extend a hung bettor's deadline. + turnName string + turnHand int + dealer blackjackHand + shoe *shoe } // loneTurnGrace multiplies the action window into an absolute per-turn @@ -94,7 +100,7 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa return &blackjackTable{ tableBase: tableBase{RoomID: roomID, Config: cfg}, Rules: rules, players: newSeatSet[*blackjackPlayer](), phase: blackjackWaiting, - shoe: newShoe(rules.Decks, rules.ShuffleAt, rng), + shoe: newShoe(rules.Decks, rules.ShuffleAt, rng), turnHand: -1, } } @@ -465,6 +471,7 @@ func (t *blackjackTable) beginPlay() { t.phase = blackjackPlaying t.timer.start(t.Rules.ActionTicks) t.activePlayer, t.activeHand = 0, 0 + t.turnName, t.turnHand = "", -1 } // roundCardCeiling is the heuristic number of cards this table's round is @@ -475,19 +482,29 @@ func (t *blackjackTable) beginPlay() { // 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) + // Size the boundary reshuffle from the seats actually in the round, not + // the configured maximum: using MaxPlayers made ensureForRound dominate + // the cut card, so shuffle_at never fired on a table whose maximum seats + // implied more cards than the cut card allowed. + return blackjackRoundCards(t.bettorCount(), 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. The turn ceiling restarts - // with the window for the same reason. + // and timeouts can never extend the deadline. t.timer.start(t.Rules.ActionTicks) - t.ceiling.start(t.Rules.ActionTicks * loneTurnGrace) name := t.activeName() if name == "" { return t.settle() } + // The absolute turn ceiling belongs to the turn, not the prompt: restart + // it only when the active (player, hand) changes. A seat event that + // re-prompts the same turn must not reset a hung bettor's deadline. + if name != t.turnName || t.activeHand != t.turnHand { + t.ceiling.start(t.Rules.ActionTicks * loneTurnGrace) + t.turnName = name + t.turnHand = t.activeHand + } p := t.players.get(name) h := p.hands[t.activeHand] ranks := make([]string, len(h.cards)) @@ -710,6 +727,7 @@ func (t *blackjackTable) resetRound() { t.window.close() t.timer.stop() t.ceiling.stop() + t.turnName, t.turnHand = "", -1 for _, p := range t.players.ordered() { p.HasBet, p.Wager, p.hands, p.insurance, p.insuranceStakeID, p.declinedInsurance = false, Wager{}, nil, 0, "", false } @@ -837,7 +855,8 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { } switch { case index < t.activePlayer: - // The same player stays active, one seat earlier. + // The same player stays active, one seat earlier. prompt() sees the + // unchanged turn and keeps the existing ceiling. t.activePlayer-- case index == t.activePlayer: // The next bettor slides into the active seat. diff --git a/internal/casino/cards.go b/internal/casino/cards.go index 27da8aa..2d53b4a 100644 --- a/internal/casino/cards.go +++ b/internal/casino/cards.go @@ -73,6 +73,14 @@ func (s *shoe) remaining() int { return len(s.cards) } func (s *shoe) draw() card { if len(s.cards) == 0 { + if s.decks <= 0 { + // A synthetic, explicitly-scripted shoe (tests) has no decks to + // rebuild from and no cards left; return a visibly-invalid sentinel + // card rather than panicking. Production shoes always have Decks > + // 0 (Normalize), so this only surfaces a test fixture that drew + // past its script. + return card{rank: "?", suit: "?"} + } // 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 diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go index ee8e8c9..38d426d 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -141,6 +141,37 @@ func (p SlotProfile) Validate() error { return fmt.Errorf("unsupported slot geometry %dx%d: the runtime evaluator supports %dx%d only", profile.Rows, profile.Reels, slotGridRows, slotGridReels) } + // A named trigger must exist, or the feature silently never fires while + // the RTP model also computes a zero trigger probability and reports green. + if profile.FreeSpinTrigger != "" { + found := false + positiveWeight := false + for _, symbol := range profile.Symbols { + if symbol.ID == profile.FreeSpinTrigger { + found = true + positiveWeight = symbol.Weight > 0 + break + } + } + if !found { + return fmt.Errorf("free_spin_trigger %q names no symbol in symbols", profile.FreeSpinTrigger) + } + if !positiveWeight { + return fmt.Errorf("free_spin_trigger %q names a symbol with no reel weight, so the feature can never trigger", profile.FreeSpinTrigger) + } + } + // A paytable must not have a gap: evaluateSlot pays only the longest run, + // so Payout{3: 0.5} with no Payout[4] makes every 4-run pay zero — less + // than the 3-run — while the RTP model agrees and nothing else flags it. + // A symbol whose shorter runs also pay zero (e.g. only a 5-run pays) is a + // legitimate shape and is not flagged. + for _, symbol := range profile.Symbols { + for length := 4; length <= 5; length++ { + if symbol.Payout[length] <= 0 && symbol.Payout[length-1] > 0 { + return fmt.Errorf("symbol %q pays for a run of %d but not %d: a longer run would pay zero", symbol.ID, length-1, length) + } + } + } return nil } diff --git a/internal/casino/credits.go b/internal/casino/credits.go index 8b7cb7a..631a4bc 100644 --- a/internal/casino/credits.go +++ b/internal/casino/credits.go @@ -47,6 +47,12 @@ func MulPayout(amount int, multiplier float64) (int, error) { return MaxCredits, fmt.Errorf("payout overflow: %d * %v exceeds %d", amount, multiplier, MaxCredits) } payout := int(math.Floor(float64(amount) * multiplier)) + if payout == 0 && multiplier > 0 { + // A positive stake on a winning outcome must never pay nothing: a + // fractional multiplier (e.g. a 5% baccarat commission) at a 1-credit + // bet floors to zero, turning a win into a total loss. + payout = 1 + } if payout > MaxCredits { return MaxCredits, fmt.Errorf("payout overflow: %d * %v exceeds %d", amount, multiplier, MaxCredits) } diff --git a/internal/casino/hardening_fixes_batch2_test.go b/internal/casino/hardening_fixes_batch2_test.go new file mode 100644 index 0000000..1391143 --- /dev/null +++ b/internal/casino/hardening_fixes_batch2_test.go @@ -0,0 +1,108 @@ +package casino + +import "testing" + +// TestMulPayoutPositiveBetNeverZero: a positive stake on a winning outcome +// must not floor to zero (a 1-credit banker win at 0.95 paid nothing). +func TestMulPayoutPositiveBetNeverZero(t *testing.T) { + if got, err := MulPayout(1, 0.95); err != nil || got != 1 { + t.Fatalf("MulPayout(1, 0.95) = %d, %v; want 1", got, err) + } + if got, err := MulPayout(1, 0.5); err != nil || got != 1 { + t.Fatalf("MulPayout(1, 0.5) = %d, %v; want 1", got, err) + } + // A zero amount is still zero. + if got, err := MulPayout(0, 0.95); err != nil || got != 0 { + t.Fatalf("MulPayout(0, 0.95) = %d, %v; want 0", got, err) + } +} + +// TestCalculateRTPIncludesTriggerPayout: the runtime pays a paying scatter +// like any other symbol, so the RTP model must include it. +func TestCalculateRTPIncludesTriggerPayout(t *testing.T) { + base := DefaultHuffAndPuffProfile() + withPay := DefaultHuffAndPuffProfile() + for i := range withPay.Symbols { + if withPay.Symbols[i].ID == withPay.FreeSpinTrigger { + withPay.Symbols[i].Payout = map[int]float64{3: 1, 4: 2, 5: 5} + } + } + if CalculateRTP(withPay) <= CalculateRTP(base) { + t.Fatalf("paying trigger did not raise RTP: %v <= %v", CalculateRTP(withPay), CalculateRTP(base)) + } +} + +// TestSlotProfileRejectsMissingTriggerSymbol: a named trigger that matches no +// symbol silently disables the feature. +func TestSlotProfileRejectsMissingTriggerSymbol(t *testing.T) { + p := DefaultHuffAndPuffProfile() + p.FreeSpinTrigger = "ghost" + if err := p.Validate(); err == nil { + t.Fatal("accepted a free_spin_trigger that names no symbol") + } +} + +// TestSlotProfileRejectsPaytableGap: a positive payout for a shorter run with +// no payout for a longer run makes the longer run pay zero. +func TestSlotProfileRejectsPaytableGap(t *testing.T) { + p := DefaultHuffAndPuffProfile() + for i := range p.Symbols { + if p.Symbols[i].ID == "straw" { + p.Symbols[i].Payout = map[int]float64{3: 0.5} + } + } + if err := p.Validate(); err == nil { + t.Fatal("accepted a paytable with a gap") + } +} + +// TestSlotProfileRejectsWeightlessTrigger: a trigger symbol with no reel +// weight never appears, so the feature silently never fires. +func TestSlotProfileRejectsWeightlessTrigger(t *testing.T) { + p := DefaultHuffAndPuffProfile() + for i := range p.Symbols { + if p.Symbols[i].ID == p.FreeSpinTrigger { + p.Symbols[i].Weight = 0 + } + } + if err := p.Validate(); err == nil { + t.Fatal("accepted a free_spin_trigger with no reel weight") + } +} + +// TestDisconnectingNonBettorDoesNotResetLoneTurnCeiling: a third party's +// seat event re-prompts the same turn and must not extend a hung lone +// bettor's absolute deadline. +func TestDisconnectingNonBettorDoesNotResetLoneTurnCeiling(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 3, + 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.join("bob") + table.bet("alice", "", 10, wallet) + + // Let the betting window expire so the round deals with alice the only + // bettor and bob seated as a non-bettor. + for i := 0; i < 200 && table.phase != blackjackPlaying; i++ { + table.tick() + } + if table.phase != blackjackPlaying { + t.Fatalf("setup: round did not start (phase=%s)", table.phase) + } + for i := 0; i < 3; i++ { + table.tick() + } + before := table.ceiling.clock + if before == 0 { + t.Fatal("setup: turn ceiling did not advance") + } + + table.markDisconnected("bob") + if table.ceiling.clock != before { + t.Fatalf("a non-bettor's disconnect reset the turn ceiling: %d -> %d", before, table.ceiling.clock) + } +} diff --git a/internal/casino/slots.go b/internal/casino/slots.go index 64caf85..6be5004 100644 --- a/internal/casino/slots.go +++ b/internal/casino/slots.go @@ -182,9 +182,6 @@ func CalculateRTP(profile SlotProfile) float64 { if symbol.ID == p.FreeSpinTrigger { scatterProbability = probability } - if symbol.ID == p.FreeSpinTrigger { - continue - } for length := 3; length <= 5; length++ { ways := math.Pow(float64(p.Rows), float64(length)) nextReelNoMatch := 1.0 diff --git a/internal/casino/types.go b/internal/casino/types.go index 080e60a..71da01c 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -162,6 +162,11 @@ func (c TableConfig) Normalize() TableConfig { if c.MaxBet < c.MinBet { c.MaxBet = c.MinBet } + // Normalize the nested game rule blocks too: a new game that reads + // t.Config.Blackjack/Baccarat directly (rather than only t.Rules) would + // otherwise see zero values and build an empty shoe. + c.Blackjack = c.Blackjack.Normalize() + c.Baccarat = c.Baccarat.Normalize() return c } |
