@slot-engine/core 0.0.1
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/.turbo/turbo-build.log +33 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +7 -0
- package/README.md +8 -0
- package/dist/index.d.mts +1306 -0
- package/dist/index.d.ts +1306 -0
- package/dist/index.js +2929 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2874 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lib/zstd.exe +0 -0
- package/dist/optimizer-rust/Cargo.toml +19 -0
- package/dist/optimizer-rust/src/exes.rs +154 -0
- package/dist/optimizer-rust/src/main.rs +1659 -0
- package/index.ts +205 -0
- package/lib/zstd.exe +0 -0
- package/optimizer-rust/Cargo.toml +19 -0
- package/optimizer-rust/src/exes.rs +154 -0
- package/optimizer-rust/src/main.rs +1659 -0
- package/package.json +33 -0
- package/src/Board.ts +527 -0
- package/src/Book.ts +83 -0
- package/src/GameConfig.ts +148 -0
- package/src/GameMode.ts +86 -0
- package/src/GameState.ts +272 -0
- package/src/GameSymbol.ts +61 -0
- package/src/ReelGenerator.ts +589 -0
- package/src/ResultSet.ts +207 -0
- package/src/Simulation.ts +625 -0
- package/src/SlotGame.ts +117 -0
- package/src/Wallet.ts +203 -0
- package/src/WinType.ts +102 -0
- package/src/analysis/index.ts +198 -0
- package/src/analysis/utils.ts +128 -0
- package/src/optimizer/OptimizationConditions.ts +99 -0
- package/src/optimizer/OptimizationParameters.ts +46 -0
- package/src/optimizer/OptimizationScaling.ts +18 -0
- package/src/optimizer/index.ts +142 -0
- package/src/utils/math-config.ts +109 -0
- package/src/utils/setup-file.ts +36 -0
- package/src/utils/zstd.ts +28 -0
- package/src/winTypes/ClusterWinType.ts +3 -0
- package/src/winTypes/LinesWinType.ts +208 -0
- package/src/winTypes/ManywaysWinType.ts +3 -0
- package/tsconfig.json +19 -0
- package/utils.ts +270 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import assert from "assert"
|
|
4
|
+
import { buildSync } from "esbuild"
|
|
5
|
+
import { createDirIfNotExists, JSONL, printBoard, writeFile } from "../utils"
|
|
6
|
+
import { Board } from "./Board"
|
|
7
|
+
import { GameConfig } from "./GameConfig"
|
|
8
|
+
import { GameModeName } from "./GameMode"
|
|
9
|
+
import { ResultSet } from "./ResultSet"
|
|
10
|
+
import { Wallet } from "./Wallet"
|
|
11
|
+
import { Book } from "./Book"
|
|
12
|
+
import { Worker, isMainThread, parentPort, workerData } from "worker_threads"
|
|
13
|
+
import { RecordItem } from "./GameState"
|
|
14
|
+
import { zstd } from "./utils/zstd"
|
|
15
|
+
import { AnyGameModes, AnySymbols, AnyUserData, CommonGameOptions } from "../index"
|
|
16
|
+
|
|
17
|
+
let completedSimulations = 0
|
|
18
|
+
const TEMP_FILENAME = "__temp_compiled_src_IGNORE.js"
|
|
19
|
+
|
|
20
|
+
export class Simulation {
|
|
21
|
+
protected readonly gameConfigOpts: CommonGameOptions
|
|
22
|
+
readonly gameConfig: GameConfig
|
|
23
|
+
readonly simRunsAmount: Partial<Record<GameModeName, number>>
|
|
24
|
+
private readonly concurrency: number
|
|
25
|
+
private wallet: Wallet
|
|
26
|
+
private library: Map<string, Book>
|
|
27
|
+
readonly records: RecordItem[]
|
|
28
|
+
protected debug = false
|
|
29
|
+
|
|
30
|
+
constructor(opts: SimulationConfigOpts, gameConfigOpts: CommonGameOptions) {
|
|
31
|
+
this.gameConfig = new GameConfig(gameConfigOpts)
|
|
32
|
+
this.gameConfigOpts = gameConfigOpts
|
|
33
|
+
this.simRunsAmount = opts.simRunsAmount || {}
|
|
34
|
+
this.concurrency = (opts.concurrency || 6) >= 2 ? opts.concurrency || 6 : 2
|
|
35
|
+
this.wallet = new Wallet()
|
|
36
|
+
this.library = new Map()
|
|
37
|
+
this.records = []
|
|
38
|
+
|
|
39
|
+
const gameModeKeys = Object.keys(this.gameConfig.config.gameModes)
|
|
40
|
+
assert(
|
|
41
|
+
Object.values(this.gameConfig.config.gameModes)
|
|
42
|
+
.map((m) => gameModeKeys.includes(m.name))
|
|
43
|
+
.every((v) => v === true),
|
|
44
|
+
"Game mode name must match its key in the gameModes object.",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if (isMainThread) {
|
|
48
|
+
this.preprocessFiles()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async runSimulation(opts: SimulationOpts) {
|
|
53
|
+
const debug = opts.debug || false
|
|
54
|
+
this.debug = debug
|
|
55
|
+
|
|
56
|
+
const gameModesToSimulate = Object.keys(this.simRunsAmount)
|
|
57
|
+
const configuredGameModes = Object.keys(this.gameConfig.config.gameModes)
|
|
58
|
+
|
|
59
|
+
if (gameModesToSimulate.length === 0) {
|
|
60
|
+
throw new Error("No game modes configured for simulation.")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.gameConfig.generateReelsetFiles()
|
|
64
|
+
|
|
65
|
+
// Code that runs when the user executes the simulations.
|
|
66
|
+
// This spawns individual processes and merges the results afterwards.
|
|
67
|
+
if (isMainThread) {
|
|
68
|
+
const debugDetails: Record<string, Record<string, any>> = {}
|
|
69
|
+
|
|
70
|
+
for (const mode of gameModesToSimulate) {
|
|
71
|
+
completedSimulations = 0
|
|
72
|
+
this.wallet = new Wallet()
|
|
73
|
+
this.library = new Map()
|
|
74
|
+
|
|
75
|
+
debugDetails[mode] = {}
|
|
76
|
+
|
|
77
|
+
console.log(`\nSimulating game mode: ${mode}`)
|
|
78
|
+
console.time(mode)
|
|
79
|
+
|
|
80
|
+
const runs = this.simRunsAmount[mode] || 0
|
|
81
|
+
|
|
82
|
+
if (runs <= 0) continue
|
|
83
|
+
|
|
84
|
+
if (!configuredGameModes.includes(mode)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Tried to simulate game mode "${mode}", but it's not configured in the game config.`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode)
|
|
91
|
+
|
|
92
|
+
await this.spawnWorkersForGameMode({ mode, simNumsToCriteria })
|
|
93
|
+
|
|
94
|
+
createDirIfNotExists(
|
|
95
|
+
path.join(
|
|
96
|
+
process.cwd(),
|
|
97
|
+
this.gameConfig.config.outputDir,
|
|
98
|
+
"optimization_files",
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
createDirIfNotExists(
|
|
102
|
+
path.join(process.cwd(), this.gameConfig.config.outputDir, "publish_files"),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
Simulation.writeLookupTableCSV({
|
|
106
|
+
gameMode: mode,
|
|
107
|
+
library: this.library,
|
|
108
|
+
gameConfig: this.gameConfig.config,
|
|
109
|
+
})
|
|
110
|
+
Simulation.writeLookupTableSegmentedCSV({
|
|
111
|
+
gameMode: mode,
|
|
112
|
+
library: this.library,
|
|
113
|
+
gameConfig: this.gameConfig.config,
|
|
114
|
+
})
|
|
115
|
+
Simulation.writeRecords({
|
|
116
|
+
gameMode: mode,
|
|
117
|
+
records: this.records,
|
|
118
|
+
gameConfig: this.gameConfig.config,
|
|
119
|
+
debug: this.debug,
|
|
120
|
+
})
|
|
121
|
+
await Simulation.writeBooksJson({
|
|
122
|
+
gameMode: mode,
|
|
123
|
+
library: this.library,
|
|
124
|
+
gameConfig: this.gameConfig.config,
|
|
125
|
+
})
|
|
126
|
+
Simulation.writeIndexJson({
|
|
127
|
+
gameConfig: this.gameConfig.config,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
debugDetails[mode].rtp =
|
|
131
|
+
this.wallet.getCumulativeWins() /
|
|
132
|
+
(runs * this.gameConfig.config.gameModes[mode]!.cost)
|
|
133
|
+
|
|
134
|
+
debugDetails[mode].wins = this.wallet.getCumulativeWins()
|
|
135
|
+
debugDetails[mode].winsPerSpinType = this.wallet.getCumulativeWinsPerSpinType()
|
|
136
|
+
|
|
137
|
+
console.timeEnd(mode)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log("\n=== SIMULATION SUMMARY ===")
|
|
141
|
+
console.table(debugDetails)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let desiredSims = 0
|
|
145
|
+
let actualSims = 0
|
|
146
|
+
const criteriaToRetries: Record<string, number> = {}
|
|
147
|
+
|
|
148
|
+
// Code that runs for individual processes
|
|
149
|
+
if (!isMainThread) {
|
|
150
|
+
const { mode, simStart, simEnd, index } = workerData
|
|
151
|
+
|
|
152
|
+
const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode)
|
|
153
|
+
|
|
154
|
+
// Run each simulation until the criteria is met.
|
|
155
|
+
for (let simId = simStart; simId <= simEnd; simId++) {
|
|
156
|
+
if (this.debug) desiredSims++
|
|
157
|
+
|
|
158
|
+
const criteria = simNumsToCriteria[simId] || "N/A"
|
|
159
|
+
const ctx = new SimulationContext(this.gameConfigOpts)
|
|
160
|
+
|
|
161
|
+
if (!criteriaToRetries[criteria]) {
|
|
162
|
+
criteriaToRetries[criteria] = 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
ctx.runSingleSimulation({ simId, mode, criteria, index })
|
|
166
|
+
|
|
167
|
+
if (this.debug) {
|
|
168
|
+
criteriaToRetries[criteria] += ctx.actualSims - 1
|
|
169
|
+
actualSims += ctx.actualSims
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (this.debug) {
|
|
174
|
+
console.log(`Desired ${desiredSims}, Actual ${actualSims}`)
|
|
175
|
+
console.log(`Retries per criteria:`, criteriaToRetries)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
parentPort?.postMessage({
|
|
179
|
+
type: "done",
|
|
180
|
+
workerNum: index,
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Runs all simulations for a specific game mode.
|
|
187
|
+
*/
|
|
188
|
+
async spawnWorkersForGameMode(opts: {
|
|
189
|
+
mode: string
|
|
190
|
+
simNumsToCriteria: Record<number, string>
|
|
191
|
+
}) {
|
|
192
|
+
const { mode, simNumsToCriteria } = opts
|
|
193
|
+
|
|
194
|
+
const numSims = Object.keys(simNumsToCriteria).length
|
|
195
|
+
const simRangesPerChunk = this.getSimRangesForChunks(numSims, this.concurrency!)
|
|
196
|
+
|
|
197
|
+
await Promise.all(
|
|
198
|
+
simRangesPerChunk.map(([simStart, simEnd], index) => {
|
|
199
|
+
return this.callWorker({
|
|
200
|
+
basePath: this.gameConfig.config.outputDir,
|
|
201
|
+
mode,
|
|
202
|
+
simStart,
|
|
203
|
+
simEnd,
|
|
204
|
+
index,
|
|
205
|
+
totalSims: numSims,
|
|
206
|
+
})
|
|
207
|
+
}),
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async callWorker(opts: {
|
|
212
|
+
basePath: string
|
|
213
|
+
mode: string
|
|
214
|
+
simStart: number
|
|
215
|
+
simEnd: number
|
|
216
|
+
index: number
|
|
217
|
+
totalSims: number
|
|
218
|
+
}) {
|
|
219
|
+
const { mode, simEnd, simStart, basePath, index, totalSims } = opts
|
|
220
|
+
|
|
221
|
+
function logArrowProgress(current: number, total: number) {
|
|
222
|
+
const percentage = (current / total) * 100
|
|
223
|
+
const progressBarLength = 50
|
|
224
|
+
const filledLength = Math.round((progressBarLength * current) / total)
|
|
225
|
+
const bar = "█".repeat(filledLength) + "-".repeat(progressBarLength - filledLength)
|
|
226
|
+
process.stdout.write(`\r[${bar}] ${percentage.toFixed(2)}% (${current}/${total})`)
|
|
227
|
+
if (current === total) {
|
|
228
|
+
process.stdout.write("\n")
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
const scriptPath = path.join(process.cwd(), basePath, TEMP_FILENAME)
|
|
234
|
+
|
|
235
|
+
const worker = new Worker(scriptPath, {
|
|
236
|
+
workerData: {
|
|
237
|
+
mode,
|
|
238
|
+
simStart,
|
|
239
|
+
simEnd,
|
|
240
|
+
index,
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
worker.on("message", (msg) => {
|
|
245
|
+
if (msg.type === "log") {
|
|
246
|
+
//console.log(`[Worker ${msg.workerNum}] ${msg.message}`)
|
|
247
|
+
} else if (msg.type === "complete") {
|
|
248
|
+
completedSimulations++
|
|
249
|
+
|
|
250
|
+
if (completedSimulations % 250 === 0) {
|
|
251
|
+
logArrowProgress(completedSimulations, totalSims)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Write data to global library
|
|
255
|
+
const book = Book.fromSerialized(msg.book)
|
|
256
|
+
this.library.set(book.id.toString(), book)
|
|
257
|
+
this.wallet.mergeSerialized(msg.wallet)
|
|
258
|
+
this.mergeRecords(msg.records)
|
|
259
|
+
} else if (msg.type === "done") {
|
|
260
|
+
resolve(true)
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
worker.on("error", (error) => {
|
|
265
|
+
console.error("Error:", error)
|
|
266
|
+
reject(error)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
worker.on("exit", (code) => {
|
|
270
|
+
if (code !== 0) {
|
|
271
|
+
reject(new Error(`Worker stopped with exit code ${code}`))
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Creates a CSV file in the format "simulationId,weight,payout".
|
|
279
|
+
*
|
|
280
|
+
* `weight` defaults to 1.
|
|
281
|
+
*/
|
|
282
|
+
private static writeLookupTableCSV(opts: {
|
|
283
|
+
gameMode: string
|
|
284
|
+
library: Map<string, Book>
|
|
285
|
+
gameConfig: GameConfig["config"]
|
|
286
|
+
fileNameWithoutExtension?: string
|
|
287
|
+
}) {
|
|
288
|
+
const { gameMode, fileNameWithoutExtension, library, gameConfig } = opts
|
|
289
|
+
|
|
290
|
+
const rows: string[] = []
|
|
291
|
+
|
|
292
|
+
for (const [bookId, book] of library.entries()) {
|
|
293
|
+
rows.push(`${book.id},1,${Math.round(book.getPayout())}`)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
rows.sort((a, b) => Number(a.split(",")[0]) - Number(b.split(",")[0]))
|
|
297
|
+
|
|
298
|
+
const outputFileName = fileNameWithoutExtension
|
|
299
|
+
? `${fileNameWithoutExtension}.csv`
|
|
300
|
+
: `lookUpTable_${gameMode}.csv`
|
|
301
|
+
|
|
302
|
+
const outputFilePath = path.join(gameConfig.outputDir, outputFileName)
|
|
303
|
+
|
|
304
|
+
writeFile(outputFilePath, rows.join("\n"))
|
|
305
|
+
|
|
306
|
+
return outputFilePath
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Creates a CSV file in the format "simulationId,criteria,payoutBase,payoutFreespins".
|
|
311
|
+
*/
|
|
312
|
+
private static writeLookupTableSegmentedCSV(opts: {
|
|
313
|
+
gameMode: string
|
|
314
|
+
library: Map<string, Book>
|
|
315
|
+
gameConfig: GameConfig["config"]
|
|
316
|
+
fileNameWithoutExtension?: string
|
|
317
|
+
}) {
|
|
318
|
+
const { gameMode, fileNameWithoutExtension, library, gameConfig } = opts
|
|
319
|
+
|
|
320
|
+
const rows: string[] = []
|
|
321
|
+
|
|
322
|
+
for (const [bookId, book] of library.entries()) {
|
|
323
|
+
rows.push(
|
|
324
|
+
`${book.id},${book.criteria},${book.getBasegameWins()},${book.getFreespinsWins()}`,
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
rows.sort((a, b) => Number(a.split(",")[0]) - Number(b.split(",")[0]))
|
|
329
|
+
|
|
330
|
+
const outputFileName = fileNameWithoutExtension
|
|
331
|
+
? `${fileNameWithoutExtension}.csv`
|
|
332
|
+
: `lookUpTableSegmented_${gameMode}.csv`
|
|
333
|
+
|
|
334
|
+
const outputFilePath = path.join(gameConfig.outputDir, outputFileName)
|
|
335
|
+
writeFile(outputFilePath, rows.join("\n"))
|
|
336
|
+
|
|
337
|
+
return outputFilePath
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private static writeRecords(opts: {
|
|
341
|
+
gameMode: string
|
|
342
|
+
records: RecordItem[]
|
|
343
|
+
gameConfig: GameConfig["config"]
|
|
344
|
+
fileNameWithoutExtension?: string
|
|
345
|
+
debug?: boolean
|
|
346
|
+
}) {
|
|
347
|
+
const { gameMode, fileNameWithoutExtension, records, gameConfig, debug } = opts
|
|
348
|
+
|
|
349
|
+
const outputFileName = fileNameWithoutExtension
|
|
350
|
+
? `${fileNameWithoutExtension}.json`
|
|
351
|
+
: `force_record_${gameMode}.json`
|
|
352
|
+
|
|
353
|
+
const outputFilePath = path.join(gameConfig.outputDir, outputFileName)
|
|
354
|
+
writeFile(outputFilePath, JSON.stringify(records, null, 2))
|
|
355
|
+
|
|
356
|
+
if (debug) Simulation.logSymbolOccurrences(records)
|
|
357
|
+
|
|
358
|
+
return outputFilePath
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private static writeIndexJson(opts: { gameConfig: GameConfig["config"] }) {
|
|
362
|
+
const { gameConfig } = opts
|
|
363
|
+
|
|
364
|
+
const outputFilePath = path.join(
|
|
365
|
+
process.cwd(),
|
|
366
|
+
gameConfig.outputDir,
|
|
367
|
+
"publish_files",
|
|
368
|
+
"index.json",
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
const modes = Object.entries(gameConfig.gameModes).map(([mode, modeConfig]) => ({
|
|
372
|
+
name: mode,
|
|
373
|
+
cost: modeConfig.cost,
|
|
374
|
+
events: `books_${mode}.jsonl.zst`,
|
|
375
|
+
weights: `lookUpTable_${mode}_0.csv`,
|
|
376
|
+
}))
|
|
377
|
+
|
|
378
|
+
writeFile(outputFilePath, JSON.stringify({ modes }, null, 2))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private static async writeBooksJson(opts: {
|
|
382
|
+
gameMode: string
|
|
383
|
+
library: Map<string, Book>
|
|
384
|
+
gameConfig: GameConfig["config"]
|
|
385
|
+
fileNameWithoutExtension?: string
|
|
386
|
+
}) {
|
|
387
|
+
const { gameMode, fileNameWithoutExtension, library, gameConfig } = opts
|
|
388
|
+
|
|
389
|
+
const outputFileName = fileNameWithoutExtension
|
|
390
|
+
? `${fileNameWithoutExtension}.jsonl`
|
|
391
|
+
: `books_${gameMode}.jsonl`
|
|
392
|
+
|
|
393
|
+
const outputFilePath = path.join(gameConfig.outputDir, outputFileName)
|
|
394
|
+
const books = Array.from(library.values())
|
|
395
|
+
.map((b) => b.serialize())
|
|
396
|
+
.map((b) => ({
|
|
397
|
+
id: b.id,
|
|
398
|
+
payoutMultiplier: b.payout,
|
|
399
|
+
events: b.events,
|
|
400
|
+
}))
|
|
401
|
+
.sort((a, b) => a.id - b.id)
|
|
402
|
+
|
|
403
|
+
const contents = JSONL.stringify(books)
|
|
404
|
+
|
|
405
|
+
writeFile(outputFilePath, contents)
|
|
406
|
+
|
|
407
|
+
const compressedFileName = fileNameWithoutExtension
|
|
408
|
+
? `${fileNameWithoutExtension}.jsonl.zst`
|
|
409
|
+
: `books_${gameMode}.jsonl.zst`
|
|
410
|
+
|
|
411
|
+
const compressedFilePath = path.join(
|
|
412
|
+
process.cwd(),
|
|
413
|
+
gameConfig.outputDir,
|
|
414
|
+
"publish_files",
|
|
415
|
+
compressedFileName,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
fs.rmSync(compressedFilePath, { force: true })
|
|
419
|
+
|
|
420
|
+
await zstd("-f", outputFilePath, "-o", compressedFilePath)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private static logSymbolOccurrences(records: RecordItem[]) {
|
|
424
|
+
const validRecords = records.filter(
|
|
425
|
+
(r) =>
|
|
426
|
+
r.search.some((s) => s.name === "symbolId") &&
|
|
427
|
+
r.search.some((s) => s.name === "kind"),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
const structuredRecords = validRecords
|
|
431
|
+
.map((r) => {
|
|
432
|
+
const symbolEntry = r.search.find((s) => s.name === "symbolId")
|
|
433
|
+
const kindEntry = r.search.find((s) => s.name === "kind")
|
|
434
|
+
const spinTypeEntry = r.search.find((s) => s.name === "spinType")
|
|
435
|
+
return {
|
|
436
|
+
symbol: symbolEntry ? symbolEntry.value : "unknown",
|
|
437
|
+
kind: kindEntry ? kindEntry.value : "unknown",
|
|
438
|
+
spinType: spinTypeEntry ? spinTypeEntry.value : "unknown",
|
|
439
|
+
timesTriggered: r.timesTriggered,
|
|
440
|
+
}
|
|
441
|
+
})
|
|
442
|
+
.sort((a, b) => {
|
|
443
|
+
if (a.symbol < b.symbol) return -1
|
|
444
|
+
if (a.symbol > b.symbol) return 1
|
|
445
|
+
if (a.kind < b.kind) return -1
|
|
446
|
+
if (a.kind > b.kind) return 1
|
|
447
|
+
if (a.spinType < b.spinType) return -1
|
|
448
|
+
if (a.spinType > b.spinType) return 1
|
|
449
|
+
return 0
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
console.table(structuredRecords)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Compiles user configured game to JS for use in different Node processes
|
|
457
|
+
*/
|
|
458
|
+
private preprocessFiles() {
|
|
459
|
+
const builtFilePath = path.join(this.gameConfig.config.outputDir, TEMP_FILENAME)
|
|
460
|
+
fs.rmSync(builtFilePath, { force: true })
|
|
461
|
+
buildSync({
|
|
462
|
+
entryPoints: [process.cwd()],
|
|
463
|
+
bundle: true,
|
|
464
|
+
platform: "node",
|
|
465
|
+
outfile: path.join(this.gameConfig.config.outputDir, TEMP_FILENAME),
|
|
466
|
+
external: ["esbuild", "@mongodb-js/zstd"],
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private getSimRangesForChunks(total: number, chunks: number): [number, number][] {
|
|
471
|
+
const base = Math.floor(total / chunks)
|
|
472
|
+
const remainder = total % chunks
|
|
473
|
+
const result: [number, number][] = []
|
|
474
|
+
|
|
475
|
+
let current = 1
|
|
476
|
+
|
|
477
|
+
for (let i = 0; i < chunks; i++) {
|
|
478
|
+
const size = base + (i < remainder ? 1 : 0)
|
|
479
|
+
const start = current
|
|
480
|
+
const end = current + size - 1
|
|
481
|
+
result.push([start, end])
|
|
482
|
+
current = end + 1
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return result
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private mergeRecords(otherRecords: RecordItem[]) {
|
|
489
|
+
for (const otherRecord of otherRecords) {
|
|
490
|
+
let record = this.records.find((r) => {
|
|
491
|
+
if (r.search.length !== otherRecord.search.length) return false
|
|
492
|
+
for (let i = 0; i < r.search.length; i++) {
|
|
493
|
+
if (r.search[i]!.name !== otherRecord.search[i]!.name) return false
|
|
494
|
+
if (r.search[i]!.value !== otherRecord.search[i]!.value) return false
|
|
495
|
+
}
|
|
496
|
+
return true
|
|
497
|
+
})
|
|
498
|
+
if (!record) {
|
|
499
|
+
record = {
|
|
500
|
+
search: otherRecord.search,
|
|
501
|
+
timesTriggered: 0,
|
|
502
|
+
bookIds: [],
|
|
503
|
+
}
|
|
504
|
+
this.records.push(record)
|
|
505
|
+
}
|
|
506
|
+
record.timesTriggered += otherRecord.timesTriggered
|
|
507
|
+
for (const bookId of otherRecord.bookIds) {
|
|
508
|
+
if (!record.bookIds.includes(bookId)) {
|
|
509
|
+
record.bookIds.push(bookId)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export type SimulationConfigOpts = {
|
|
517
|
+
/**
|
|
518
|
+
* Object containing the game modes and their respective simulation runs amount.
|
|
519
|
+
*/
|
|
520
|
+
simRunsAmount: Partial<Record<GameModeName, number>>
|
|
521
|
+
/**
|
|
522
|
+
* Number of concurrent processes to use for simulations.
|
|
523
|
+
*
|
|
524
|
+
* Default: 6
|
|
525
|
+
*/
|
|
526
|
+
concurrency?: number
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* @internal
|
|
531
|
+
*/
|
|
532
|
+
export type AnySimulationContext<
|
|
533
|
+
TGameModes extends AnyGameModes = AnyGameModes,
|
|
534
|
+
TSymbols extends AnySymbols = AnySymbols,
|
|
535
|
+
TUserState extends AnyUserData = AnyUserData,
|
|
536
|
+
> = SimulationContext<TGameModes, TSymbols, TUserState>
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* @internal
|
|
540
|
+
*/
|
|
541
|
+
export class SimulationContext<
|
|
542
|
+
TGameModes extends AnyGameModes,
|
|
543
|
+
TSymbols extends AnySymbols,
|
|
544
|
+
TUserState extends AnyUserData,
|
|
545
|
+
> extends Board<TGameModes, TSymbols, TUserState> {
|
|
546
|
+
constructor(opts: CommonGameOptions<any, any, TUserState>) {
|
|
547
|
+
super(opts)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
actualSims = 0
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Will run a single simulation until the specified criteria is met.
|
|
554
|
+
*/
|
|
555
|
+
runSingleSimulation(opts: {
|
|
556
|
+
simId: number
|
|
557
|
+
mode: string
|
|
558
|
+
criteria: string
|
|
559
|
+
index: number
|
|
560
|
+
}) {
|
|
561
|
+
const { simId, mode, criteria, index } = opts
|
|
562
|
+
|
|
563
|
+
this.state.currentGameMode = mode
|
|
564
|
+
this.state.currentSimulationId = simId
|
|
565
|
+
this.state.isCriteriaMet = false
|
|
566
|
+
|
|
567
|
+
while (!this.state.isCriteriaMet) {
|
|
568
|
+
this.actualSims++
|
|
569
|
+
this.resetSimulation()
|
|
570
|
+
|
|
571
|
+
const resultSet = this.getGameModeCriteria(this.state.currentGameMode, criteria)
|
|
572
|
+
this.state.currentResultSet = resultSet
|
|
573
|
+
this.state.book.criteria = resultSet.criteria
|
|
574
|
+
|
|
575
|
+
this.handleGameFlow()
|
|
576
|
+
|
|
577
|
+
if (resultSet.meetsCriteria(this)) {
|
|
578
|
+
this.state.isCriteriaMet = true
|
|
579
|
+
this.config.hooks.onSimulationAccepted?.(this)
|
|
580
|
+
this.record({
|
|
581
|
+
criteria: resultSet.criteria,
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
this.wallet.confirmWins(this)
|
|
587
|
+
this.confirmRecords()
|
|
588
|
+
|
|
589
|
+
parentPort?.postMessage({
|
|
590
|
+
type: "complete",
|
|
591
|
+
simId,
|
|
592
|
+
book: this.state.book.serialize(),
|
|
593
|
+
wallet: this.wallet.serialize(),
|
|
594
|
+
records: this.getRecords(),
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* If a simulation does not meet the required criteria, reset the state to run it again.
|
|
600
|
+
*
|
|
601
|
+
* This also runs once before each simulation to ensure a clean state.
|
|
602
|
+
*/
|
|
603
|
+
protected resetSimulation() {
|
|
604
|
+
this.resetState()
|
|
605
|
+
this.resetBoard()
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Contains and executes the entire game logic:
|
|
610
|
+
* - Drawing the board
|
|
611
|
+
* - Evaluating wins
|
|
612
|
+
* - Updating wallet
|
|
613
|
+
* - Handling free spins
|
|
614
|
+
* - Recording events
|
|
615
|
+
*
|
|
616
|
+
* You can customize the game flow by implementing the `onHandleGameFlow` hook in the game configuration.
|
|
617
|
+
*/
|
|
618
|
+
protected handleGameFlow() {
|
|
619
|
+
this.config.hooks.onHandleGameFlow(this)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export interface SimulationOpts {
|
|
624
|
+
debug?: boolean
|
|
625
|
+
}
|