@slot-engine/core 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.mts +7 -14
- package/dist/index.d.ts +7 -14
- package/dist/index.js +68 -90
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +68 -90
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -1
- package/.turbo/turbo-build.log +0 -33
- package/.turbo/turbo-typecheck.log +0 -4
- package/CHANGELOG.md +0 -7
- package/dist/lib/zstd.exe +0 -0
- package/index.ts +0 -205
- package/lib/zstd.exe +0 -0
- package/optimizer-rust/Cargo.toml +0 -19
- package/optimizer-rust/src/exes.rs +0 -154
- package/optimizer-rust/src/main.rs +0 -1659
- package/src/Board.ts +0 -527
- package/src/Book.ts +0 -83
- package/src/GameConfig.ts +0 -148
- package/src/GameMode.ts +0 -86
- package/src/GameState.ts +0 -272
- package/src/GameSymbol.ts +0 -61
- package/src/ReelGenerator.ts +0 -589
- package/src/ResultSet.ts +0 -207
- package/src/Simulation.ts +0 -625
- package/src/SlotGame.ts +0 -117
- package/src/Wallet.ts +0 -203
- package/src/WinType.ts +0 -102
- package/src/analysis/index.ts +0 -198
- package/src/analysis/utils.ts +0 -128
- package/src/optimizer/OptimizationConditions.ts +0 -99
- package/src/optimizer/OptimizationParameters.ts +0 -46
- package/src/optimizer/OptimizationScaling.ts +0 -18
- package/src/optimizer/index.ts +0 -142
- package/src/utils/math-config.ts +0 -109
- package/src/utils/setup-file.ts +0 -36
- package/src/utils/zstd.ts +0 -28
- package/src/winTypes/ClusterWinType.ts +0 -3
- package/src/winTypes/LinesWinType.ts +0 -208
- package/src/winTypes/ManywaysWinType.ts +0 -3
- package/tsconfig.json +0 -19
- package/utils.ts +0 -270
package/src/ReelGenerator.ts
DELETED
|
@@ -1,589 +0,0 @@
|
|
|
1
|
-
import fs from "fs"
|
|
2
|
-
import path from "path"
|
|
3
|
-
import { AnyGameConfig, GameConfig } from "./GameConfig"
|
|
4
|
-
import { GameSymbol } from "./GameSymbol"
|
|
5
|
-
import { createDirIfNotExists, RandomNumberGenerator, weightedRandom } from "../utils"
|
|
6
|
-
import { isMainThread } from "worker_threads"
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* This class is responsible for generating reel sets for slot games based on specified configurations.
|
|
10
|
-
*
|
|
11
|
-
* **While it offers a high degree of customization, some configurations may lead to unsolvable scenarios.**
|
|
12
|
-
*
|
|
13
|
-
* If the reel generator is unable to fulfill niche constraints,\
|
|
14
|
-
* you might need to adjust your configuration, or edit the generated reels manually.\
|
|
15
|
-
* Setting a different seed may also help.
|
|
16
|
-
*/
|
|
17
|
-
export class ReelGenerator {
|
|
18
|
-
id: string
|
|
19
|
-
associatedGameModeName: string = ""
|
|
20
|
-
protected readonly symbolWeights: Map<string, number> = new Map()
|
|
21
|
-
protected readonly rowsAmount: number
|
|
22
|
-
reels: Reels = []
|
|
23
|
-
protected limitSymbolsToReels?: Record<string, number[]>
|
|
24
|
-
protected readonly spaceBetweenSameSymbols?: number | Record<string, number>
|
|
25
|
-
protected readonly spaceBetweenSymbols?: Record<string, Record<string, number>>
|
|
26
|
-
protected readonly preferStackedSymbols?: number
|
|
27
|
-
protected readonly symbolStacks?: Record<
|
|
28
|
-
string,
|
|
29
|
-
{
|
|
30
|
-
chance: number | Record<string, number>
|
|
31
|
-
min?: number | Record<string, number>
|
|
32
|
-
max?: number | Record<string, number>
|
|
33
|
-
}
|
|
34
|
-
>
|
|
35
|
-
protected readonly symbolQuotas?: Record<string, number | Record<string, number>>
|
|
36
|
-
outputDir: string = ""
|
|
37
|
-
csvPath: string = ""
|
|
38
|
-
overrideExisting: boolean
|
|
39
|
-
rng: RandomNumberGenerator
|
|
40
|
-
|
|
41
|
-
constructor(opts: ReelGeneratorOpts) {
|
|
42
|
-
this.id = opts.id
|
|
43
|
-
this.symbolWeights = new Map(Object.entries(opts.symbolWeights))
|
|
44
|
-
this.rowsAmount = opts.rowsAmount || 250
|
|
45
|
-
this.outputDir = opts.outputDir
|
|
46
|
-
|
|
47
|
-
if (opts.limitSymbolsToReels) this.limitSymbolsToReels = opts.limitSymbolsToReels
|
|
48
|
-
|
|
49
|
-
this.overrideExisting = opts.overrideExisting || false
|
|
50
|
-
this.spaceBetweenSameSymbols = opts.spaceBetweenSameSymbols
|
|
51
|
-
this.spaceBetweenSymbols = opts.spaceBetweenSymbols
|
|
52
|
-
this.preferStackedSymbols = opts.preferStackedSymbols
|
|
53
|
-
this.symbolStacks = opts.symbolStacks
|
|
54
|
-
this.symbolQuotas = opts.symbolQuotas
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
(typeof this.spaceBetweenSameSymbols == "number" &&
|
|
58
|
-
(this.spaceBetweenSameSymbols < 1 || this.spaceBetweenSameSymbols > 8)) ||
|
|
59
|
-
(typeof this.spaceBetweenSameSymbols == "object" &&
|
|
60
|
-
Object.values(this.spaceBetweenSameSymbols).some((v) => v < 1 || v > 8))
|
|
61
|
-
) {
|
|
62
|
-
throw new Error(
|
|
63
|
-
`spaceBetweenSameSymbols must be between 1 and 8, got ${this.spaceBetweenSameSymbols}.`,
|
|
64
|
-
)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
Object.values(this.spaceBetweenSymbols || {}).some((o) =>
|
|
69
|
-
Object.values(o).some((v) => v < 1 || v > 8),
|
|
70
|
-
)
|
|
71
|
-
) {
|
|
72
|
-
throw new Error(
|
|
73
|
-
`spaceBetweenSymbols must be between 1 and 8, got ${this.spaceBetweenSymbols}.`,
|
|
74
|
-
)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (
|
|
78
|
-
this.preferStackedSymbols &&
|
|
79
|
-
(this.preferStackedSymbols < 0 || this.preferStackedSymbols > 100)
|
|
80
|
-
) {
|
|
81
|
-
throw new Error(
|
|
82
|
-
`preferStackedSymbols must be between 0 and 100, got ${this.preferStackedSymbols}.`,
|
|
83
|
-
)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
this.rng = new RandomNumberGenerator()
|
|
87
|
-
this.rng.setSeed(opts.seed ?? 0)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
private validateConfig({ config }: GameConfig) {
|
|
91
|
-
config.symbols.forEach((symbol) => {
|
|
92
|
-
if (!this.symbolWeights.has(symbol.id)) {
|
|
93
|
-
throw new Error(
|
|
94
|
-
[
|
|
95
|
-
`Symbol "${symbol.id}" is not defined in the symbol weights of the reel generator ${this.id} for mode ${this.associatedGameModeName}.`,
|
|
96
|
-
`Please ensure all symbols have weights defined.\n`,
|
|
97
|
-
].join(" "),
|
|
98
|
-
)
|
|
99
|
-
}
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
for (const [symbolId, weight] of this.symbolWeights.entries()) {
|
|
103
|
-
if (!config.symbols.has(symbolId)) {
|
|
104
|
-
throw new Error(
|
|
105
|
-
[
|
|
106
|
-
`Symbol "${symbolId}" is defined in the reel generator's symbol weights, but does not exist in the game config.`,
|
|
107
|
-
`Please ensure all symbols in the reel generator are defined in the game config.\n`,
|
|
108
|
-
].join(" "),
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (this.limitSymbolsToReels && Object.keys(this.limitSymbolsToReels).length == 0) {
|
|
114
|
-
this.limitSymbolsToReels = undefined
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (this.outputDir === "") {
|
|
118
|
-
throw new Error("Output directory must be specified for the ReelGenerator.")
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
private isSymbolAllowedOnReel(symbolId: string, reelIdx: number) {
|
|
123
|
-
if (!this.limitSymbolsToReels) return true
|
|
124
|
-
const allowedReels = this.limitSymbolsToReels[symbolId]
|
|
125
|
-
if (!allowedReels || allowedReels.length === 0) return true
|
|
126
|
-
return allowedReels.includes(reelIdx)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
private resolveStacking(symbolId: string, reelIdx: number) {
|
|
130
|
-
const cfg = this.symbolStacks?.[symbolId]
|
|
131
|
-
if (!cfg) return null
|
|
132
|
-
|
|
133
|
-
const STACKING_MIN = 1
|
|
134
|
-
const STACKING_MAX = 4
|
|
135
|
-
|
|
136
|
-
const chance =
|
|
137
|
-
typeof cfg.chance === "number" ? cfg.chance : (cfg.chance?.[reelIdx] ?? 0)
|
|
138
|
-
if (chance <= 0) return null
|
|
139
|
-
|
|
140
|
-
let min = typeof cfg.min === "number" ? cfg.min : (cfg.min?.[reelIdx] ?? STACKING_MIN)
|
|
141
|
-
let max = typeof cfg.max === "number" ? cfg.max : (cfg.max?.[reelIdx] ?? STACKING_MAX)
|
|
142
|
-
|
|
143
|
-
return { chance, min, max }
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
private tryPlaceStack(
|
|
147
|
-
reel: Array<GameSymbol | null>,
|
|
148
|
-
gameConf: GameConfig,
|
|
149
|
-
reelIdx: number,
|
|
150
|
-
symbolId: string,
|
|
151
|
-
startIndex: number,
|
|
152
|
-
maxStack: number,
|
|
153
|
-
) {
|
|
154
|
-
if (!this.isSymbolAllowedOnReel(symbolId, reelIdx)) return 0
|
|
155
|
-
|
|
156
|
-
let canPlace = 0
|
|
157
|
-
for (let j = 0; j < maxStack; j++) {
|
|
158
|
-
const idx = (startIndex + j) % this.rowsAmount
|
|
159
|
-
if (reel[idx] !== null) break
|
|
160
|
-
canPlace++
|
|
161
|
-
}
|
|
162
|
-
if (canPlace === 0) return 0
|
|
163
|
-
|
|
164
|
-
const symObj = gameConf.config.symbols.get(symbolId)
|
|
165
|
-
if (!symObj) {
|
|
166
|
-
throw new Error(
|
|
167
|
-
`Symbol with id "${symbolId}" not found in the game config symbols map.`,
|
|
168
|
-
)
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
for (let j = 0; j < canPlace; j++) {
|
|
172
|
-
const idx = (startIndex + j) % reel.length
|
|
173
|
-
reel[idx] = symObj
|
|
174
|
-
}
|
|
175
|
-
return canPlace
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Checks if a symbol can be placed at the target index without violating spacing rules.
|
|
180
|
-
*/
|
|
181
|
-
private violatesSpacing(
|
|
182
|
-
reel: Array<GameSymbol | null>,
|
|
183
|
-
symbolId: string,
|
|
184
|
-
targetIndex: number,
|
|
185
|
-
) {
|
|
186
|
-
const circDist = (a: number, b: number) => {
|
|
187
|
-
const diff = Math.abs(a - b)
|
|
188
|
-
return Math.min(diff, this.rowsAmount - diff)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const spacingType = this.spaceBetweenSameSymbols ?? undefined
|
|
192
|
-
const sameSpacing =
|
|
193
|
-
typeof spacingType === "number" ? spacingType : (spacingType?.[symbolId] ?? 0)
|
|
194
|
-
|
|
195
|
-
for (let i = 0; i <= reel.length; i++) {
|
|
196
|
-
const placed = reel[i]
|
|
197
|
-
if (!placed) continue
|
|
198
|
-
|
|
199
|
-
const dist = circDist(targetIndex, i)
|
|
200
|
-
|
|
201
|
-
// Same symbol spacing
|
|
202
|
-
if (sameSpacing >= 1 && placed.id === symbolId) {
|
|
203
|
-
if (dist <= sameSpacing) return true
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Cross-symbol spacing
|
|
207
|
-
if (this.spaceBetweenSymbols) {
|
|
208
|
-
const forward = this.spaceBetweenSymbols[symbolId]?.[placed.id] ?? 0
|
|
209
|
-
if (forward >= 1 && dist <= forward) return true
|
|
210
|
-
|
|
211
|
-
const reverse = this.spaceBetweenSymbols[placed.id]?.[symbolId] ?? 0
|
|
212
|
-
if (reverse >= 1 && dist <= reverse) return true
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return false
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
generateReels(gameConf: AnyGameConfig) {
|
|
220
|
-
this.validateConfig(gameConf)
|
|
221
|
-
|
|
222
|
-
const gameMode = gameConf.config.gameModes[this.associatedGameModeName]
|
|
223
|
-
|
|
224
|
-
if (!gameMode) {
|
|
225
|
-
throw new Error(
|
|
226
|
-
`Error generating reels for game mode "${this.associatedGameModeName}". It's not defined in the game config.`,
|
|
227
|
-
)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const filePath = path.join(
|
|
231
|
-
gameConf.config.outputDir,
|
|
232
|
-
`reels_${this.associatedGameModeName}-${this.id}.csv`,
|
|
233
|
-
)
|
|
234
|
-
this.csvPath = filePath
|
|
235
|
-
|
|
236
|
-
const exists = fs.existsSync(filePath)
|
|
237
|
-
|
|
238
|
-
const reelsAmount = gameMode.reelsAmount
|
|
239
|
-
const weightsObj = Object.fromEntries(this.symbolWeights)
|
|
240
|
-
|
|
241
|
-
// Generate initial reels with random symbols
|
|
242
|
-
for (let ridx = 0; ridx < reelsAmount; ridx++) {
|
|
243
|
-
const reel: Array<GameSymbol | null> = new Array(this.rowsAmount).fill(null)
|
|
244
|
-
|
|
245
|
-
const reelQuotas: Record<string, number> = {}
|
|
246
|
-
const quotaCounts: Record<string, number> = {}
|
|
247
|
-
let totalReelsQuota = 0
|
|
248
|
-
|
|
249
|
-
// Get quotas for this reel, across all symbols
|
|
250
|
-
for (const [sym, quotaConf] of Object.entries(this.symbolQuotas || {})) {
|
|
251
|
-
const q = typeof quotaConf === "number" ? quotaConf : quotaConf[ridx]
|
|
252
|
-
if (!q) continue
|
|
253
|
-
reelQuotas[sym] = q
|
|
254
|
-
totalReelsQuota += q
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (totalReelsQuota > 100) {
|
|
258
|
-
throw new Error(
|
|
259
|
-
`Total symbol quotas for reel ${ridx} exceed 100%. Adjust your configuration on ReelGenerator "${this.id}".`,
|
|
260
|
-
)
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (totalReelsQuota > 0) {
|
|
264
|
-
for (const [sym, quota] of Object.entries(reelQuotas)) {
|
|
265
|
-
const quotaCount = Math.max(1, Math.floor((this.rowsAmount * quota) / 100))
|
|
266
|
-
quotaCounts[sym] = quotaCount
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Place required quotas first (use stacking over spacing, if configured)
|
|
271
|
-
for (const [sym, targetCount] of Object.entries(quotaCounts)) {
|
|
272
|
-
let remaining = targetCount
|
|
273
|
-
let attempts = 0
|
|
274
|
-
|
|
275
|
-
while (remaining > 0) {
|
|
276
|
-
if (attempts++ > this.rowsAmount * 10) {
|
|
277
|
-
throw new Error(
|
|
278
|
-
`Failed to place ${targetCount} of symbol ${sym} on reel ${ridx} (likely spacing/stacking too strict).`,
|
|
279
|
-
)
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const pos = Math.round(this.rng.randomFloat(0, this.rowsAmount - 1))
|
|
283
|
-
const stackCfg = this.resolveStacking(sym, ridx)
|
|
284
|
-
let placed = 0
|
|
285
|
-
|
|
286
|
-
// Try to place a symbol stack first, if configured
|
|
287
|
-
if (stackCfg && Math.round(this.rng.randomFloat(1, 100)) <= stackCfg.chance) {
|
|
288
|
-
const stackSize = Math.max(
|
|
289
|
-
0,
|
|
290
|
-
Math.round(this.rng.randomFloat(stackCfg.min, stackCfg.max)),
|
|
291
|
-
)
|
|
292
|
-
const toPlace = Math.min(stackSize, remaining)
|
|
293
|
-
placed = this.tryPlaceStack(reel, gameConf, ridx, sym, pos, toPlace)
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Not enough space, fall back to placing single symbols
|
|
297
|
-
if (
|
|
298
|
-
placed === 0 &&
|
|
299
|
-
reel[pos] === null &&
|
|
300
|
-
this.isSymbolAllowedOnReel(sym, ridx) &&
|
|
301
|
-
!this.violatesSpacing(reel, sym, pos)
|
|
302
|
-
) {
|
|
303
|
-
reel[pos] = gameConf.config.symbols.get(sym)!
|
|
304
|
-
placed = 1
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
remaining -= placed
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Fill the rest of the reel randomly
|
|
312
|
-
for (let r = 0; r < this.rowsAmount; r++) {
|
|
313
|
-
if (reel[r] !== null) continue // already placed quota
|
|
314
|
-
|
|
315
|
-
let chosenSymbolId = weightedRandom(weightsObj, this.rng)
|
|
316
|
-
|
|
317
|
-
// If symbolStacks is NOT configured for the next choice, allow "preferStackedSymbols" fallback
|
|
318
|
-
const nextHasStackCfg = !!this.resolveStacking(chosenSymbolId, ridx)
|
|
319
|
-
if (!nextHasStackCfg && this.preferStackedSymbols && reel.length > 0) {
|
|
320
|
-
const prevSymbol = r - 1 >= 0 ? reel[r - 1] : reel[reel.length - 1]
|
|
321
|
-
if (
|
|
322
|
-
prevSymbol &&
|
|
323
|
-
Math.round(this.rng.randomFloat(1, 100)) <= this.preferStackedSymbols &&
|
|
324
|
-
(!this.spaceBetweenSameSymbols ||
|
|
325
|
-
!this.violatesSpacing(reel, prevSymbol.id, r))
|
|
326
|
-
) {
|
|
327
|
-
chosenSymbolId = prevSymbol.id
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Check for stacking preference
|
|
332
|
-
if (this.preferStackedSymbols && reel.length > 0) {
|
|
333
|
-
const prevSymbol = r - 1 >= 0 ? reel[r - 1] : reel[reel.length - 1]
|
|
334
|
-
|
|
335
|
-
if (
|
|
336
|
-
Math.round(this.rng.randomFloat(1, 100)) <= this.preferStackedSymbols &&
|
|
337
|
-
(!this.spaceBetweenSameSymbols ||
|
|
338
|
-
!this.violatesSpacing(reel, prevSymbol!.id, r))
|
|
339
|
-
) {
|
|
340
|
-
chosenSymbolId = prevSymbol!.id
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// If symbol has stack config, try to place a stack (ignore spacing)
|
|
345
|
-
const stackCfg = this.resolveStacking(chosenSymbolId, ridx)
|
|
346
|
-
if (stackCfg && this.isSymbolAllowedOnReel(chosenSymbolId, ridx)) {
|
|
347
|
-
const roll = Math.round(this.rng.randomFloat(1, 100))
|
|
348
|
-
if (roll <= stackCfg.chance) {
|
|
349
|
-
const desiredSize = Math.max(
|
|
350
|
-
1,
|
|
351
|
-
Math.round(this.rng.randomFloat(stackCfg.min, stackCfg.max)),
|
|
352
|
-
)
|
|
353
|
-
const placed = this.tryPlaceStack(
|
|
354
|
-
reel,
|
|
355
|
-
gameConf,
|
|
356
|
-
ridx,
|
|
357
|
-
chosenSymbolId,
|
|
358
|
-
r,
|
|
359
|
-
desiredSize,
|
|
360
|
-
)
|
|
361
|
-
if (placed > 0) {
|
|
362
|
-
// advance loop to skip the cells we just filled on this side of the boundary
|
|
363
|
-
// (wrapped cells at the start are already filled and will be skipped when encountered)
|
|
364
|
-
r += placed - 1
|
|
365
|
-
continue
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
let tries = 0
|
|
371
|
-
const maxTries = 2500
|
|
372
|
-
|
|
373
|
-
while (
|
|
374
|
-
!this.isSymbolAllowedOnReel(chosenSymbolId, ridx) ||
|
|
375
|
-
this.violatesSpacing(reel, chosenSymbolId, r)
|
|
376
|
-
) {
|
|
377
|
-
if (++tries > maxTries) {
|
|
378
|
-
throw new Error(
|
|
379
|
-
[
|
|
380
|
-
`Failed to place a symbol on reel ${ridx} at position ${r} after ${maxTries} attempts.\n`,
|
|
381
|
-
"Try to change the seed or adjust your configuration.\n",
|
|
382
|
-
].join(" "),
|
|
383
|
-
)
|
|
384
|
-
}
|
|
385
|
-
chosenSymbolId = weightedRandom(weightsObj, this.rng)
|
|
386
|
-
|
|
387
|
-
const hasStackCfg = !!this.resolveStacking(chosenSymbolId, ridx)
|
|
388
|
-
if (!hasStackCfg && this.preferStackedSymbols && reel.length > 0) {
|
|
389
|
-
const prevSymbol = r - 1 >= 0 ? reel[r - 1] : reel[reel.length - 1]
|
|
390
|
-
if (
|
|
391
|
-
prevSymbol &&
|
|
392
|
-
Math.round(this.rng.randomFloat(1, 100)) <= this.preferStackedSymbols &&
|
|
393
|
-
(!this.spaceBetweenSameSymbols ||
|
|
394
|
-
!this.violatesSpacing(reel, prevSymbol.id, r))
|
|
395
|
-
) {
|
|
396
|
-
chosenSymbolId = prevSymbol.id
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const symbol = gameConf.config.symbols.get(chosenSymbolId)
|
|
402
|
-
|
|
403
|
-
if (!symbol) {
|
|
404
|
-
throw new Error(
|
|
405
|
-
`Symbol with id "${chosenSymbolId}" not found in the game config symbols map.`,
|
|
406
|
-
)
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
reel[r] = symbol
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (reel.some((s) => s === null)) {
|
|
413
|
-
throw new Error(`Reel ${ridx} has unfilled positions after generation.`)
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
this.reels.push(reel as GameSymbol[])
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Write the CSV
|
|
420
|
-
const csvRows: string[][] = Array.from({ length: this.rowsAmount }, () =>
|
|
421
|
-
Array.from({ length: reelsAmount }, () => ""),
|
|
422
|
-
)
|
|
423
|
-
|
|
424
|
-
for (let ridx = 0; ridx < reelsAmount; ridx++) {
|
|
425
|
-
for (let r = 0; r < this.rowsAmount; r++) {
|
|
426
|
-
csvRows[r]![ridx] = this.reels[ridx]![r]!.id
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
const csvString = csvRows.map((row) => row.join(",")).join("\n")
|
|
431
|
-
|
|
432
|
-
if (isMainThread) {
|
|
433
|
-
createDirIfNotExists(this.outputDir)
|
|
434
|
-
fs.writeFileSync(filePath, csvString)
|
|
435
|
-
|
|
436
|
-
this.reels = this.parseReelsetCSV(filePath, gameConf)
|
|
437
|
-
console.log(
|
|
438
|
-
`Generated reelset ${this.id} for game mode ${this.associatedGameModeName}`,
|
|
439
|
-
)
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if (exists) {
|
|
443
|
-
this.reels = this.parseReelsetCSV(filePath, gameConf)
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Reads a reelset CSV file and returns the reels as arrays of GameSymbols.
|
|
449
|
-
*/
|
|
450
|
-
parseReelsetCSV(reelSetPath: string, { config }: GameConfig) {
|
|
451
|
-
const csvData = fs.readFileSync(reelSetPath, "utf8")
|
|
452
|
-
const rows = csvData.split("\n").filter((line) => line.trim() !== "")
|
|
453
|
-
const reels: Reels = Array.from(
|
|
454
|
-
{ length: config.gameModes[this.associatedGameModeName]!.reelsAmount },
|
|
455
|
-
() => [],
|
|
456
|
-
)
|
|
457
|
-
rows.forEach((row) => {
|
|
458
|
-
const symsInRow = row.split(",").map((symbolId) => {
|
|
459
|
-
const symbol = config.symbols.get(symbolId.trim())
|
|
460
|
-
if (!symbol) {
|
|
461
|
-
throw new Error(`Symbol with id "${symbolId}" not found in game config.`)
|
|
462
|
-
}
|
|
463
|
-
return symbol
|
|
464
|
-
})
|
|
465
|
-
symsInRow.forEach((symbol, ridx) => {
|
|
466
|
-
reels[ridx]!.push(symbol)
|
|
467
|
-
})
|
|
468
|
-
})
|
|
469
|
-
return reels
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
interface ReelGeneratorOpts {
|
|
474
|
-
/**
|
|
475
|
-
* The unique identifier of the reel generator.\
|
|
476
|
-
* Must be unique per game mode.
|
|
477
|
-
*/
|
|
478
|
-
id: string
|
|
479
|
-
/**
|
|
480
|
-
* The weights of the symbols in the reelset.\
|
|
481
|
-
* This is a mapping of symbol IDs to their respective weights.
|
|
482
|
-
*/
|
|
483
|
-
symbolWeights: Record<string, number>
|
|
484
|
-
/**
|
|
485
|
-
* The number of rows in the reelset.\
|
|
486
|
-
* Default is 250, but can be adjusted as needed.
|
|
487
|
-
*/
|
|
488
|
-
rowsAmount?: number
|
|
489
|
-
/**
|
|
490
|
-
* The directory where the generated reelset files will be saved.\
|
|
491
|
-
* **It's recommended to just use `__dirname`**!
|
|
492
|
-
*/
|
|
493
|
-
outputDir: string
|
|
494
|
-
/**
|
|
495
|
-
* Prevent the same symbol from appearing directly above or below itself.\
|
|
496
|
-
* This can be a single number for all symbols, or a mapping of symbol IDs to
|
|
497
|
-
* their respective spacing values.
|
|
498
|
-
*
|
|
499
|
-
* Must be 1 or higher, if set.
|
|
500
|
-
*
|
|
501
|
-
* **This is overridden by `symbolStacks`**
|
|
502
|
-
*/
|
|
503
|
-
spaceBetweenSameSymbols?: number | Record<string, number>
|
|
504
|
-
/**
|
|
505
|
-
* Prevents specific symbols from appearing within a certain distance of each other.
|
|
506
|
-
*
|
|
507
|
-
* Useful for preventing scatter and super scatter symbols from appearing too close to each other.
|
|
508
|
-
*
|
|
509
|
-
* **This is overridden by `symbolStacks`**
|
|
510
|
-
*/
|
|
511
|
-
spaceBetweenSymbols?: Record<string, Record<string, number>>
|
|
512
|
-
/**
|
|
513
|
-
* A percentage value 0-100 that indicates the likelihood of a symbol being stacked.\
|
|
514
|
-
* A value of 0 means no stacked symbols, while 100 means all symbols are stacked.
|
|
515
|
-
*
|
|
516
|
-
* This is only a preference. Symbols may still not be stacked if\
|
|
517
|
-
* other restrictions (like `spaceBetweenSameSymbols`) prevent it.
|
|
518
|
-
*
|
|
519
|
-
* **This is overridden by `symbolStacks`**
|
|
520
|
-
*/
|
|
521
|
-
preferStackedSymbols?: number
|
|
522
|
-
/**
|
|
523
|
-
* A mapping of symbols to their respective advanced stacking configuration.
|
|
524
|
-
*
|
|
525
|
-
* @example
|
|
526
|
-
* ```ts
|
|
527
|
-
* symbolStacks: {
|
|
528
|
-
* "W": {
|
|
529
|
-
* chance: { "1": 20, "2": 20, "3": 20, "4": 20 }, // 20% chance to be stacked on reels 2-5
|
|
530
|
-
* min: 2, // At least 2 wilds in a stack
|
|
531
|
-
* max: 4, // At most 4 wilds in a stack
|
|
532
|
-
* }
|
|
533
|
-
* }
|
|
534
|
-
* ```
|
|
535
|
-
*/
|
|
536
|
-
symbolStacks?: Record<
|
|
537
|
-
string,
|
|
538
|
-
{
|
|
539
|
-
chance: number | Record<string, number>
|
|
540
|
-
min?: number | Record<string, number>
|
|
541
|
-
max?: number | Record<string, number>
|
|
542
|
-
}
|
|
543
|
-
>
|
|
544
|
-
/**
|
|
545
|
-
* Configures symbols to be limited to specific reels.\
|
|
546
|
-
* For example, you could configure Scatters to appear only on reels 1, 3 and 5.
|
|
547
|
-
*
|
|
548
|
-
* @example
|
|
549
|
-
* ```ts
|
|
550
|
-
* limitSymbolsToReels: {
|
|
551
|
-
* "S": [0, 2, 4], // Remember that reels are 0-indexed.
|
|
552
|
-
* }
|
|
553
|
-
* ```
|
|
554
|
-
*/
|
|
555
|
-
limitSymbolsToReels?: Record<string, number[]>
|
|
556
|
-
/**
|
|
557
|
-
* Defines optional quotas for symbols on the reels.\
|
|
558
|
-
* The quota (1-100%) defines how often a symbol should appear in the reelset, or in a specific reel.
|
|
559
|
-
*
|
|
560
|
-
* This is particularly useful for controlling the frequency of special symbols like scatters or wilds.
|
|
561
|
-
*
|
|
562
|
-
* Reels not provided for a symbol will use the weights from `symbolWeights`.
|
|
563
|
-
*
|
|
564
|
-
* _Any_ small quota will ensure that the symbol appears at least once on the reel.
|
|
565
|
-
*
|
|
566
|
-
* @example
|
|
567
|
-
* ```ts
|
|
568
|
-
* symbolQuotas: {
|
|
569
|
-
* "S": 3, // 3% of symbols on each reel will be scatters
|
|
570
|
-
* "W": { "1": 10, "2": 5, "3": 3, "4": 1 }, // Wilds will appear with different quotas on selected reels
|
|
571
|
-
* }
|
|
572
|
-
* ```
|
|
573
|
-
*/
|
|
574
|
-
symbolQuotas?: Record<string, number | Record<string, number>>
|
|
575
|
-
/**
|
|
576
|
-
* If true, existing reels CSV files will be overwritten.
|
|
577
|
-
*/
|
|
578
|
-
overrideExisting?: boolean
|
|
579
|
-
/**
|
|
580
|
-
* Optional seed for the RNG to ensure reproducible results.
|
|
581
|
-
*
|
|
582
|
-
* Default seed is `0`.
|
|
583
|
-
*
|
|
584
|
-
* Note: Seeds 0 and 1 produce the same results.
|
|
585
|
-
*/
|
|
586
|
-
seed?: number
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
export type Reels = GameSymbol[][]
|