aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/config_validate.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/config_validate.go')
-rw-r--r--internal/casino/config_validate.go89
1 files changed, 85 insertions, 4 deletions
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...)
}