1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
|
package casino
import (
"errors"
"fmt"
"math"
"strings"
)
// Validate checks raw configuration before Normalize supplies omitted (zero)
// defaults. Invalid explicit values must not silently become house rules.
func (c MachineConfig) Validate() error {
if err := validateIdentityAndBets(c.ID, c.MinBet, c.MaxBet); err != nil {
return err
}
if c.Game != GameSlots {
return fmt.Errorf("unsupported machine game %q", c.Game)
}
if c.Variant != "" {
if _, ok := slotVariants[c.Variant]; !ok {
return fmt.Errorf("unsupported slots variant %q", c.Variant)
}
}
if c.SpinTicks < 0 {
return fmt.Errorf("spin_ticks must not be negative")
}
if !finiteNonnegative(c.Payout) || c.Payout > 1 {
return fmt.Errorf("payout must be finite and between 0 and 1 (0 uses the default)")
}
// The machine's effective profile is the built-in one scaled to the
// configured RTP; it must be a geometry the runtime evaluator can
// actually play.
return machineSlotProfile(c).Validate()
}
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")
}
def, ok := tableGameDefFor(c.Game)
if !ok {
return fmt.Errorf("unsupported table game %q", c.Game)
}
return def.validate(c)
}
// normalizeBlackjackTable applies the blackjack rule defaults. It is the
// game's Normalize entry in tableGames.
func normalizeBlackjackTable(c TableConfig) TableConfig {
c.Blackjack = c.Blackjack.Normalize()
return c
}
// normalizeBaccaratTable applies the baccarat rule defaults. It is the game's
// Normalize entry in tableGames.
func normalizeBaccaratTable(c TableConfig) TableConfig {
c.Baccarat = c.Baccarat.Normalize()
return c
}
func validateBlackjackTable(c TableConfig) error {
b := c.Blackjack
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 !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)
}
// 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
}
func validateBaccaratTable(c TableConfig) error {
b := c.Baccarat
if err := validateShoe(b.Decks, b.ShuffleAt); err != nil {
return err
}
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
}
func validateIdentityAndBets(id string, minBet, maxBet int) error {
if id == "" || strings.TrimSpace(id) != id {
return fmt.Errorf("id must be nonempty with no surrounding whitespace")
}
if minBet < 0 || maxBet < 0 {
return fmt.Errorf("min_bet and max_bet must not be negative")
}
if minBet > 0 && maxBet == 0 && minBet > DefaultMaxBet {
return fmt.Errorf("min_bet (%d) exceeds the default maximum bet (%d); set a larger max_bet", minBet, DefaultMaxBet)
}
if maxBet > 0 && maxBet < max(1, minBet) {
return fmt.Errorf("max_bet must be at least min_bet")
}
return nil
}
func finiteNonnegative(n float64) bool {
return !math.IsNaN(n) && !math.IsInf(n, 0) && n >= 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.
if decks < 0 || decks > 64 {
return fmt.Errorf("decks must be between 0 and 64 (0 uses the default)")
}
if !finiteNonnegative(shuffleAt) || shuffleAt >= 1 {
return fmt.Errorf("shuffle_at must be finite and in [0, 1)")
}
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
// 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
// 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 also assumes that geometry (fixed 3..5 symbol runs and a
// five-reel no-match term). 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)
}
// 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 != "" {
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
}
// 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 factory, ok := slotVariants[c.Variant]; ok {
profile = factory()
}
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
check := func(kind, id string, err error) {
if seen[id] {
errs = append(errs, fmt.Errorf("duplicate casino id %q", id))
}
seen[id] = true
if err != nil {
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 := 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)
}
// A machine addresses by its variant when one is declared; a bare game
// name is addressable only when exactly one machine in the room declares
// it, so a room may host several variants of one game (and the validator
// no longer rejects the second variant as a duplicate game).
machineGameCount := make(map[string]int, len(machines))
for _, c := range machines {
machineGameCount[strings.ToLower(string(c.Game))]++
}
for _, c := range machines {
check("machine", c.ID, c.Validate())
if c.Variant != "" {
checkAddressable(strings.ToLower(c.Variant), c.ID)
}
// A variant-less machine is addressed by its game name, so two of them
// collide; a variant-ful machine only claims the bare game name when it
// is the sole machine that declares it.
if c.Variant == "" || machineGameCount[strings.ToLower(string(c.Game))] == 1 {
checkAddressable(strings.ToLower(string(c.Game)), c.ID)
}
}
return errors.Join(errs...)
}
|