aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat.go10
-rw-r--r--internal/casino/blackjack.go8
-rw-r--r--internal/casino/config_validate.go77
-rw-r--r--internal/casino/config_validate_test.go50
-rw-r--r--internal/casino/core.go16
-rw-r--r--internal/casino/hardening_casino_validate_test.go14
-rw-r--r--internal/casino/manager_test.go8
-rw-r--r--internal/casino/registry.go18
-rw-r--r--internal/casino/slots.go6
-rw-r--r--internal/casino/types.go11
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