diff options
| author | historia <[not public]> | 2026-09-19 03:15:59 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-19 03:15:59 -0400 |
| commit | 0e60567a7a0addb9421769ee7be7853bfa8788cb (patch) | |
| tree | 7645112db0e4eae95b8a83c1e4f9aab38bede655 /internal/casino | |
| parent | cc33072eced3d5460512c09c42ec4531c807d7af (diff) | |
| download | thehouseoficarus-0e60567a7a0addb9421769ee7be7853bfa8788cb.tar.gz | |
refactor: simplifcation, change how rooms are stored in player yaml, remove legacy code
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/baccarat.go | 10 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 8 | ||||
| -rw-r--r-- | internal/casino/config_validate.go | 77 | ||||
| -rw-r--r-- | internal/casino/config_validate_test.go | 50 | ||||
| -rw-r--r-- | internal/casino/core.go | 16 | ||||
| -rw-r--r-- | internal/casino/hardening_casino_validate_test.go | 14 | ||||
| -rw-r--r-- | internal/casino/manager_test.go | 8 | ||||
| -rw-r--r-- | internal/casino/registry.go | 18 | ||||
| -rw-r--r-- | internal/casino/slots.go | 6 | ||||
| -rw-r--r-- | internal/casino/types.go | 11 |
10 files changed, 164 insertions, 54 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 62a83a5..53ed0a9 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -241,7 +241,15 @@ func (t *baccaratTable) markDisconnected(name string) []Event { return nil } if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { - p.Wallet.RefundOutstanding() + if _, _, err := p.Wallet.RefundOutstanding(); err != nil { + // The stake stays pending on disk and startup recovery will return + // it, but tell the departing player instead of silently swallowing + // the failure. + t.removeSeatLocked(name) + events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} + events = append(events, t.private(name, "Your stake could not be returned just now; it stays pending and will be refunded after the next server restart.")) + return t.closeIfEmptyLocked(events) + } } t.removeSeatLocked(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index cb71034..ed56d15 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -777,6 +777,7 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { if index < 0 { return nil } + refundFailed := false if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { if t.phase == blackjackPlaying || t.phase == blackjackInsurance { // A disconnected player forfeits the round in flight: consume @@ -791,12 +792,17 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { } 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() + if _, _, err := p.Wallet.RefundOutstanding(); err != nil { + refundFailed = true + } } } t.removeSeatLocked(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} events = t.closeIfEmptyLocked(events) + if refundFailed { + events = append(events, t.private(name, "Your stake could not be returned just now; it stays pending and will be refunded after the next server restart.")) + } if t.phase != blackjackPlaying { // Betting and insurance resolve against the remaining seats on the // next tick; waiting has no round in flight. diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go index 6181883..9ad1776 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "math" + "sort" "strings" ) @@ -21,8 +22,8 @@ func (c MachineConfig) Validate() error { return fmt.Errorf("unsupported slots variant %q", c.Variant) } } - if c.SpinTicks < 0 { - return fmt.Errorf("spin_ticks must not be negative") + if c.SpinTicks < 0 || c.SpinTicks > maxSpinTicks { + return fmt.Errorf("spin_ticks must be between 0 and %d", maxSpinTicks) } if !finiteNonnegative(c.Payout) || c.Payout > 1 { return fmt.Errorf("payout must be finite and between 0 and 1 (0 uses the default)") @@ -37,16 +38,40 @@ func (c TableConfig) Validate() error { if err := validateIdentityAndBets(c.ID, c.MinBet, c.MaxBet); err != nil { return err } - if c.MaxPlayers < 0 || c.BettingTicks < 0 { - return fmt.Errorf("max_players and betting_ticks must not be negative") + if c.MaxPlayers < 0 || c.MaxPlayers > maxTablePlayers { + return fmt.Errorf("max_players must be between 0 and %d", maxTablePlayers) + } + if c.BettingTicks < 0 || c.BettingTicks > maxBettingTicks { + return fmt.Errorf("betting_ticks must be between 0 and %d", maxBettingTicks) } def, ok := tableGameDefFor(c.Game) if !ok { return fmt.Errorf("unsupported table game %q", c.Game) } + if foreign := foreignRuleBlocks(c); len(foreign) > 0 { + return fmt.Errorf("table game %q has a rule block for a different game (%s); remove it or set game to match", c.Game, strings.Join(foreign, ", ")) + } return def.validate(c) } +// foreignRuleBlocks names every game-specific rule block present on c that +// does not belong to c.Game, in a deterministic order. Each game registers its +// own block in tableGames, so a new game widens this automatically instead of +// needing another hand-maintained switch. +func foreignRuleBlocks(c TableConfig) []string { + var foreign []string + for game, def := range tableGames { + if game == c.Game || def.hasRuleBlock == nil || def.ruleBlockName == "" { + continue + } + if def.hasRuleBlock(c) { + foreign = append(foreign, def.ruleBlockName) + } + } + sort.Strings(foreign) + return foreign +} + // normalizeBlackjackTable applies the blackjack rule defaults. It is the // game's Normalize entry in tableGames. func normalizeBlackjackTable(c TableConfig) TableConfig { @@ -66,8 +91,8 @@ func validateBlackjackTable(c TableConfig) error { if err := validateShoe(b.Decks, b.ShuffleAt); err != nil { return err } - if b.MaxSplitHands < 0 || b.ActionTicks < 0 { - return fmt.Errorf("max_split_hands and action_ticks must not be negative") + if b.MaxSplitHands < 0 || b.ActionTicks < 0 || b.ActionTicks > maxActionTicks { + return fmt.Errorf("max_split_hands must not be negative and action_ticks must be between 0 and %d", maxActionTicks) } if !finiteNonnegative(b.BlackjackPayout) || !finiteNonnegative(b.InsurancePayout) { return fmt.Errorf("blackjack_payout and insurance_payout must be finite and nonnegative") @@ -130,6 +155,22 @@ func finiteNonnegative(n float64) bool { return !math.IsNaN(n) && !math.IsInf(n, 0) && n >= 0 } +// hasBlackjackRuleBlock reports whether c carries any explicit blackjack rule. +// Used by foreignRuleBlocks to reject a block authored for another game. +func hasBlackjackRuleBlock(c TableConfig) bool { + b := c.Blackjack + return b.Decks != 0 || b.ShuffleAt != 0 || b.BlackjackPayout != 0 || + b.DealerHitSoft17 != nil || b.MaxSplitHands != 0 || b.ResplitAces != nil || + b.DoubleAfterSplit != nil || b.DoubleSplitBlackjack != nil || + b.InsurancePayout != 0 || b.Surrender != nil || b.ActionTicks != 0 || + b.SplitUnlikeTenValues != nil +} + +func hasBaccaratRuleBlock(c TableConfig) bool { + b := c.Baccarat + return b.Decks != 0 || b.ShuffleAt != 0 || b.TiePayout != 0 || b.BankerPayout != 0 +} + func validateShoe(decks int, shuffleAt float64) error { // A generous resource limit prevents malformed content from allocating an // unbounded shoe. Zero retains the existing eight-deck default. @@ -158,6 +199,18 @@ const ( maxBankerPayout = 0.99 ) +// Authoring limits for tick-based windows. A typo such as betting_ticks: +// 2000000 would otherwise publish a table that holds its betting window open +// for days; while the window is open a committed stake cannot be withdrawn, so +// the bettor is trapped. These are generous relative to normal play (a tick is +// 600ms). +const ( + maxTablePlayers = 64 + maxBettingTicks = 3600 + maxActionTicks = 3600 + maxSpinTicks = 3600 +) + // 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. @@ -187,6 +240,9 @@ const baccaratRoundCards = 6 // the geometry here keeps that gate meaningful. func (p SlotProfile) Validate() error { profile := p.Normalize() + if len(profile.Symbols) == 0 { + return fmt.Errorf("slot profile has no symbols") + } 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) @@ -265,7 +321,7 @@ func machineSlotProfile(c MachineConfig) SlotProfile { } // ValidateRoomConfigs checks the shared room-local ID namespace as well as -// individual definitions. Legacy slots under casino_tables remain supported. +// individual definitions. // // 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 @@ -291,13 +347,6 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error { addressable[key] = id } for _, c := range tables { - if c.Game == GameSlots { - // A slots "table" is instantiated as the room's slots machine. - legacy := LegacySlotMachine(c) - check("legacy slots", c.ID, legacy.Validate()) - checkAddressable(strings.ToLower(string(GameSlots)), c.ID) - continue - } check("table", c.ID, c.Validate()) checkAddressable(strings.ToLower(string(c.Game)), c.ID) } diff --git a/internal/casino/config_validate_test.go b/internal/casino/config_validate_test.go index f0f87f5..5e72054 100644 --- a/internal/casino/config_validate_test.go +++ b/internal/casino/config_validate_test.go @@ -75,15 +75,16 @@ func TestTableConfigValidation(t *testing.T) { } } -func TestRoomCasinoIDsAndLegacySlots(t *testing.T) { - tables := []TableConfig{{ID: "slots", Game: GameSlots}} +func TestRoomCasinoIDsRejectDuplicates(t *testing.T) { machines := []MachineConfig{{ID: "slots", Game: GameSlots}} - if err := ValidateRoomConfigs(tables, nil); err != nil { - t.Fatalf("legacy slots rejected: %v", err) - } - if ValidateRoomConfigs(tables, machines) == nil || ValidateRoomConfigs(nil, append(machines, machines...)) == nil { + if ValidateRoomConfigs(nil, append(machines, machines...)) == nil { t.Fatal("duplicate casino IDs accepted") } + + // Slots are machines only: declaring them under casino_tables is invalid. + if err := ValidateRoomConfigs([]TableConfig{{ID: "slots", Game: GameSlots}}, nil); err == nil { + t.Fatal("slots table accepted; slots must be declared under casino_machines") + } } func TestManagerGamesHaveIndependentRandomStreams(t *testing.T) { @@ -122,3 +123,40 @@ func TestScaleToRTPDoesNotChangeSourceProfile(t *testing.T) { t.Fatal("profiles share mutable paytables") } } + +func TestSlotProfileEmptySymbolsRejected(t *testing.T) { + // A new slotVariants entry that forgets its symbol table must not validate + // and silently play the built-in cabinet. + if err := (SlotProfile{}).Validate(); err == nil { + t.Fatal("empty slot profile validated; expected an error") + } +} + +func TestTableConfigRejectsForeignRuleBlock(t *testing.T) { + c := TableConfig{ID: "t", Game: GameBaccarat, Blackjack: BlackjackConfig{BlackjackPayout: 15}} + if err := c.Validate(); err == nil { + t.Fatal("baccarat table with a blackjack rule block validated; expected an error") + } + ok := TableConfig{ID: "t", Game: GameBlackjack} + if err := ok.Validate(); err != nil { + t.Fatalf("valid blackjack table rejected: %v", err) + } +} + +func TestTableConfigTimerCaps(t *testing.T) { + cases := map[string]TableConfig{ + "huge betting window": {ID: "t", Game: GameBaccarat, BettingTicks: maxBettingTicks + 1}, + "huge action window": {ID: "t", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: maxActionTicks + 1}}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + if err := c.Validate(); err == nil { + t.Fatal("expected an error") + } + }) + } + m := MachineConfig{ID: "m", Game: GameSlots, SpinTicks: maxSpinTicks + 1} + if err := m.Validate(); err == nil { + t.Fatal("expected an error for an oversized spin window") + } +} diff --git a/internal/casino/core.go b/internal/casino/core.go index 0b9d0af..7143841 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -143,11 +143,7 @@ func resolveStakeIDs(w Wallet, ids []string) { if w == nil { return } - for _, id := range ids { - if id != "" { - w.ResolveStake(id) - } - } + w.ResolveStakesByIDs(ids...) } // settleStakeOutcome applies one wager's outcome through the wallet and @@ -175,12 +171,10 @@ func settleStakeOutcome(w Wallet, payout int, stakeIDs []string, payoutErr error if payout > 0 { return w.PayCredits(payout, stakeIDs...) != nil } - // A zero payout is a loss: consume the stakes so recovery cannot refund a - // round that resolved. - for _, id := range stakeIDs { - if id != "" { - w.ResolveStake(id) - } + // A zero payout is a loss: consume the stakes in one save so recovery can + // never refund a round that resolved. + if w != nil { + w.ResolveStakesByIDs(stakeIDs...) } return false } diff --git a/internal/casino/hardening_casino_validate_test.go b/internal/casino/hardening_casino_validate_test.go index 1d27d54..dfec79b 100644 --- a/internal/casino/hardening_casino_validate_test.go +++ b/internal/casino/hardening_casino_validate_test.go @@ -162,13 +162,13 @@ func TestRoomRejectsASecondEntryForOneGame(t *testing.T) { 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) + // Slots are machines only: a slots entry under casino_tables is rejected + // outright rather than treated as a machine. + if legacy := ValidateRoomConfigs( + []TableConfig{{ID: "slots_table", Game: GameSlots}}, + nil, + ); legacy == nil { + t.Fatal("slots table accepted; slots must be declared under casino_machines") } // Two machines of the same variant are equally unreachable. twoVariants := ValidateRoomConfigs(nil, []MachineConfig{ diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go index 9b5d35a..a832d53 100644 --- a/internal/casino/manager_test.go +++ b/internal/casino/manager_test.go @@ -45,6 +45,14 @@ func (w *testWallet) ResolveStake(stakeID string) { w.open = kept } +// ResolveStakesByIDs consumes the listed stakes in one step, mirroring the +// production wallet. +func (w *testWallet) ResolveStakesByIDs(stakeIDs ...string) { + for _, id := range stakeIDs { + w.ResolveStake(id) + } +} + // ResolveStakes mirrors the production wallet: every outstanding stake is // settled (no longer refundable). func (w *testWallet) ResolveStakes() { w.open = nil } diff --git a/internal/casino/registry.go b/internal/casino/registry.go index 9a973d4..26acd8c 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -13,6 +13,12 @@ type tableGameDef struct { validate func(TableConfig) error // normalize applies this game's rule defaults to a table config. normalize func(TableConfig) TableConfig + // ruleBlockName is the YAML rule block this game owns (for error messages), + // and hasRuleBlock reports whether that block carries any explicit value. + // Together they let TableConfig.Validate reject a block authored for a + // different game without a hand-maintained switch. + ruleBlockName string + hasRuleBlock func(TableConfig) bool // new builds the game. It is called only after validate passes and the // shared table config has been normalized. new func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame @@ -26,15 +32,19 @@ var tableGames map[GameName]tableGameDef func init() { tableGames = map[GameName]tableGameDef{ GameBlackjack: { - validate: validateBlackjackTable, - normalize: normalizeBlackjackTable, + validate: validateBlackjackTable, + normalize: normalizeBlackjackTable, + ruleBlockName: "blackjack", + hasRuleBlock: hasBlackjackRuleBlock, new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame { return newBlackjackTable(roomID, cfg, rng) }, }, GameBaccarat: { - validate: validateBaccaratTable, - normalize: normalizeBaccaratTable, + validate: validateBaccaratTable, + normalize: normalizeBaccaratTable, + ruleBlockName: "baccarat", + hasRuleBlock: hasBaccaratRuleBlock, new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame { return newBaccaratTable(roomID, cfg, rng) }, diff --git a/internal/casino/slots.go b/internal/casino/slots.go index 217e9fb..419cfcf 100644 --- a/internal/casino/slots.go +++ b/internal/casino/slots.go @@ -76,9 +76,9 @@ func (p SlotProfile) Normalize() SlotProfile { if p.FreeSpinCount <= 0 { p.FreeSpinCount = 8 } - if len(p.Symbols) == 0 { - return DefaultHuffAndPuffProfile() - } + // An empty symbol table is NOT defaulted: a new variant that forgets its + // symbols must fail validation, not silently play the built-in cabinet. + // machineSlotProfile is the one place that starts from a real profile. return p } diff --git a/internal/casino/types.go b/internal/casino/types.go index e057ba6..2d4ba02 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -129,13 +129,6 @@ type MachineConfig struct { Payout float64 `yaml:"payout,omitempty"` } -// LegacySlotMachine converts a legacy slots TableConfig (older rooms declared -// slots under casino_tables) into the MachineConfig the room actually -// instantiates. Shared by validation and room lookup so the two cannot drift. -func LegacySlotMachine(c TableConfig) MachineConfig { - return MachineConfig{ID: c.ID, Game: GameSlots, MinBet: c.MinBet, MaxBet: c.MaxBet} -} - func (c MachineConfig) Normalize() MachineConfig { if c.MinBet <= 0 { c.MinBet = 1 @@ -216,6 +209,10 @@ type Wallet interface { // ResolveStake consumes ONE pending stake by wallet handle (a single // hand paid or lost while a sibling hand's round is still in flight). ResolveStake(stakeID string) + // ResolveStakesByIDs consumes the listed pending stakes in ONE save. It is + // the batched form of ResolveStake used when a losing round has several + // stakes (split/double hands) so they are not committed one save at a time. + ResolveStakesByIDs(stakeIDs ...string) // RefundOutstanding returns every pending stake to the player (chips as // chips, credits as credits) and clears the ledger. An error means the // refund could not be made durable: nothing was returned and the stakes |
