diff options
| author | historia <[not public]> | 2026-09-18 16:13:49 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 16:13:49 -0400 |
| commit | 194164a77eb4f2479b94c19e6f0e1278ab608781 (patch) | |
| tree | 53413add8ed6f32a34d1733397e380cc98ef70ef /internal/casino | |
| parent | 67e01aab85607b9e601185c7d62dff576af3bb43 (diff) | |
| download | thehouseoficarus-194164a77eb4f2479b94c19e6f0e1278ab608781.tar.gz | |
fix: more casino, economy, gameplay, security, and world data hardening gaps
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/baccarat.go | 10 | ||||
| -rw-r--r-- | internal/casino/baccarat_test.go | 4 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 50 | ||||
| -rw-r--r-- | internal/casino/blackjack_test.go | 20 | ||||
| -rw-r--r-- | internal/casino/config_validate.go | 53 | ||||
| -rw-r--r-- | internal/casino/correct_casino_refund_bank_test.go | 32 | ||||
| -rw-r--r-- | internal/casino/machine.go | 34 | ||||
| -rw-r--r-- | internal/casino/p2_hardening_test.go | 60 | ||||
| -rw-r--r-- | internal/casino/types.go | 18 |
9 files changed, 247 insertions, 34 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 231a011..c9a50a1 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -369,6 +369,16 @@ func settleBaccaratBet(spot string, bet int, playerTotal, bankerTotal int, rules case bankerTotal > playerTotal: if spot == baccaratSpotBanker { bankerWin, _ := MulPayout(bet, rules.BankerPayout) + // The banker commission rounds UP, not down: when the wager is + // too small for a whole credit of commission, a winning banker + // bet returns only the stake. MulPayout's positive-stake floor + // would otherwise round the commission to zero and make the + // banker bet +EV (it wins more often than it loses, and ties + // push), which the "never bet less than one commission unit" + // rule exists to prevent. + if float64(bet)*rules.BankerPayout < 1 { + return "push", bet + } return "win", bet + bankerWin } default: diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go index c5565b1..39941eb 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 != 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(baccaratSpotBanker, 1, 4, 6, rules); result != "push" || payout != 1 { + t.Fatalf("banker win on bet 1: result=%s payout=%d, want push/1 (a sub-credit commission rounds up, returning only the stake)", 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 16a480c..fd0572b 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -618,7 +618,16 @@ func (t *blackjackTable) settle() []Event { } payoutFailed := false for _, hand := range p.hands { - result, payout := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) + result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) + if settleErr != nil { + // A payout multiplier that overflows the credit cap is a + // configuration defect, not a payable win. Treat it like a + // failed settlement so the stake stays open for recovery to + // refund rather than paying a clamped/absurd amount. + payoutFailed = true + events = append(events, t.playerAction(p.Name, "Settlement unavailable.", payoutFailedMessage())) + continue + } if payout > 0 && p.Wallet != nil { if err := p.Wallet.PayStakeCover(payout, hand.stakes); err != nil { // The payout could not be made durable. The journal @@ -658,10 +667,17 @@ func (t *blackjackTable) settle() []Event { } if p.insurance > 0 { insurancePayout := 0 + var insuranceErr error if dealerBlackjack { - insurancePayout, _ = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) + insurancePayout, insuranceErr = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) } - if insurancePayout > 0 { + switch { + case insuranceErr != nil: + // Overflow is a config defect: leave the insurance stake open + // for recovery to refund instead of paying a clamped amount. + payoutFailed = true + events = append(events, t.private(p.Name, payoutFailedMessage())) + case insurancePayout > 0: if err := p.Wallet.PayStakeCover(insurancePayout, []string{p.insuranceStakeID}); err != nil { payoutFailed = true events = append(events, t.private(p.Name, payoutFailedMessage())) @@ -669,7 +685,7 @@ func (t *blackjackTable) settle() []Event { p.Wallet.ResolveStake(p.insuranceStakeID) events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) } - } else { + default: // Insurance lost: its stake was consumed by the rules. p.Wallet.ResolveStake(p.insuranceStakeID) events = append(events, t.private(p.Name, "Insurance pays 0 credits.")) @@ -690,36 +706,36 @@ func (t *blackjackTable) settle() []Event { return events } -func settleHand(hand *blackjackHand, dealerTotal int, dealerBlackjack bool, rules BlackjackConfig) (string, int) { +func settleHand(hand *blackjackHand, dealerTotal int, dealerBlackjack bool, rules BlackjackConfig) (string, int, error) { total, _ := hand.total() if hand.surrendered { - return "surrender", hand.bet / 2 + return "surrender", hand.bet / 2, nil } if hand.bust { - return "bust", 0 + return "bust", 0, nil } playerBlackjack := hand.blackjack(rules) if playerBlackjack && dealerBlackjack { - return "push", hand.bet + return "push", hand.bet, nil } if dealerBlackjack { - return "dealer blackjack", 0 + return "dealer blackjack", 0, nil } if playerBlackjack { // Winnings round down to whole credits; never bet less than two. - blackjackWin, _ := MulPayout(hand.bet, rules.BlackjackPayout) - return "blackjack", hand.bet + blackjackWin + blackjackWin, err := MulPayout(hand.bet, rules.BlackjackPayout) + if err != nil { + return "blackjack", 0, err + } + return "blackjack", hand.bet + blackjackWin, nil } if total > 21 || dealerTotal > total && dealerTotal <= 21 { - return "loss", 0 + return "loss", 0, nil } if dealerTotal == total { - return "push", hand.bet - } - if dealerTotal > 21 { - return "win", hand.bet * 2 + return "push", hand.bet, nil } - return "win", hand.bet * 2 + return "win", hand.bet * 2, nil } func (t *blackjackTable) resetRound() { diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go index 0f7527c..2e2e985 100644 --- a/internal/casino/blackjack_test.go +++ b/internal/casino/blackjack_test.go @@ -34,22 +34,22 @@ func TestBlackjackHandScoreShowsBlackjackAs21(t *testing.T) { func TestBlackjackSettlementRules(t *testing.T) { rules := (BlackjackConfig{}).Normalize() natural := &blackjackHand{bet: 10, cards: []card{{rank: "A"}, {rank: "K"}}} - if result, payout := settleHand(natural, 20, false, rules); result != "blackjack" || payout != 25 { - t.Fatalf("natural result=%s payout=%d", result, payout) + if result, payout, err := settleHand(natural, 20, false, rules); err != nil || result != "blackjack" || payout != 25 { + t.Fatalf("natural result=%s payout=%d err=%v", result, payout, err) } smallNatural := &blackjackHand{bet: 1, cards: []card{{rank: "A"}, {rank: "K"}}} - if result, payout := settleHand(smallNatural, 20, false, rules); result != "blackjack" || payout != 2 { - t.Fatalf("small natural result=%s payout=%d, want blackjack/2 (winnings round down)", result, payout) + if result, payout, err := settleHand(smallNatural, 20, false, rules); err != nil || result != "blackjack" || payout != 2 { + t.Fatalf("small natural result=%s payout=%d err=%v, want blackjack/2 (winnings round down)", result, payout, err) } dealerNatural := &blackjackHand{bet: 10, cards: []card{{rank: "A"}, {rank: "Q"}}} - if result, payout := settleHand(dealerNatural, 21, true, rules); result != "push" || payout != 10 { - t.Fatalf("dealer natural result=%s payout=%d", result, payout) + if result, payout, err := settleHand(dealerNatural, 21, true, rules); err != nil || result != "push" || payout != 10 { + t.Fatalf("dealer natural result=%s payout=%d err=%v", result, payout, err) } - if result, payout := settleHand(&blackjackHand{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}, 21, true, rules); result != "dealer blackjack" || payout != 0 { - t.Fatalf("dealer blackjack result=%s payout=%d", result, payout) + if result, payout, err := settleHand(&blackjackHand{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}, 21, true, rules); err != nil || result != "dealer blackjack" || payout != 0 { + t.Fatalf("dealer blackjack result=%s payout=%d err=%v", result, payout, err) } - if result, payout := settleHand(&blackjackHand{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}, 22, false, rules); result != "win" || payout != 20 { - t.Fatalf("dealer bust result=%s payout=%d", result, payout) + if result, payout, err := settleHand(&blackjackHand{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}, 22, false, rules); err != nil || result != "win" || payout != 20 { + t.Fatalf("dealer bust result=%s payout=%d err=%v", result, payout, err) } } diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go index 38d426d..a03cfbf 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -50,6 +50,12 @@ 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") } + if b.BlackjackPayout > maxBlackjackPayout { + return fmt.Errorf("blackjack_payout %v exceeds the %d:1 cap", b.BlackjackPayout, maxBlackjackPayout) + } + if b.InsurancePayout > maxInsurancePayout { + return fmt.Errorf("insurance_payout %v exceeds the %d:1 cap", b.InsurancePayout, maxInsurancePayout) + } 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) @@ -70,6 +76,12 @@ 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") } + if b.TiePayout > maxTiePayout { + return fmt.Errorf("tie_payout %v exceeds the %d:1 cap", b.TiePayout, maxTiePayout) + } + if b.BankerPayout > maxBankerPayout { + return fmt.Errorf("banker_payout %v exceeds the %v:1 cap", b.BankerPayout, maxBankerPayout) + } // Non-blocking for the same reason as blackjack above. return nil default: @@ -114,6 +126,22 @@ func validateShoe(decks int, shuffleAt float64) error { // the whole stake. const minimumSurrenderBet = 2 +// Payout-multiplier caps. Validation bounds authored multipliers so a typo +// (e.g. blackjack_payout: 15) cannot silently create an absurd table and so +// MulPayout's overflow backstop is not the only thing standing between a +// configuration error and a MaxCredits payout. +const ( + maxBlackjackPayout = 5 + maxInsurancePayout = 5 + maxTiePayout = 10 + maxBankerPayout = 0.99 +) + +// maxSlotSymbolLen is the fixed width of one reel cell in the renderer +// (" " + symbol + padding + " "); a longer id would make the renderer's +// width padding negative and panic. +const maxSlotSymbolLen = 7 + // 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 @@ -141,6 +169,31 @@ 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) } + // Symbol IDs are looked up by exact string and rendered in fixed-width + // reel cells, so a duplicate ID silently pays that win twice, an over-long + // ID panics the renderer, and a zero total weight makes weightedSymbol + // unable to pick anything. + seenSymbols := make(map[string]bool, len(profile.Symbols)) + totalWeight := 0 + for _, symbol := range profile.Symbols { + if symbol.ID == "" { + return fmt.Errorf("slot symbol with an empty id") + } + if len(symbol.ID) > maxSlotSymbolLen { + return fmt.Errorf("slot symbol id %q exceeds the %d-character reel width", symbol.ID, maxSlotSymbolLen) + } + if seenSymbols[symbol.ID] { + return fmt.Errorf("duplicate slot symbol id %q", symbol.ID) + } + seenSymbols[symbol.ID] = true + if symbol.Weight < 0 { + return fmt.Errorf("slot symbol %q has negative reel weight", symbol.ID) + } + totalWeight += symbol.Weight + } + if totalWeight <= 0 { + return fmt.Errorf("slot symbols have no total reel weight, so no symbol can ever appear") + } // 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 != "" { diff --git a/internal/casino/correct_casino_refund_bank_test.go b/internal/casino/correct_casino_refund_bank_test.go index fd90ae1..4262997 100644 --- a/internal/casino/correct_casino_refund_bank_test.go +++ b/internal/casino/correct_casino_refund_bank_test.go @@ -99,3 +99,35 @@ func TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery(t *testing.T) { t.Fatalf("the failed refund was not reported: %+v", events) } } + +// TestMachineDisconnectSettlesBankedFeatureWinnings: already-landed free-spin +// winnings are not part of the forfeited in-flight wager, so a disconnect must +// settle them through the wallet rather than silently zeroing featureBank. +func TestMachineDisconnectSettlesBankedFeatureWinnings(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) + } + + events := m.markDisconnected("alice") + if wallet.paid != 25 { + t.Fatalf("disconnect paid %d, want 25 banked free-spin credits", wallet.paid) + } + if got := wallet.credits + wallet.paid; got != 115 { + t.Fatalf("banked winnings were destroyed on disconnect: balance %d, want 115 (100 - 10 forfeited stake + 25 banked)", 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 disconnect: %+v", wallet.open) + } + if m.owns("alice") { + t.Fatal("machine still owns the disconnected player") + } +} diff --git a/internal/casino/machine.go b/internal/casino/machine.go index cf0ccb9..83e018f 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -380,15 +380,47 @@ func (m *Machine) resetSeat() { // markDisconnected removes the player from the machine, forfeiting any // in-flight wager, so the seat is immediately free for someone else. +// +// Already-landed free-spin winnings (featureBank) are NOT part of the +// in-flight wager: they are settled after resetSeat resolves the triggering +// stake, so the unlinked settlement cannot be read at recovery as covering +// that stake. If the round's payout could not be persisted (stakeFailed), the +// stake is left open for recovery and the bank is reported as forfeited +// rather than silently destroyed. func (m *Machine) markDisconnected(name string) []Event { m.mu.Lock() defer m.mu.Unlock() if !m.owns(name) { return nil } + bank := int(m.featureBank) + wallet := m.player.Wallet + stakeFailed := m.stakeFailed m.resetSeat() - return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, + events := []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, Message: fmt.Sprintf("%s stands up from the slots machine.", name), Color: "casino_action", Public: true}} + if bank <= 0 || wallet == nil { + return events + } + switch { + case stakeFailed: + 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 round could not be persisted.", bank), + Color: "casino_action", Public: false}) + case wallet.PayCredits(bank) != nil: + 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}) + default: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + Message: fmt.Sprintf("%s's %d banked free-spin credits pay out on disconnect.", name, bank), + PrivateMessage: fmt.Sprintf("The house settles your %d banked free-spin winnings.", bank), + Color: "casino_action", Public: true}) + } + return events } func (m *Machine) hasParticipant(name string) bool { diff --git a/internal/casino/p2_hardening_test.go b/internal/casino/p2_hardening_test.go new file mode 100644 index 0000000..435e102 --- /dev/null +++ b/internal/casino/p2_hardening_test.go @@ -0,0 +1,60 @@ +package casino + +import "testing" + +// TestTableConfigRejectsAbsurdMultipliers: authored payout multipliers are +// bounded so a typo cannot silently create an absurd table (and so the +// MulPayout overflow backstop is not the only line of defence). +func TestTableConfigRejectsAbsurdMultipliers(t *testing.T) { + for name, c := range map[string]TableConfig{ + "blackjack payout": {ID: "t", Game: GameBlackjack, Blackjack: BlackjackConfig{BlackjackPayout: 15}}, + "insurance payout": {ID: "t", Game: GameBlackjack, Blackjack: BlackjackConfig{InsurancePayout: 15}}, + "tie payout": {ID: "t", Game: GameBaccarat, Baccarat: BaccaratConfig{TiePayout: 100}}, + "banker payout": {ID: "t", Game: GameBaccarat, Baccarat: BaccaratConfig{BankerPayout: 1.5}}, + } { + t.Run(name, func(t *testing.T) { + if c.Validate() == nil { + t.Fatalf("accepted an absurd payout multiplier: %+v", c) + } + }) + } + // The shipped/default values remain valid. + if err := (TableConfig{ID: "t", Game: GameBaccarat}).Validate(); err != nil { + t.Fatalf("default baccarat table rejected: %v", err) + } + if err := (TableConfig{ID: "t", Game: GameBlackjack}).Validate(); err != nil { + t.Fatalf("default blackjack table rejected: %v", err) + } +} + +// TestSlotProfileRejectsDuplicateSymbol: a duplicated id would pay that win +// twice because evaluateSlot matches symbols by id. +func TestSlotProfileRejectsDuplicateSymbol(t *testing.T) { + p := DefaultHuffAndPuffProfile() + p.Symbols = append(p.Symbols, p.Symbols[0]) + if err := p.Validate(); err == nil { + t.Fatal("accepted a duplicate slot symbol id") + } +} + +// TestSlotProfileRejectsZeroWeight: with no total reel weight weightedSymbol +// can never pick a symbol. +func TestSlotProfileRejectsZeroWeight(t *testing.T) { + p := DefaultHuffAndPuffProfile() + for i := range p.Symbols { + p.Symbols[i].Weight = 0 + } + if err := p.Validate(); err == nil { + t.Fatal("accepted a profile with no reel weight") + } +} + +// TestSlotProfileRejectsOverlongSymbol: the renderer pads to a fixed 7-cell +// width, so a longer id makes the padding negative and panics. +func TestSlotProfileRejectsOverlongSymbol(t *testing.T) { + p := DefaultHuffAndPuffProfile() + p.Symbols[0].ID = "way-too-long" + if err := p.Validate(); err == nil { + t.Fatal("accepted a symbol id longer than the reel width") + } +} diff --git a/internal/casino/types.go b/internal/casino/types.go index 71da01c..578aee1 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -44,10 +44,20 @@ func ruleBool(value *bool, defaultValue bool) bool { func (c BlackjackConfig) DealerHitsSoft17() bool { return ruleBool(c.DealerHitSoft17, false) } func (c BlackjackConfig) AllowsResplitAces() bool { return ruleBool(c.ResplitAces, false) } func (c BlackjackConfig) AllowsDoubleAfterSplit() bool { return ruleBool(c.DoubleAfterSplit, true) } + +// CountsDoubleSplitBlackjack reports whether a 3+ card hand that reaches 21 +// after a split and double is paid as a blackjack (3:2). When true (the +// default) this raises the table RTP above the nominal blackjack payout; +// standard play pays 1:1 for any 21 on a split hand, doubled or not. func (c BlackjackConfig) CountsDoubleSplitBlackjack() bool { return ruleBool(c.DoubleSplitBlackjack, true) } -func (c BlackjackConfig) AllowsSurrender() bool { return ruleBool(c.Surrender, false) } + +func (c BlackjackConfig) AllowsSurrender() bool { return ruleBool(c.Surrender, false) } + +// AllowsUnlikeTenSplit reports whether any two 10-valued cards may split +// (K+Q, 10+J). Standard casinos split only identical ranks, so a true value +// (the default) is player-favourable. func (c BlackjackConfig) AllowsUnlikeTenSplit() bool { return ruleBool(c.SplitUnlikeTenValues, true) } func (c BlackjackConfig) Normalize() BlackjackConfig { @@ -159,14 +169,14 @@ func (c TableConfig) Normalize() TableConfig { if c.MaxBet <= 0 { c.MaxBet = DefaultMaxBet } - 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() + if c.MaxBet < c.MinBet { + c.MaxBet = c.MinBet + } return c } |
