@slot-engine/core 0.0.4 → 0.0.6

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/dist/index.mjs CHANGED
@@ -1,235 +1,82 @@
1
- // src/GameConfig.ts
1
+ // src/constants.ts
2
+ var SPIN_TYPE = {
3
+ BASE_GAME: "basegame",
4
+ FREE_SPINS: "freespins"
5
+ };
6
+
7
+ // src/game-config/index.ts
2
8
  import assert from "assert";
3
- var GameConfig = class _GameConfig {
4
- config;
5
- constructor(opts) {
6
- this.config = {
7
- id: opts.id,
8
- name: opts.name,
9
- gameModes: opts.gameModes,
10
- symbols: /* @__PURE__ */ new Map(),
11
- padSymbols: opts.padSymbols || 0,
12
- scatterToFreespins: opts.scatterToFreespins,
13
- anticipationTriggers: {
14
- [_GameConfig.SPIN_TYPE.BASE_GAME]: getAnticipationTrigger(
15
- _GameConfig.SPIN_TYPE.BASE_GAME
16
- ),
17
- [_GameConfig.SPIN_TYPE.FREE_SPINS]: getAnticipationTrigger(
18
- _GameConfig.SPIN_TYPE.FREE_SPINS
19
- )
20
- },
21
- maxWinX: opts.maxWinX,
22
- hooks: opts.hooks,
23
- userState: opts.userState,
24
- outputDir: "__build__"
25
- };
26
- for (const [key, value] of Object.entries(opts.symbols)) {
27
- assert(
28
- value.id === key,
29
- `Symbol key "${key}" does not match symbol id "${value.id}"`
30
- );
31
- this.config.symbols.set(key, value);
32
- }
33
- function getAnticipationTrigger(spinType) {
34
- return Math.min(...Object.keys(opts.scatterToFreespins[spinType]).map(Number)) - 1;
35
- }
36
- }
9
+ function createGameConfig(opts) {
10
+ const symbols = /* @__PURE__ */ new Map();
11
+ for (const [key, value] of Object.entries(opts.symbols)) {
12
+ assert(value.id === key, `Symbol key "${key}" does not match symbol id "${value.id}"`);
13
+ symbols.set(key, value);
14
+ }
15
+ const getAnticipationTrigger = (spinType) => {
16
+ return Math.min(...Object.keys(opts.scatterToFreespins[spinType]).map(Number)) - 1;
17
+ };
18
+ return {
19
+ padSymbols: opts.padSymbols || 1,
20
+ userState: opts.userState || {},
21
+ ...opts,
22
+ symbols,
23
+ anticipationTriggers: {
24
+ [SPIN_TYPE.BASE_GAME]: getAnticipationTrigger(SPIN_TYPE.BASE_GAME),
25
+ [SPIN_TYPE.FREE_SPINS]: getAnticipationTrigger(SPIN_TYPE.FREE_SPINS)
26
+ },
27
+ outputDir: "__build__"
28
+ };
29
+ }
30
+
31
+ // src/simulation/index.ts
32
+ import fs2 from "fs";
33
+ import path from "path";
34
+ import assert6 from "assert";
35
+ import zlib from "zlib";
36
+ import { buildSync } from "esbuild";
37
+ import { Worker, isMainThread, parentPort, workerData } from "worker_threads";
38
+
39
+ // src/result-set/index.ts
40
+ import assert2 from "assert";
41
+
42
+ // src/service/index.ts
43
+ var AbstractService = class {
37
44
  /**
38
- * Generates reelset CSV files for all game modes.
45
+ * Function that returns the current game context.
39
46
  */
40
- generateReelsetFiles() {
41
- for (const mode of Object.values(this.config.gameModes)) {
42
- if (mode.reelSets && mode.reelSets.length > 0) {
43
- for (const reelGenerator of Object.values(mode.reelSets)) {
44
- reelGenerator.associatedGameModeName = mode.name;
45
- reelGenerator.outputDir = this.config.outputDir;
46
- reelGenerator.generateReels(this);
47
- }
48
- } else {
49
- throw new Error(
50
- `Game mode "${mode.name}" has no reel sets defined. Cannot generate reelset files.`
51
- );
52
- }
53
- }
47
+ ctx;
48
+ constructor(ctx) {
49
+ this.ctx = ctx;
50
+ }
51
+ };
52
+
53
+ // src/service/rng.ts
54
+ var RngService = class extends AbstractService {
55
+ rng = new RandomNumberGenerator();
56
+ constructor(ctx) {
57
+ super(ctx);
54
58
  }
55
59
  /**
56
- * Retrieves a reel set by its ID within a specific game mode.
60
+ * Random weighted selection from a set of items.
57
61
  */
58
- getReelsetById(gameMode, id) {
59
- const reelSet = this.config.gameModes[gameMode].reelSets.find((rs) => rs.id === id);
60
- if (!reelSet) {
61
- throw new Error(
62
- `Reel set with id "${id}" not found in game mode "${gameMode}". Available reel sets: ${this.config.gameModes[gameMode].reelSets.map((rs) => rs.id).join(", ")}`
63
- );
64
- }
65
- return reelSet.reels;
66
- }
62
+ weightedRandom = this.rng.weightedRandom.bind(this.rng);
67
63
  /**
68
- * Retrieves the number of free spins awarded for a given spin type and scatter count.
64
+ * Selects a random item from an array.
69
65
  */
70
- getFreeSpinsForScatters(spinType, scatterCount) {
71
- const freespinsConfig = this.config.scatterToFreespins[spinType];
72
- if (!freespinsConfig) {
73
- throw new Error(
74
- `No free spins configuration found for spin type "${spinType}". Please check your game configuration.`
75
- );
76
- }
77
- return freespinsConfig[scatterCount] || 0;
78
- }
66
+ randomItem = this.rng.randomItem.bind(this.rng);
79
67
  /**
80
- * Retrieves a result set by its criteria within a specific game mode.
68
+ * Shuffles an array.
81
69
  */
82
- getResultSetByCriteria(mode, criteria) {
83
- const gameMode = this.config.gameModes[mode];
84
- if (!gameMode) {
85
- throw new Error(`Game mode "${mode}" not found in game config.`);
86
- }
87
- const resultSet = gameMode.resultSets.find((rs) => rs.criteria === criteria);
88
- if (!resultSet) {
89
- throw new Error(
90
- `Criteria "${criteria}" not found in game mode "${mode}". Available criteria: ${gameMode.resultSets.map((rs) => rs.criteria).join(", ")}`
91
- );
92
- }
93
- return resultSet;
94
- }
70
+ shuffle = this.rng.shuffle.bind(this.rng);
95
71
  /**
96
- * Returns all configured symbols as an array.
72
+ * Generates a random float between two values.
97
73
  */
98
- getSymbolArray() {
99
- return Array.from(this.config.symbols).map(([n, v]) => v);
100
- }
101
- static SPIN_TYPE = {
102
- BASE_GAME: "basegame",
103
- FREE_SPINS: "freespins"
104
- };
105
- };
106
-
107
- // src/GameMode.ts
108
- var GameMode = class {
109
- name;
110
- reelsAmount;
111
- symbolsPerReel;
112
- cost;
113
- rtp;
114
- reelSets;
115
- resultSets;
116
- isBonusBuy;
117
- constructor(opts) {
118
- this.name = opts.name;
119
- this.reelsAmount = opts.reelsAmount;
120
- this.symbolsPerReel = opts.symbolsPerReel;
121
- this.cost = opts.cost;
122
- this.rtp = opts.rtp;
123
- this.reelSets = opts.reelSets;
124
- this.resultSets = opts.resultSets;
125
- this.isBonusBuy = opts.isBonusBuy;
126
- if (this.symbolsPerReel.length !== this.reelsAmount) {
127
- throw new Error(
128
- `symbolsPerReel length (${this.symbolsPerReel.length}) must match reelsAmount (${this.reelsAmount}).`
129
- );
130
- }
131
- if (this.resultSets.length == 0) {
132
- throw new Error("GameMode must have at least one ResultSet defined.");
133
- }
134
- }
135
- };
136
-
137
- // src/GameSymbol.ts
138
- var GameSymbol = class _GameSymbol {
139
- id;
140
- pays;
141
- properties;
142
- constructor(opts) {
143
- this.id = opts.id;
144
- this.pays = opts.pays;
145
- this.properties = new Map(Object.entries(opts.properties || {}));
146
- if (this.pays && Object.keys(this.pays).length === 0) {
147
- throw new Error(`GameSymbol "${this.id}" must have pays defined.`);
148
- }
149
- }
74
+ randomFloat = this.rng.randomFloat.bind(this.rng);
150
75
  /**
151
- * Compares this symbol to another symbol or a set of properties.
76
+ * Sets the seed for the RNG.
152
77
  */
153
- compare(symbolOrProperties) {
154
- if (!symbolOrProperties) {
155
- console.warn("No symbol or properties provided for comparison.");
156
- return false;
157
- }
158
- if (symbolOrProperties instanceof _GameSymbol) {
159
- return this.id === symbolOrProperties.id;
160
- } else {
161
- for (const [key, value] of Object.entries(symbolOrProperties)) {
162
- if (!this.properties.has(key) || this.properties.get(key) !== value) {
163
- return false;
164
- }
165
- }
166
- return true;
167
- }
168
- }
78
+ setSeedIfDifferent = this.rng.setSeedIfDifferent.bind(this.rng);
169
79
  };
170
-
171
- // src/ReelGenerator.ts
172
- import fs2 from "fs";
173
- import path from "path";
174
-
175
- // utils.ts
176
- import fs from "fs";
177
- function weightedRandom(weights, rng) {
178
- const totalWeight = Object.values(weights).reduce(
179
- (sum, weight) => sum + weight,
180
- 0
181
- );
182
- const randomValue = rng.randomFloat(0, 1) * totalWeight;
183
- let cumulativeWeight = 0;
184
- for (const [key, weight] of Object.entries(weights)) {
185
- cumulativeWeight += weight;
186
- if (randomValue < cumulativeWeight) {
187
- return key;
188
- }
189
- }
190
- throw new Error("No item selected in weighted random selection.");
191
- }
192
- function randomItem(array, rng) {
193
- if (array.length === 0) {
194
- throw new Error("Cannot select a random item from an empty array.");
195
- }
196
- const randomIndex = Math.floor(rng.randomFloat(0, 1) * array.length);
197
- return array[randomIndex];
198
- }
199
- function createDirIfNotExists(dirPath) {
200
- if (!fs.existsSync(dirPath)) {
201
- fs.mkdirSync(dirPath, { recursive: true });
202
- }
203
- }
204
- function shuffle(array, rng) {
205
- const newArray = [...array];
206
- let currentIndex = newArray.length, randomIndex;
207
- while (currentIndex != 0) {
208
- randomIndex = Math.floor(rng.randomFloat(0, 1) * currentIndex);
209
- currentIndex--;
210
- [newArray[currentIndex], newArray[randomIndex]] = [
211
- newArray[randomIndex],
212
- newArray[currentIndex]
213
- ];
214
- }
215
- return newArray;
216
- }
217
- function writeJsonFile(filePath, data) {
218
- try {
219
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2), {
220
- encoding: "utf8"
221
- });
222
- } catch (error) {
223
- throw new Error(`Failed to write JSON file at ${filePath}: ${error}`);
224
- }
225
- }
226
- function writeFile(filePath, data) {
227
- try {
228
- fs.writeFileSync(filePath, data, { encoding: "utf8" });
229
- } catch (error) {
230
- throw new Error(`Failed to write file at ${filePath}: ${error}`);
231
- }
232
- }
233
80
  var RandomNumberGenerator = class {
234
81
  mIdum;
235
82
  mIy;
@@ -314,7 +161,66 @@ var RandomNumberGenerator = class {
314
161
  }
315
162
  return float * (high - low) + low;
316
163
  }
164
+ weightedRandom(weights) {
165
+ const totalWeight = Object.values(weights).reduce(
166
+ (sum, weight) => sum + weight,
167
+ 0
168
+ );
169
+ const randomValue = this.randomFloat(0, 1) * totalWeight;
170
+ let cumulativeWeight = 0;
171
+ for (const [key, weight] of Object.entries(weights)) {
172
+ cumulativeWeight += weight;
173
+ if (randomValue < cumulativeWeight) {
174
+ return key;
175
+ }
176
+ }
177
+ throw new Error("No item selected in weighted random selection.");
178
+ }
179
+ randomItem(array) {
180
+ if (array.length === 0) {
181
+ throw new Error("Cannot select a random item from an empty array.");
182
+ }
183
+ const randomIndex = Math.floor(this.randomFloat(0, 1) * array.length);
184
+ return array[randomIndex];
185
+ }
186
+ shuffle(array) {
187
+ const newArray = [...array];
188
+ let currentIndex = newArray.length, randomIndex;
189
+ while (currentIndex != 0) {
190
+ randomIndex = Math.floor(this.randomFloat(0, 1) * currentIndex);
191
+ currentIndex--;
192
+ [newArray[currentIndex], newArray[randomIndex]] = [
193
+ newArray[randomIndex],
194
+ newArray[currentIndex]
195
+ ];
196
+ }
197
+ return newArray;
198
+ }
317
199
  };
200
+
201
+ // utils.ts
202
+ import fs from "fs";
203
+ function createDirIfNotExists(dirPath) {
204
+ if (!fs.existsSync(dirPath)) {
205
+ fs.mkdirSync(dirPath, { recursive: true });
206
+ }
207
+ }
208
+ function writeJsonFile(filePath, data) {
209
+ try {
210
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), {
211
+ encoding: "utf8"
212
+ });
213
+ } catch (error) {
214
+ throw new Error(`Failed to write JSON file at ${filePath}: ${error}`);
215
+ }
216
+ }
217
+ function writeFile(filePath, data) {
218
+ try {
219
+ fs.writeFileSync(filePath, data, { encoding: "utf8" });
220
+ } catch (error) {
221
+ throw new Error(`Failed to write file at ${filePath}: ${error}`);
222
+ }
223
+ }
318
224
  function copy(obj) {
319
225
  return JSON.parse(JSON.stringify(obj));
320
226
  }
@@ -327,769 +233,606 @@ var JSONL = class {
327
233
  }
328
234
  };
329
235
 
330
- // src/ReelGenerator.ts
331
- import { isMainThread } from "worker_threads";
332
- var ReelGenerator = class {
333
- id;
334
- associatedGameModeName = "";
335
- symbolWeights = /* @__PURE__ */ new Map();
336
- rowsAmount;
337
- reels = [];
338
- limitSymbolsToReels;
339
- spaceBetweenSameSymbols;
340
- spaceBetweenSymbols;
341
- preferStackedSymbols;
342
- symbolStacks;
343
- symbolQuotas;
344
- outputDir = "";
345
- csvPath = "";
346
- overrideExisting;
347
- rng;
236
+ // src/result-set/index.ts
237
+ var ResultSet = class {
238
+ criteria;
239
+ quota;
240
+ multiplier;
241
+ reelWeights;
242
+ userData;
243
+ forceMaxWin;
244
+ forceFreespins;
245
+ evaluate;
348
246
  constructor(opts) {
349
- this.id = opts.id;
350
- this.symbolWeights = new Map(Object.entries(opts.symbolWeights));
351
- this.rowsAmount = opts.rowsAmount || 250;
352
- this.outputDir = opts.outputDir;
353
- if (opts.limitSymbolsToReels) this.limitSymbolsToReels = opts.limitSymbolsToReels;
354
- this.overrideExisting = opts.overrideExisting || false;
355
- this.spaceBetweenSameSymbols = opts.spaceBetweenSameSymbols;
356
- this.spaceBetweenSymbols = opts.spaceBetweenSymbols;
357
- this.preferStackedSymbols = opts.preferStackedSymbols;
358
- this.symbolStacks = opts.symbolStacks;
359
- this.symbolQuotas = opts.symbolQuotas;
360
- if (typeof this.spaceBetweenSameSymbols == "number" && (this.spaceBetweenSameSymbols < 1 || this.spaceBetweenSameSymbols > 8) || typeof this.spaceBetweenSameSymbols == "object" && Object.values(this.spaceBetweenSameSymbols).some((v) => v < 1 || v > 8)) {
361
- throw new Error(
362
- `spaceBetweenSameSymbols must be between 1 and 8, got ${this.spaceBetweenSameSymbols}.`
363
- );
364
- }
365
- if (Object.values(this.spaceBetweenSymbols || {}).some(
366
- (o) => Object.values(o).some((v) => v < 1 || v > 8)
367
- )) {
368
- throw new Error(
369
- `spaceBetweenSymbols must be between 1 and 8, got ${this.spaceBetweenSymbols}.`
370
- );
247
+ this.criteria = opts.criteria;
248
+ this.quota = opts.quota;
249
+ this.multiplier = opts.multiplier;
250
+ this.reelWeights = opts.reelWeights;
251
+ this.userData = opts.userData;
252
+ this.forceMaxWin = opts.forceMaxWin;
253
+ this.forceFreespins = opts.forceFreespins;
254
+ this.evaluate = opts.evaluate;
255
+ }
256
+ static assignCriteriaToSimulations(ctx, gameModeName) {
257
+ const rng = new RandomNumberGenerator();
258
+ rng.setSeed(0);
259
+ assert2(ctx.simRunsAmount, "Simulation configuration is not set.");
260
+ const simNums = ctx.simRunsAmount[gameModeName];
261
+ const resultSets = ctx.gameConfig.gameModes[gameModeName]?.resultSets;
262
+ if (!resultSets || resultSets.length === 0) {
263
+ throw new Error(`No ResultSets found for game mode: ${gameModeName}.`);
371
264
  }
372
- if (this.preferStackedSymbols && (this.preferStackedSymbols < 0 || this.preferStackedSymbols > 100)) {
373
- throw new Error(
374
- `preferStackedSymbols must be between 0 and 100, got ${this.preferStackedSymbols}.`
375
- );
265
+ if (simNums === void 0 || simNums <= 0) {
266
+ throw new Error(`No simulations configured for game mode "${gameModeName}".`);
376
267
  }
377
- this.rng = new RandomNumberGenerator();
378
- this.rng.setSeed(opts.seed ?? 0);
379
- }
380
- validateConfig({ config }) {
381
- config.symbols.forEach((symbol) => {
382
- if (!this.symbolWeights.has(symbol.id)) {
383
- throw new Error(
384
- [
385
- `Symbol "${symbol.id}" is not defined in the symbol weights of the reel generator ${this.id} for mode ${this.associatedGameModeName}.`,
386
- `Please ensure all symbols have weights defined.
387
- `
388
- ].join(" ")
389
- );
390
- }
391
- });
392
- for (const [symbolId, weight] of this.symbolWeights.entries()) {
393
- if (!config.symbols.has(symbolId)) {
394
- throw new Error(
395
- [
396
- `Symbol "${symbolId}" is defined in the reel generator's symbol weights, but does not exist in the game config.`,
397
- `Please ensure all symbols in the reel generator are defined in the game config.
398
- `
399
- ].join(" ")
400
- );
268
+ const totalQuota = resultSets.reduce((sum, rs) => sum + rs.quota, 0);
269
+ const numberOfSimsForCriteria = Object.fromEntries(
270
+ resultSets.map((rs) => {
271
+ const normalizedQuota = totalQuota > 0 ? rs.quota / totalQuota : 0;
272
+ return [rs.criteria, Math.max(Math.floor(normalizedQuota * simNums), 1)];
273
+ })
274
+ );
275
+ let totalSims = Object.values(numberOfSimsForCriteria).reduce(
276
+ (sum, num) => sum + num,
277
+ 0
278
+ );
279
+ let reduceSims = totalSims > simNums;
280
+ const criteriaToWeights = Object.fromEntries(
281
+ resultSets.map((rs) => [rs.criteria, rs.quota])
282
+ );
283
+ while (totalSims != simNums) {
284
+ const rs = rng.weightedRandom(criteriaToWeights);
285
+ if (reduceSims && numberOfSimsForCriteria[rs] > 1) {
286
+ numberOfSimsForCriteria[rs] -= 1;
287
+ } else if (!reduceSims) {
288
+ numberOfSimsForCriteria[rs] += 1;
401
289
  }
290
+ totalSims = Object.values(numberOfSimsForCriteria).reduce(
291
+ (sum, num) => sum + num,
292
+ 0
293
+ );
294
+ reduceSims = totalSims > simNums;
402
295
  }
403
- if (this.limitSymbolsToReels && Object.keys(this.limitSymbolsToReels).length == 0) {
404
- this.limitSymbolsToReels = void 0;
405
- }
406
- if (this.outputDir === "") {
407
- throw new Error("Output directory must be specified for the ReelGenerator.");
296
+ let allCriteria = [];
297
+ const simNumsToCriteria = {};
298
+ Object.entries(numberOfSimsForCriteria).forEach(([criteria, num]) => {
299
+ for (let i = 0; i <= num; i++) {
300
+ allCriteria.push(criteria);
301
+ }
302
+ });
303
+ allCriteria = rng.shuffle(allCriteria);
304
+ for (let i = 1; i <= Math.min(simNums, allCriteria.length); i++) {
305
+ simNumsToCriteria[i] = allCriteria[i];
408
306
  }
307
+ return simNumsToCriteria;
409
308
  }
410
- isSymbolAllowedOnReel(symbolId, reelIdx) {
411
- if (!this.limitSymbolsToReels) return true;
412
- const allowedReels = this.limitSymbolsToReels[symbolId];
413
- if (!allowedReels || allowedReels.length === 0) return true;
414
- return allowedReels.includes(reelIdx);
415
- }
416
- resolveStacking(symbolId, reelIdx) {
417
- const cfg = this.symbolStacks?.[symbolId];
418
- if (!cfg) return null;
419
- const STACKING_MIN = 1;
420
- const STACKING_MAX = 4;
421
- const chance = typeof cfg.chance === "number" ? cfg.chance : cfg.chance?.[reelIdx] ?? 0;
422
- if (chance <= 0) return null;
423
- let min = typeof cfg.min === "number" ? cfg.min : cfg.min?.[reelIdx] ?? STACKING_MIN;
424
- let max = typeof cfg.max === "number" ? cfg.max : cfg.max?.[reelIdx] ?? STACKING_MAX;
425
- return { chance, min, max };
309
+ /**
310
+ * Checks if core criteria is met, e.g. target multiplier or max win.
311
+ */
312
+ meetsCriteria(ctx) {
313
+ const customEval = this.evaluate?.(copy(ctx));
314
+ const freespinsMet = this.forceFreespins ? ctx.state.triggeredFreespins : true;
315
+ const wallet = ctx.services.wallet._getWallet();
316
+ const multiplierMet = this.multiplier !== void 0 ? wallet.getCurrentWin() === this.multiplier && !this.forceMaxWin : wallet.getCurrentWin() > 0 && (!this.forceMaxWin || true);
317
+ const maxWinMet = this.forceMaxWin ? wallet.getCurrentWin() >= ctx.config.maxWinX : true;
318
+ const coreCriteriaMet = freespinsMet && multiplierMet && maxWinMet;
319
+ const finalResult = customEval !== void 0 ? coreCriteriaMet && customEval === true : coreCriteriaMet;
320
+ if (this.forceMaxWin && maxWinMet) {
321
+ ctx.services.data.record({
322
+ maxwin: true
323
+ });
324
+ }
325
+ return finalResult;
426
326
  }
427
- tryPlaceStack(reel, gameConf, reelIdx, symbolId, startIndex, maxStack) {
428
- if (!this.isSymbolAllowedOnReel(symbolId, reelIdx)) return 0;
429
- let canPlace = 0;
430
- for (let j = 0; j < maxStack; j++) {
431
- const idx = (startIndex + j) % this.rowsAmount;
432
- if (reel[idx] !== null) break;
433
- canPlace++;
327
+ };
328
+
329
+ // src/game-state/index.ts
330
+ function createGameState(opts) {
331
+ return {
332
+ currentSimulationId: opts?.currentSimulationId || 0,
333
+ currentGameMode: opts?.currentGameMode || "N/A",
334
+ currentSpinType: opts?.currentSpinType || SPIN_TYPE.BASE_GAME,
335
+ currentResultSet: opts?.currentResultSet || new ResultSet({
336
+ criteria: "N/A",
337
+ quota: 0,
338
+ reelWeights: {
339
+ [SPIN_TYPE.BASE_GAME]: {},
340
+ [SPIN_TYPE.FREE_SPINS]: {}
341
+ }
342
+ }),
343
+ isCriteriaMet: opts?.isCriteriaMet || false,
344
+ currentFreespinAmount: opts?.currentFreespinAmount || 0,
345
+ totalFreespinAmount: opts?.totalFreespinAmount || 0,
346
+ userData: opts?.userData || {},
347
+ triggeredMaxWin: opts?.triggeredMaxWin || false,
348
+ triggeredFreespins: opts?.triggeredFreespins || false
349
+ };
350
+ }
351
+
352
+ // src/board/index.ts
353
+ import assert3 from "assert";
354
+
355
+ // src/game-symbol/index.ts
356
+ var GameSymbol = class _GameSymbol {
357
+ id;
358
+ pays;
359
+ properties;
360
+ constructor(opts) {
361
+ this.id = opts.id;
362
+ this.pays = opts.pays;
363
+ this.properties = new Map(Object.entries(opts.properties || {}));
364
+ if (this.pays && Object.keys(this.pays).length === 0) {
365
+ throw new Error(`GameSymbol "${this.id}" must have pays defined.`);
434
366
  }
435
- if (canPlace === 0) return 0;
436
- const symObj = gameConf.config.symbols.get(symbolId);
437
- if (!symObj) {
438
- throw new Error(
439
- `Symbol with id "${symbolId}" not found in the game config symbols map.`
440
- );
367
+ }
368
+ /**
369
+ * Compares this symbol to another symbol or a set of properties.
370
+ */
371
+ compare(symbolOrProperties) {
372
+ if (!symbolOrProperties) {
373
+ console.warn("No symbol or properties provided for comparison.");
374
+ return false;
441
375
  }
442
- for (let j = 0; j < canPlace; j++) {
443
- const idx = (startIndex + j) % reel.length;
444
- reel[idx] = symObj;
376
+ if (symbolOrProperties instanceof _GameSymbol) {
377
+ return this.id === symbolOrProperties.id;
378
+ } else {
379
+ for (const [key, value] of Object.entries(symbolOrProperties)) {
380
+ if (!this.properties.has(key) || this.properties.get(key) !== value) {
381
+ return false;
382
+ }
383
+ }
384
+ return true;
445
385
  }
446
- return canPlace;
447
386
  }
387
+ };
388
+
389
+ // src/board/index.ts
390
+ var Board = class {
448
391
  /**
449
- * Checks if a symbol can be placed at the target index without violating spacing rules.
392
+ * The current reels on the board.\
393
+ * Includes only the visible symbols (without padding).
450
394
  */
451
- violatesSpacing(reel, symbolId, targetIndex) {
452
- const circDist = (a, b) => {
453
- const diff = Math.abs(a - b);
454
- return Math.min(diff, this.rowsAmount - diff);
455
- };
456
- const spacingType = this.spaceBetweenSameSymbols ?? void 0;
457
- const sameSpacing = typeof spacingType === "number" ? spacingType : spacingType?.[symbolId] ?? 0;
458
- for (let i = 0; i <= reel.length; i++) {
459
- const placed = reel[i];
460
- if (!placed) continue;
461
- const dist = circDist(targetIndex, i);
462
- if (sameSpacing >= 1 && placed.id === symbolId) {
463
- if (dist <= sameSpacing) return true;
395
+ reels;
396
+ /**
397
+ * The top padding symbols on the board.\
398
+ * These are the symbols above the visible area.
399
+ */
400
+ paddingTop;
401
+ /**
402
+ * The bottom padding symbols on the board.\
403
+ * These are the symbols below the visible area.
404
+ */
405
+ paddingBottom;
406
+ /**
407
+ * The anticipation values for each reel on the board.\
408
+ * Used for triggering anticipation effects.
409
+ */
410
+ anticipation;
411
+ lastDrawnReelStops;
412
+ lastUsedReels;
413
+ constructor() {
414
+ this.reels = [];
415
+ this.paddingTop = [];
416
+ this.paddingBottom = [];
417
+ this.anticipation = [];
418
+ this.lastDrawnReelStops = [];
419
+ this.lastUsedReels = [];
420
+ }
421
+ makeEmptyReels(opts) {
422
+ const length = opts.reelsAmount ?? opts.ctx.services.game.getCurrentGameMode().reelsAmount;
423
+ assert3(length, "Cannot make empty reels without context or reelsAmount.");
424
+ return Array.from({ length }, () => []);
425
+ }
426
+ countSymbolsOnReel(symbolOrProperties, reelIndex) {
427
+ let total = 0;
428
+ for (const symbol of this.reels[reelIndex]) {
429
+ let matches = true;
430
+ if (symbolOrProperties instanceof GameSymbol) {
431
+ if (symbol.id !== symbolOrProperties.id) matches = false;
432
+ } else {
433
+ for (const [key, value] of Object.entries(symbolOrProperties)) {
434
+ if (!symbol.properties.has(key) || symbol.properties.get(key) !== value) {
435
+ matches = false;
436
+ break;
437
+ }
438
+ }
464
439
  }
465
- if (this.spaceBetweenSymbols) {
466
- const forward = this.spaceBetweenSymbols[symbolId]?.[placed.id] ?? 0;
467
- if (forward >= 1 && dist <= forward) return true;
468
- const reverse = this.spaceBetweenSymbols[placed.id]?.[symbolId] ?? 0;
469
- if (reverse >= 1 && dist <= reverse) return true;
440
+ if (matches) {
441
+ total++;
470
442
  }
471
443
  }
472
- return false;
444
+ return total;
473
445
  }
474
- generateReels(gameConf) {
475
- this.validateConfig(gameConf);
476
- const gameMode = gameConf.config.gameModes[this.associatedGameModeName];
477
- if (!gameMode) {
478
- throw new Error(
479
- `Error generating reels for game mode "${this.associatedGameModeName}". It's not defined in the game config.`
480
- );
481
- }
482
- const filePath = path.join(
483
- gameConf.config.outputDir,
484
- `reels_${this.associatedGameModeName}-${this.id}.csv`
485
- );
486
- this.csvPath = filePath;
487
- const exists = fs2.existsSync(filePath);
488
- const reelsAmount = gameMode.reelsAmount;
489
- const weightsObj = Object.fromEntries(this.symbolWeights);
490
- for (let ridx = 0; ridx < reelsAmount; ridx++) {
491
- const reel = new Array(this.rowsAmount).fill(null);
492
- const reelQuotas = {};
493
- const quotaCounts = {};
494
- let totalReelsQuota = 0;
495
- for (const [sym, quotaConf] of Object.entries(this.symbolQuotas || {})) {
496
- const q = typeof quotaConf === "number" ? quotaConf : quotaConf[ridx];
497
- if (!q) continue;
498
- reelQuotas[sym] = q;
499
- totalReelsQuota += q;
500
- }
501
- if (totalReelsQuota > 100) {
502
- throw new Error(
503
- `Total symbol quotas for reel ${ridx} exceed 100%. Adjust your configuration on ReelGenerator "${this.id}".`
504
- );
505
- }
506
- if (totalReelsQuota > 0) {
507
- for (const [sym, quota] of Object.entries(reelQuotas)) {
508
- const quotaCount = Math.max(1, Math.floor(this.rowsAmount * quota / 100));
509
- quotaCounts[sym] = quotaCount;
510
- }
511
- }
512
- for (const [sym, targetCount] of Object.entries(quotaCounts)) {
513
- let remaining = targetCount;
514
- let attempts = 0;
515
- while (remaining > 0) {
516
- if (attempts++ > this.rowsAmount * 10) {
517
- throw new Error(
518
- `Failed to place ${targetCount} of symbol ${sym} on reel ${ridx} (likely spacing/stacking too strict).`
519
- );
520
- }
521
- const pos = Math.round(this.rng.randomFloat(0, this.rowsAmount - 1));
522
- const stackCfg = this.resolveStacking(sym, ridx);
523
- let placed = 0;
524
- if (stackCfg && Math.round(this.rng.randomFloat(1, 100)) <= stackCfg.chance) {
525
- const stackSize = Math.max(
526
- 0,
527
- Math.round(this.rng.randomFloat(stackCfg.min, stackCfg.max))
528
- );
529
- const toPlace = Math.min(stackSize, remaining);
530
- placed = this.tryPlaceStack(reel, gameConf, ridx, sym, pos, toPlace);
446
+ countSymbolsOnBoard(symbolOrProperties) {
447
+ let total = 0;
448
+ const onReel = {};
449
+ for (const [ridx, reel] of this.reels.entries()) {
450
+ for (const symbol of reel) {
451
+ let matches = true;
452
+ if (symbolOrProperties instanceof GameSymbol) {
453
+ if (symbol.id !== symbolOrProperties.id) matches = false;
454
+ } else {
455
+ for (const [key, value] of Object.entries(symbolOrProperties)) {
456
+ if (!symbol.properties.has(key) || symbol.properties.get(key) !== value) {
457
+ matches = false;
458
+ break;
459
+ }
531
460
  }
532
- if (placed === 0 && reel[pos] === null && this.isSymbolAllowedOnReel(sym, ridx) && !this.violatesSpacing(reel, sym, pos)) {
533
- reel[pos] = gameConf.config.symbols.get(sym);
534
- placed = 1;
461
+ }
462
+ if (matches) {
463
+ total++;
464
+ if (onReel[ridx] === void 0) {
465
+ onReel[ridx] = 1;
466
+ } else {
467
+ onReel[ridx]++;
535
468
  }
536
- remaining -= placed;
537
469
  }
538
470
  }
539
- for (let r = 0; r < this.rowsAmount; r++) {
540
- if (reel[r] !== null) continue;
541
- let chosenSymbolId = weightedRandom(weightsObj, this.rng);
542
- const nextHasStackCfg = !!this.resolveStacking(chosenSymbolId, ridx);
543
- if (!nextHasStackCfg && this.preferStackedSymbols && reel.length > 0) {
544
- const prevSymbol = r - 1 >= 0 ? reel[r - 1] : reel[reel.length - 1];
545
- if (prevSymbol && Math.round(this.rng.randomFloat(1, 100)) <= this.preferStackedSymbols && (!this.spaceBetweenSameSymbols || !this.violatesSpacing(reel, prevSymbol.id, r))) {
546
- chosenSymbolId = prevSymbol.id;
547
- }
548
- }
549
- if (this.preferStackedSymbols && reel.length > 0) {
550
- const prevSymbol = r - 1 >= 0 ? reel[r - 1] : reel[reel.length - 1];
551
- if (Math.round(this.rng.randomFloat(1, 100)) <= this.preferStackedSymbols && (!this.spaceBetweenSameSymbols || !this.violatesSpacing(reel, prevSymbol.id, r))) {
552
- chosenSymbolId = prevSymbol.id;
553
- }
554
- }
555
- const stackCfg = this.resolveStacking(chosenSymbolId, ridx);
556
- if (stackCfg && this.isSymbolAllowedOnReel(chosenSymbolId, ridx)) {
557
- const roll = Math.round(this.rng.randomFloat(1, 100));
558
- if (roll <= stackCfg.chance) {
559
- const desiredSize = Math.max(
560
- 1,
561
- Math.round(this.rng.randomFloat(stackCfg.min, stackCfg.max))
562
- );
563
- const placed = this.tryPlaceStack(
564
- reel,
565
- gameConf,
566
- ridx,
567
- chosenSymbolId,
568
- r,
569
- desiredSize
570
- );
571
- if (placed > 0) {
572
- r += placed - 1;
573
- continue;
574
- }
575
- }
576
- }
577
- let tries = 0;
578
- const maxTries = 2500;
579
- while (!this.isSymbolAllowedOnReel(chosenSymbolId, ridx) || this.violatesSpacing(reel, chosenSymbolId, r)) {
580
- if (++tries > maxTries) {
581
- throw new Error(
582
- [
583
- `Failed to place a symbol on reel ${ridx} at position ${r} after ${maxTries} attempts.
584
- `,
585
- "Try to change the seed or adjust your configuration.\n"
586
- ].join(" ")
587
- );
588
- }
589
- chosenSymbolId = weightedRandom(weightsObj, this.rng);
590
- const hasStackCfg = !!this.resolveStacking(chosenSymbolId, ridx);
591
- if (!hasStackCfg && this.preferStackedSymbols && reel.length > 0) {
592
- const prevSymbol = r - 1 >= 0 ? reel[r - 1] : reel[reel.length - 1];
593
- if (prevSymbol && Math.round(this.rng.randomFloat(1, 100)) <= this.preferStackedSymbols && (!this.spaceBetweenSameSymbols || !this.violatesSpacing(reel, prevSymbol.id, r))) {
594
- chosenSymbolId = prevSymbol.id;
595
- }
596
- }
471
+ }
472
+ return [total, onReel];
473
+ }
474
+ isSymbolOnAnyReelMultipleTimes(symbol) {
475
+ for (const reel of this.reels) {
476
+ let count = 0;
477
+ for (const sym of reel) {
478
+ if (sym.id === symbol.id) {
479
+ count++;
597
480
  }
598
- const symbol = gameConf.config.symbols.get(chosenSymbolId);
599
- if (!symbol) {
600
- throw new Error(
601
- `Symbol with id "${chosenSymbolId}" not found in the game config symbols map.`
602
- );
481
+ if (count > 1) {
482
+ return true;
603
483
  }
604
- reel[r] = symbol;
605
484
  }
606
- if (reel.some((s) => s === null)) {
607
- throw new Error(`Reel ${ridx} has unfilled positions after generation.`);
485
+ }
486
+ return false;
487
+ }
488
+ getReelStopsForSymbol(reels, symbol) {
489
+ const reelStops = [];
490
+ for (let ridx = 0; ridx < reels.length; ridx++) {
491
+ const reel = reels[ridx];
492
+ const positions = [];
493
+ for (let pos = 0; pos < reel.length; pos++) {
494
+ if (reel[pos].id === symbol.id) {
495
+ positions.push(pos);
496
+ }
608
497
  }
609
- this.reels.push(reel);
498
+ reelStops.push(positions);
610
499
  }
611
- const csvRows = Array.from(
612
- { length: this.rowsAmount },
613
- () => Array.from({ length: reelsAmount }, () => "")
614
- );
500
+ return reelStops;
501
+ }
502
+ combineReelStops(opts) {
503
+ const reelsAmount = opts.reelsAmount ?? opts.ctx.services.game.getCurrentGameMode().reelsAmount;
504
+ assert3(reelsAmount, "Cannot combine reel stops without context or reelsAmount.");
505
+ const combined = [];
615
506
  for (let ridx = 0; ridx < reelsAmount; ridx++) {
616
- for (let r = 0; r < this.rowsAmount; r++) {
617
- csvRows[r][ridx] = this.reels[ridx][r].id;
507
+ combined[ridx] = [];
508
+ for (const stops of opts.reelStops) {
509
+ combined[ridx] = combined[ridx].concat(stops[ridx]);
618
510
  }
619
511
  }
620
- const csvString = csvRows.map((row) => row.join(",")).join("\n");
621
- if (isMainThread) {
622
- createDirIfNotExists(this.outputDir);
623
- fs2.writeFileSync(filePath, csvString);
624
- this.reels = this.parseReelsetCSV(filePath, gameConf);
625
- console.log(
626
- `Generated reelset ${this.id} for game mode ${this.associatedGameModeName}`
512
+ return combined;
513
+ }
514
+ getRandomReelStops(opts) {
515
+ const reelsAmount = opts.reelsAmount ?? opts.ctx.services.game.getCurrentGameMode().reelsAmount;
516
+ assert3(reelsAmount, "Cannot get random reel stops without context or reelsAmount.");
517
+ const symProbsOnReels = [];
518
+ const stopPositionsForReels = {};
519
+ for (let ridx = 0; ridx < reelsAmount; ridx++) {
520
+ symProbsOnReels.push(opts.reelStops[ridx].length / opts.reels[ridx].length);
521
+ }
522
+ while (Object.keys(stopPositionsForReels).length !== opts.amount) {
523
+ const possibleReels = [];
524
+ for (let i = 0; i < reelsAmount; i++) {
525
+ if (symProbsOnReels[i] > 0) {
526
+ possibleReels.push(i);
527
+ }
528
+ }
529
+ const possibleProbs = symProbsOnReels.filter((p) => p > 0);
530
+ const weights = Object.fromEntries(
531
+ possibleReels.map((ridx, idx) => [ridx, possibleProbs[idx]])
627
532
  );
533
+ const chosenReel = opts.ctx.services.rng.weightedRandom(weights);
534
+ const chosenStop = opts.ctx.services.rng.randomItem(
535
+ opts.reelStops[Number(chosenReel)]
536
+ );
537
+ symProbsOnReels[Number(chosenReel)] = 0;
538
+ stopPositionsForReels[Number(chosenReel)] = chosenStop;
628
539
  }
629
- if (exists) {
630
- this.reels = this.parseReelsetCSV(filePath, gameConf);
540
+ return stopPositionsForReels;
541
+ }
542
+ getRandomReelset(ctx) {
543
+ const weights = ctx.state.currentResultSet.reelWeights;
544
+ const evalWeights = ctx.state.currentResultSet.reelWeights.evaluate?.(ctx);
545
+ let reelSetId = "";
546
+ if (evalWeights) {
547
+ reelSetId = ctx.services.rng.weightedRandom(evalWeights);
548
+ } else {
549
+ reelSetId = ctx.services.rng.weightedRandom(weights[ctx.state.currentSpinType]);
631
550
  }
551
+ const reelSet = ctx.services.game.getReelsetById(ctx.state.currentGameMode, reelSetId);
552
+ return reelSet;
632
553
  }
633
- /**
634
- * Reads a reelset CSV file and returns the reels as arrays of GameSymbols.
635
- */
636
- parseReelsetCSV(reelSetPath, { config }) {
637
- const csvData = fs2.readFileSync(reelSetPath, "utf8");
638
- const rows = csvData.split("\n").filter((line) => line.trim() !== "");
639
- const reels = Array.from(
640
- { length: config.gameModes[this.associatedGameModeName].reelsAmount },
641
- () => []
554
+ resetReels(opts) {
555
+ const length = opts.reelsAmount ?? opts.ctx.services.game.getCurrentGameMode().reelsAmount;
556
+ this.reels = this.makeEmptyReels(opts);
557
+ this.anticipation = Array.from({ length }, () => false);
558
+ this.paddingTop = this.makeEmptyReels(opts);
559
+ this.paddingBottom = this.makeEmptyReels(opts);
560
+ }
561
+ drawBoardMixed(opts) {
562
+ this.resetReels(opts);
563
+ const reelsAmount = opts.reelsAmount ?? opts.ctx.services.game.getCurrentGameMode().reelsAmount;
564
+ const symbolsPerReel = opts.symbolsPerReel ?? opts.ctx.services.game.getCurrentGameMode().symbolsPerReel;
565
+ const padSymbols = opts.padSymbols ?? opts.ctx.config.padSymbols;
566
+ const finalReelStops = Array.from(
567
+ { length: reelsAmount },
568
+ () => null
642
569
  );
643
- rows.forEach((row) => {
644
- const symsInRow = row.split(",").map((symbolId) => {
645
- const symbol = config.symbols.get(symbolId.trim());
646
- if (!symbol) {
647
- throw new Error(`Symbol with id "${symbolId}" not found in game config.`);
570
+ if (opts.forcedStops) {
571
+ for (const [r, stopPos] of Object.entries(opts.forcedStops)) {
572
+ const reelIdx = Number(r);
573
+ const symCount = symbolsPerReel[reelIdx];
574
+ finalReelStops[reelIdx] = stopPos - Math.round(opts.ctx.services.rng.randomFloat(0, symCount - 1));
575
+ if (finalReelStops[reelIdx] < 0) {
576
+ finalReelStops[reelIdx] = opts.reels[reelIdx].length + finalReelStops[reelIdx];
648
577
  }
649
- return symbol;
650
- });
651
- symsInRow.forEach((symbol, ridx) => {
652
- reels[ridx].push(symbol);
653
- });
654
- });
655
- return reels;
656
- }
657
- };
658
-
659
- // src/ResultSet.ts
660
- var ResultSet = class {
661
- criteria;
662
- quota;
663
- multiplier;
664
- reelWeights;
665
- userData;
666
- forceMaxWin;
667
- forceFreespins;
668
- evaluate;
669
- constructor(opts) {
670
- this.criteria = opts.criteria;
671
- this.quota = opts.quota;
672
- this.multiplier = opts.multiplier;
673
- this.reelWeights = opts.reelWeights;
674
- this.userData = opts.userData;
675
- this.forceMaxWin = opts.forceMaxWin;
676
- this.forceFreespins = opts.forceFreespins;
677
- this.evaluate = opts.evaluate;
678
- if (this.quota < 0 || this.quota > 1) {
679
- throw new Error(`Quota must be a float between 0 and 1, got ${this.quota}.`);
578
+ }
680
579
  }
681
- }
682
- static assignCriteriaToSimulations(ctx, gameModeName) {
683
- const rng = new RandomNumberGenerator();
684
- rng.setSeed(0);
685
- if (!ctx.simRunsAmount) {
686
- throw new Error("Simulation configuration is not set.");
580
+ for (let i = 0; i < finalReelStops.length; i++) {
581
+ if (finalReelStops[i] === null) {
582
+ finalReelStops[i] = Math.floor(
583
+ opts.ctx.services.rng.randomFloat(0, opts.reels[i].length - 1)
584
+ );
585
+ }
687
586
  }
688
- const simNums = ctx.simRunsAmount[gameModeName];
689
- const resultSets = ctx.gameConfig.config.gameModes[gameModeName]?.resultSets;
690
- if (!resultSets || resultSets.length === 0) {
691
- throw new Error(`No ResultSets found for game mode: ${gameModeName}.`);
587
+ this.lastDrawnReelStops = finalReelStops.map((pos) => pos);
588
+ this.lastUsedReels = opts.reels;
589
+ for (let ridx = 0; ridx < reelsAmount; ridx++) {
590
+ const reelPos = finalReelStops[ridx];
591
+ for (let p = padSymbols - 1; p >= 0; p--) {
592
+ const topPos = (reelPos - (p + 1)) % opts.reels[ridx].length;
593
+ this.paddingTop[ridx].push(opts.reels[ridx][topPos]);
594
+ const bottomPos = (reelPos + symbolsPerReel[ridx] + p) % opts.reels[ridx].length;
595
+ this.paddingBottom[ridx].unshift(opts.reels[ridx][bottomPos]);
596
+ }
597
+ for (let row = 0; row < symbolsPerReel[ridx]; row++) {
598
+ const symbol = opts.reels[ridx][(reelPos + row) % opts.reels[ridx].length];
599
+ if (!symbol) {
600
+ throw new Error(`Failed to get symbol at pos ${reelPos + row} on reel ${ridx}`);
601
+ }
602
+ this.reels[ridx][row] = symbol;
603
+ }
692
604
  }
693
- if (simNums === void 0 || simNums <= 0) {
694
- throw new Error(`No simulations configured for game mode "${gameModeName}".`);
605
+ }
606
+ tumbleBoard(opts) {
607
+ assert3(this.lastDrawnReelStops.length > 0, "Cannot tumble board before drawing it.");
608
+ const reelsAmount = opts.reelsAmount ?? opts.ctx.services.game.getCurrentGameMode().reelsAmount;
609
+ const symbolsPerReel = opts.symbolsPerReel ?? opts.ctx.services.game.getCurrentGameMode().symbolsPerReel;
610
+ const padSymbols = opts.padSymbols ?? opts.ctx.config.padSymbols;
611
+ if (!opts.ctx && !reelsAmount && !symbolsPerReel) {
612
+ throw new Error(
613
+ "If ctx is not provided, reelsAmount and symbolsPerReel must be given."
614
+ );
695
615
  }
696
- const numberOfSimsForCriteria = Object.fromEntries(
697
- resultSets.map((rs) => [rs.criteria, Math.max(Math.floor(rs.quota * simNums), 1)])
698
- );
699
- let totalSims = Object.values(numberOfSimsForCriteria).reduce(
700
- (sum, num) => sum + num,
701
- 0
702
- );
703
- let reduceSims = totalSims > simNums;
704
- const criteriaToWeights = Object.fromEntries(
705
- resultSets.map((rs) => [rs.criteria, rs.quota])
706
- );
707
- while (totalSims != simNums) {
708
- const rs = weightedRandom(criteriaToWeights, rng);
709
- if (reduceSims && numberOfSimsForCriteria[rs] > 1) {
710
- numberOfSimsForCriteria[rs] -= 1;
711
- } else if (!reduceSims) {
712
- numberOfSimsForCriteria[rs] += 1;
616
+ const reels = this.lastUsedReels;
617
+ opts.symbolsToDelete.forEach(({ reelIdx, rowIdx }) => {
618
+ this.reels[reelIdx].splice(rowIdx, 1);
619
+ });
620
+ const newFirstSymbolPositions = {};
621
+ for (let ridx = 0; ridx < reelsAmount; ridx++) {
622
+ while (this.reels[ridx].length < symbolsPerReel[ridx]) {
623
+ const padSymbol = this.paddingTop[ridx].pop();
624
+ if (padSymbol) {
625
+ this.reels[ridx].unshift(padSymbol);
626
+ } else {
627
+ break;
628
+ }
629
+ }
630
+ const previousStop = this.lastDrawnReelStops[ridx];
631
+ const stopBeforePad = previousStop - padSymbols - 1;
632
+ const symbolsNeeded = symbolsPerReel[ridx] - this.reels[ridx].length;
633
+ for (let s = 0; s < symbolsNeeded; s++) {
634
+ const symbolPos = (stopBeforePad - s + reels[ridx].length) % reels[ridx].length;
635
+ const newSymbol = reels[ridx][symbolPos];
636
+ assert3(newSymbol, "Failed to get new symbol for tumbling.");
637
+ this.reels[ridx].unshift(newSymbol);
638
+ newFirstSymbolPositions[ridx] = symbolPos;
713
639
  }
714
- totalSims = Object.values(numberOfSimsForCriteria).reduce(
715
- (sum, num) => sum + num,
716
- 0
717
- );
718
- reduceSims = totalSims > simNums;
719
640
  }
720
- let allCriteria = [];
721
- const simNumsToCriteria = {};
722
- Object.entries(numberOfSimsForCriteria).forEach(([criteria, num]) => {
723
- for (let i = 0; i <= num; i++) {
724
- allCriteria.push(criteria);
641
+ for (let ridx = 0; ridx < reelsAmount; ridx++) {
642
+ const firstSymbolPos = newFirstSymbolPositions[ridx];
643
+ for (let p = 1; p <= padSymbols; p++) {
644
+ const topPos = (firstSymbolPos - p + reels[ridx].length) % reels[ridx].length;
645
+ const padSymbol = reels[ridx][topPos];
646
+ assert3(padSymbol, "Failed to get new padding symbol for tumbling.");
647
+ this.paddingTop[ridx].unshift(padSymbol);
725
648
  }
726
- });
727
- allCriteria = shuffle(allCriteria, rng);
728
- for (let i = 1; i <= Math.min(simNums, allCriteria.length); i++) {
729
- simNumsToCriteria[i] = allCriteria[i];
730
649
  }
731
- return simNumsToCriteria;
650
+ }
651
+ };
652
+
653
+ // src/service/board.ts
654
+ var BoardService = class extends AbstractService {
655
+ board;
656
+ constructor(ctx) {
657
+ super(ctx);
658
+ this.board = new Board();
732
659
  }
733
660
  /**
734
- * Checks if core criteria is met, e.g. target multiplier or max win.
661
+ * Resets the board to an empty state.\
662
+ * This is called before drawing a new board.
735
663
  */
736
- meetsCriteria(ctx) {
737
- const customEval = this.evaluate?.(copy(ctx));
738
- const freespinsMet = this.forceFreespins ? ctx.state.triggeredFreespins : true;
739
- const multiplierMet = this.multiplier !== void 0 ? ctx.wallet.getCurrentWin() === this.multiplier && !this.forceMaxWin : ctx.wallet.getCurrentWin() > 0 && (!this.forceMaxWin || true);
740
- const maxWinMet = this.forceMaxWin ? ctx.wallet.getCurrentWin() >= ctx.config.maxWinX : true;
741
- const coreCriteriaMet = freespinsMet && multiplierMet && maxWinMet;
742
- const finalResult = customEval !== void 0 ? coreCriteriaMet && customEval === true : coreCriteriaMet;
743
- if (this.forceMaxWin && maxWinMet) {
744
- ctx.record({
745
- maxwin: true
746
- });
747
- }
748
- return finalResult;
664
+ resetBoard() {
665
+ this.resetReels();
666
+ this.board.lastDrawnReelStops = [];
749
667
  }
750
- };
751
-
752
- // src/Wallet.ts
753
- var Wallet = class {
754
668
  /**
755
- * Total win amount (as the bet multiplier) from all simulations.
669
+ * Gets the current reels and symbols on the board.
756
670
  */
757
- cumulativeWins = 0;
671
+ getBoardReels() {
672
+ return this.board.reels;
673
+ }
674
+ getPaddingTop() {
675
+ return this.board.paddingTop;
676
+ }
677
+ getPaddingBottom() {
678
+ return this.board.paddingBottom;
679
+ }
680
+ getAnticipation() {
681
+ return this.board.anticipation;
682
+ }
683
+ resetReels() {
684
+ this.board.resetReels({
685
+ ctx: this.ctx()
686
+ });
687
+ }
758
688
  /**
759
- * Total win amount (as the bet multiplier) per spin type.
760
- *
761
- * @example
762
- * ```ts
763
- * {
764
- * basegame: 50,
765
- * freespins: 100,
766
- * superfreespins: 200,
767
- * }
768
- * ```
769
- */
770
- cumulativeWinsPerSpinType = {
771
- [GameConfig.SPIN_TYPE.BASE_GAME]: 0,
772
- [GameConfig.SPIN_TYPE.FREE_SPINS]: 0
773
- };
774
- /**
775
- * Current win amount (as the bet multiplier) for the ongoing simulation.
776
- */
777
- currentWin = 0;
778
- /**
779
- * Current win amount (as the bet multiplier) for the ongoing simulation per spin type.
780
- *
781
- * @example
782
- * ```ts
783
- * {
784
- * basegame: 50,
785
- * freespins: 100,
786
- * superfreespins: 200,
787
- * }
788
- * ```
789
- */
790
- currentWinPerSpinType = {
791
- [GameConfig.SPIN_TYPE.BASE_GAME]: 0,
792
- [GameConfig.SPIN_TYPE.FREE_SPINS]: 0
793
- };
794
- /**
795
- * Holds the current win amount for a single (free) spin.\
796
- * After each spin, this amount is added to `currentWinPerSpinType` and then reset to zero.
689
+ * Sets the anticipation value for a specific reel.
797
690
  */
798
- currentSpinWin = 0;
691
+ setAnticipationForReel(reelIndex, value) {
692
+ this.board.anticipation[reelIndex] = value;
693
+ }
799
694
  /**
800
- * Current win amount (as the bet multiplier) for the ongoing tumble sequence.
695
+ * Counts how many symbols matching the criteria are on a specific reel.
801
696
  */
802
- currentTumbleWin = 0;
803
- constructor() {
697
+ countSymbolsOnReel(symbolOrProperties, reelIndex) {
698
+ return this.board.countSymbolsOnReel(symbolOrProperties, reelIndex);
804
699
  }
805
700
  /**
806
- * Updates the win for the current spin.
701
+ * Counts how many symbols matching the criteria are on the board.
807
702
  *
808
- * Should be called after each tumble event, if applicable.\
809
- * Or generally call this to add wins during a spin.
703
+ * Passing a GameSymbol will compare by ID, passing a properties object will compare by properties.
810
704
  *
811
- * After each (free) spin, this amount should be added to `currentWinPerSpinType` via `confirmSpinWin()`
705
+ * Returns a tuple where the first element is the total count, and the second element is a record of counts per reel index.
812
706
  */
813
- addSpinWin(amount) {
814
- this.currentSpinWin += amount;
707
+ countSymbolsOnBoard(symbolOrProperties) {
708
+ return this.board.countSymbolsOnBoard(symbolOrProperties);
815
709
  }
816
710
  /**
817
- * Assigns a win amount to the given spin type.
711
+ * Checks if a symbol appears more than once on any reel in the current reel set.
818
712
  *
819
- * Should be called after `addSpinWin()`, and after your tumble events are played out,\
820
- * and after a (free) spin is played out to finalize the win.
713
+ * Useful to check for "forbidden" generations, e.g. 2 scatters on one reel.
821
714
  */
822
- confirmSpinWin(spinType) {
823
- if (!Object.keys(this.currentWinPerSpinType).includes(spinType)) {
824
- throw new Error(`Spin type "${spinType}" does not exist in the wallet.`);
825
- }
826
- this.currentWinPerSpinType[spinType] += this.currentSpinWin;
827
- this.currentWin += this.currentSpinWin;
828
- this.currentSpinWin = 0;
715
+ isSymbolOnAnyReelMultipleTimes(symbol) {
716
+ return this.board.isSymbolOnAnyReelMultipleTimes(symbol);
829
717
  }
830
718
  /**
831
- * Returns the accumulated win amount (as the bet multiplier) from all simulations.
719
+ * Gets all reel stops (positions) where the specified symbol appears in the current reel set.\
720
+ * Returns an array of arrays, where each inner array contains the positions for the corresponding reel.
832
721
  */
833
- getCumulativeWins() {
834
- return this.cumulativeWins;
722
+ getReelStopsForSymbol(reels, symbol) {
723
+ return this.board.getReelStopsForSymbol(reels, symbol);
835
724
  }
836
725
  /**
837
- * Returns the accumulated win amount (as the bet multiplier) per spin type from all simulations.
726
+ * Combines multiple arrays of reel stops into a single array of reel stops.\
838
727
  */
839
- getCumulativeWinsPerSpinType() {
840
- return this.cumulativeWinsPerSpinType;
728
+ combineReelStops(...reelStops) {
729
+ return this.board.combineReelStops({
730
+ ctx: this.ctx(),
731
+ reelStops
732
+ });
841
733
  }
842
734
  /**
843
- * Returns the current win amount (as the bet multiplier) for the ongoing simulation.
735
+ * From a list of reel stops on reels, selects a random stop for a speficied number of random symbols.
736
+ *
737
+ * Mostly useful for placing scatter symbols on the board.
844
738
  */
845
- getCurrentWin() {
846
- return this.currentWin;
739
+ getRandomReelStops(reels, reelStops, amount) {
740
+ return this.board.getRandomReelStops({
741
+ ctx: this.ctx(),
742
+ reels,
743
+ reelStops,
744
+ amount
745
+ });
847
746
  }
848
747
  /**
849
- * Returns the current win amount (as the bet multiplier) per spin type for the ongoing simulation.
748
+ * Selects a random reel set based on the configured weights of the current result set.\
749
+ * Returns the reels as arrays of GameSymbols.
850
750
  */
851
- getCurrentWinPerSpinType() {
852
- return this.currentWinPerSpinType;
751
+ getRandomReelset() {
752
+ return this.board.getRandomReelset(this.ctx());
853
753
  }
854
754
  /**
855
- * Adds a win to `currentSpinWin` and `currentTumbleWin`.
856
- *
857
- * After each (free) spin, this amount should be added to `currentWinPerSpinType` via `confirmSpinWin()`
755
+ * Draws a board using specified reel stops.
858
756
  */
859
- addTumbleWin(amount) {
860
- this.currentTumbleWin += amount;
861
- this.addSpinWin(amount);
757
+ drawBoardWithForcedStops(reels, forcedStops) {
758
+ this.drawBoardMixed(reels, forcedStops);
862
759
  }
863
760
  /**
864
- * Resets the current win amounts to zero.
761
+ * Draws a board using random reel stops.
865
762
  */
866
- resetCurrentWin() {
867
- this.currentWin = 0;
868
- this.currentSpinWin = 0;
869
- this.currentTumbleWin = 0;
870
- for (const spinType of Object.keys(this.currentWinPerSpinType)) {
871
- this.currentWinPerSpinType[spinType] = 0;
872
- }
763
+ drawBoardWithRandomStops(reels) {
764
+ this.drawBoardMixed(reels);
765
+ }
766
+ drawBoardMixed(reels, forcedStops) {
767
+ this.board.drawBoardMixed({
768
+ ctx: this.ctx(),
769
+ reels,
770
+ forcedStops
771
+ });
873
772
  }
874
773
  /**
875
- * Adds current wins to cumulative wins and resets current wins to zero.
774
+ * Tumbles the board. All given symbols will be deleted and new symbols will fall from the top.
876
775
  */
877
- confirmWins(ctx) {
878
- function process2(number) {
879
- return Math.round(Math.min(number, ctx.config.maxWinX) * 100) / 100;
880
- }
881
- ctx.state.book.writePayout(ctx);
882
- this.currentWin = process2(this.currentWin);
883
- this.cumulativeWins += this.currentWin;
884
- let spinTypeWins = 0;
885
- for (const spinType of Object.keys(this.currentWinPerSpinType)) {
886
- const st = spinType;
887
- const spinTypeWin = process2(this.currentWinPerSpinType[st]);
888
- this.cumulativeWinsPerSpinType[st] += spinTypeWin;
889
- spinTypeWins += spinTypeWin;
890
- }
891
- if (process2(spinTypeWins) !== this.currentWin) {
892
- throw new Error(
893
- `Inconsistent wallet state: currentWin (${this.currentWin}) does not equal spinTypeWins (${spinTypeWins}).`
894
- );
895
- }
896
- this.resetCurrentWin();
897
- }
898
- serialize() {
899
- return {
900
- cumulativeWins: this.cumulativeWins,
901
- cumulativeWinsPerSpinType: this.cumulativeWinsPerSpinType,
902
- currentWin: this.currentWin,
903
- currentWinPerSpinType: this.currentWinPerSpinType,
904
- currentSpinWin: this.currentSpinWin,
905
- currentTumbleWin: this.currentTumbleWin
906
- };
907
- }
908
- merge(wallet) {
909
- this.cumulativeWins += wallet.getCumulativeWins();
910
- const otherWinsPerSpinType = wallet.getCumulativeWinsPerSpinType();
911
- for (const spinType of Object.keys(this.cumulativeWinsPerSpinType)) {
912
- this.cumulativeWinsPerSpinType[spinType] += otherWinsPerSpinType[spinType] || 0;
913
- }
914
- }
915
- mergeSerialized(data) {
916
- this.cumulativeWins += data.cumulativeWins;
917
- for (const spinType of Object.keys(this.cumulativeWinsPerSpinType)) {
918
- this.cumulativeWinsPerSpinType[spinType] += data.cumulativeWinsPerSpinType[spinType] || 0;
919
- }
920
- this.currentWin += data.currentWin;
921
- this.currentSpinWin += data.currentSpinWin;
922
- this.currentTumbleWin += data.currentTumbleWin;
923
- for (const spinType of Object.keys(this.currentWinPerSpinType)) {
924
- this.currentWinPerSpinType[spinType] += data.currentWinPerSpinType[spinType] || 0;
925
- }
776
+ tumbleBoard(symbolsToDelete) {
777
+ this.board.tumbleBoard({
778
+ ctx: this.ctx(),
779
+ symbolsToDelete
780
+ });
926
781
  }
927
782
  };
928
783
 
929
- // src/Book.ts
930
- var Book = class _Book {
931
- id;
932
- criteria = "N/A";
933
- events = [];
934
- payout = 0;
935
- basegameWins = 0;
936
- freespinsWins = 0;
937
- constructor(opts) {
938
- this.id = opts.id;
939
- }
940
- /**
941
- * Adds an event to the book.
942
- */
943
- addEvent(event) {
944
- const index = this.events.length + 1;
945
- this.events.push({ index, ...event });
946
- }
947
- /**
948
- * Transfers the win data from the wallet to the book.
949
- */
950
- writePayout(ctx) {
951
- function process2(number) {
952
- return Math.round(Math.min(number, ctx.config.maxWinX) * 100) / 100;
953
- }
954
- this.payout = Math.round(process2(ctx.wallet.getCurrentWin()) * 100);
955
- this.basegameWins = process2(
956
- ctx.wallet.getCurrentWinPerSpinType()[GameConfig.SPIN_TYPE.BASE_GAME] || 0
957
- );
958
- this.freespinsWins = process2(
959
- ctx.wallet.getCurrentWinPerSpinType()[GameConfig.SPIN_TYPE.FREE_SPINS] || 0
960
- );
961
- }
962
- getPayout() {
963
- return this.payout;
964
- }
965
- getBasegameWins() {
966
- return this.basegameWins;
967
- }
968
- getFreespinsWins() {
969
- return this.freespinsWins;
784
+ // src/service/data.ts
785
+ import assert4 from "assert";
786
+ var DataService = class extends AbstractService {
787
+ recorder;
788
+ book;
789
+ constructor(ctx) {
790
+ super(ctx);
970
791
  }
971
- serialize() {
972
- return {
973
- id: this.id,
974
- criteria: this.criteria,
975
- events: this.events,
976
- payout: this.payout,
977
- basegameWins: this.basegameWins,
978
- freespinsWins: this.freespinsWins
979
- };
792
+ ensureRecorder() {
793
+ assert4(this.recorder, "Recorder not set in DataService. Call setRecorder() first.");
980
794
  }
981
- static fromSerialized(data) {
982
- const book = new _Book({ id: data.id });
983
- book.criteria = data.criteria;
984
- book.events = data.events;
985
- book.payout = data.payout;
986
- book.basegameWins = data.basegameWins;
987
- book.freespinsWins = data.freespinsWins;
988
- return book;
795
+ ensureBook() {
796
+ assert4(this.book, "Book not set in DataService. Call setBook() first.");
989
797
  }
990
- };
991
-
992
- // src/GameState.ts
993
- var GameState = class extends GameConfig {
994
- state;
995
798
  /**
996
- * The wallet stores win data for the current and all simulations, respectively.
799
+ * Intended for internal use only.
997
800
  */
998
- wallet;
801
+ _setRecorder(recorder) {
802
+ this.recorder = recorder;
803
+ }
999
804
  /**
1000
- * Recorder for statistical analysis (e.g. symbol occurrences, etc.).
805
+ * Intended for internal use only.
1001
806
  */
1002
- recorder;
1003
- constructor(opts) {
1004
- super(opts);
1005
- this.state = {
1006
- currentSpinType: GameConfig.SPIN_TYPE.BASE_GAME,
1007
- library: /* @__PURE__ */ new Map(),
1008
- book: new Book({ id: 0 }),
1009
- currentGameMode: "N/A",
1010
- currentSimulationId: 0,
1011
- isCriteriaMet: false,
1012
- currentFreespinAmount: 0,
1013
- totalFreespinAmount: 0,
1014
- rng: new RandomNumberGenerator(),
1015
- userData: opts.userState || {},
1016
- triggeredMaxWin: false,
1017
- triggeredFreespins: false,
1018
- // This is a placeholder ResultSet to avoid null checks elsewhere.
1019
- currentResultSet: new ResultSet({
1020
- criteria: "N/A",
1021
- quota: 0,
1022
- reelWeights: {
1023
- [GameConfig.SPIN_TYPE.BASE_GAME]: {},
1024
- [GameConfig.SPIN_TYPE.FREE_SPINS]: {}
1025
- }
1026
- })
1027
- };
1028
- this.wallet = new Wallet();
1029
- this.recorder = {
1030
- pendingRecords: [],
1031
- records: []
1032
- };
807
+ _getBook() {
808
+ return this.book;
1033
809
  }
1034
810
  /**
1035
- * Gets the configuration for the current game mode.
811
+ * Intended for internal use only.
1036
812
  */
1037
- getCurrentGameMode() {
1038
- return this.config.gameModes[this.state.currentGameMode];
1039
- }
1040
- resetState() {
1041
- this.state.rng.setSeedIfDifferent(this.state.currentSimulationId);
1042
- this.state.book = new Book({ id: this.state.currentSimulationId });
1043
- this.state.currentSpinType = GameConfig.SPIN_TYPE.BASE_GAME;
1044
- this.state.currentFreespinAmount = 0;
1045
- this.state.totalFreespinAmount = 0;
1046
- this.state.triggeredMaxWin = false;
1047
- this.state.triggeredFreespins = false;
1048
- this.wallet.resetCurrentWin();
1049
- this.clearPendingRecords();
1050
- this.state.userData = this.config.userState || {};
813
+ _setBook(book) {
814
+ this.book = book;
1051
815
  }
1052
816
  /**
1053
- * Empties the list of pending records in the recorder.
817
+ * Intended for internal use only.
1054
818
  */
1055
- clearPendingRecords() {
1056
- this.recorder.pendingRecords = [];
819
+ _getRecorder() {
820
+ return this.recorder;
1057
821
  }
1058
822
  /**
1059
- * Confirms all pending records and adds them to the main records list.
823
+ * Intended for internal use only.
1060
824
  */
1061
- confirmRecords() {
1062
- for (const pendingRecord of this.recorder.pendingRecords) {
1063
- const search = Object.entries(pendingRecord.properties).map(([name, value]) => ({ name, value })).sort((a, b) => a.name.localeCompare(b.name));
1064
- let record = this.recorder.records.find((r) => {
1065
- if (r.search.length !== search.length) return false;
1066
- for (let i = 0; i < r.search.length; i++) {
1067
- if (r.search[i].name !== search[i].name) return false;
1068
- if (r.search[i].value !== search[i].value) return false;
1069
- }
1070
- return true;
1071
- });
1072
- if (!record) {
1073
- record = {
1074
- search,
1075
- timesTriggered: 0,
1076
- bookIds: []
1077
- };
1078
- this.recorder.records.push(record);
1079
- }
1080
- record.timesTriggered++;
1081
- if (!record.bookIds.includes(pendingRecord.bookId)) {
1082
- record.bookIds.push(pendingRecord.bookId);
1083
- }
1084
- }
1085
- this.clearPendingRecords();
825
+ _getRecords() {
826
+ this.ensureRecorder();
827
+ return this.recorder.records;
1086
828
  }
1087
829
  /**
1088
830
  * Record data for statistical analysis.
1089
831
  */
1090
832
  record(data) {
833
+ this.ensureRecorder();
1091
834
  this.recorder.pendingRecords.push({
1092
- bookId: this.state.currentSimulationId,
835
+ bookId: this.ctx().state.currentSimulationId,
1093
836
  properties: Object.fromEntries(
1094
837
  Object.entries(data).map(([k, v]) => [k, String(v)])
1095
838
  )
@@ -1098,49 +841,101 @@ var GameState = class extends GameConfig {
1098
841
  /**
1099
842
  * Records a symbol occurrence for statistical analysis.
1100
843
  *
1101
- * Calls `this.record()` with the provided data.
844
+ * Calls `ctx.services.data.record()` with the provided data.
1102
845
  */
1103
846
  recordSymbolOccurrence(data) {
1104
847
  this.record(data);
1105
848
  }
1106
849
  /**
1107
- * Gets all confirmed records.
850
+ * Adds an event to the book.
1108
851
  */
1109
- getRecords() {
1110
- return this.recorder.records;
852
+ addBookEvent(event) {
853
+ this.ensureBook();
854
+ this.book.addEvent(event);
1111
855
  }
1112
856
  /**
1113
- * Moves the current book to the library and resets the current book.
857
+ * Intended for internal use only.
1114
858
  */
1115
- moveBookToLibrary() {
1116
- this.state.library.set(this.state.book.id.toString(), this.state.book);
1117
- this.state.book = new Book({ id: 0 });
859
+ _clearPendingRecords() {
860
+ this.ensureRecorder();
861
+ this.recorder.pendingRecords = [];
1118
862
  }
1119
- /**
1120
- * Increases the freespin count by the specified amount.
1121
- *
1122
- * Also sets `state.triggeredFreespins` to true.
1123
- */
1124
- awardFreespins(amount) {
1125
- this.state.currentFreespinAmount += amount;
1126
- this.state.totalFreespinAmount += amount;
1127
- this.state.triggeredFreespins = true;
863
+ };
864
+
865
+ // src/service/game.ts
866
+ var GameService = class extends AbstractService {
867
+ constructor(ctx) {
868
+ super(ctx);
1128
869
  }
1129
870
  /**
1130
- * Ensures the requested number of scatters is valid based on the game configuration.\
871
+ * Retrieves a reel set by its ID within a specific game mode.
872
+ */
873
+ getReelsetById(gameMode, id) {
874
+ const reelSet = this.ctx().config.gameModes[gameMode].reelSets.find(
875
+ (rs) => rs.id === id
876
+ );
877
+ if (!reelSet) {
878
+ throw new Error(
879
+ `Reel set with id "${id}" not found in game mode "${gameMode}". Available reel sets: ${this.ctx().config.gameModes[gameMode].reelSets.map((rs) => rs.id).join(", ")}`
880
+ );
881
+ }
882
+ return reelSet.reels;
883
+ }
884
+ /**
885
+ * Retrieves the number of free spins awarded for a given spin type and scatter count.
886
+ */
887
+ getFreeSpinsForScatters(spinType, scatterCount) {
888
+ const freespinsConfig = this.ctx().config.scatterToFreespins[spinType];
889
+ if (!freespinsConfig) {
890
+ throw new Error(
891
+ `No free spins configuration found for spin type "${spinType}". Please check your game configuration.`
892
+ );
893
+ }
894
+ return freespinsConfig[scatterCount] || 0;
895
+ }
896
+ /**
897
+ * Retrieves a result set by its criteria within a specific game mode.
898
+ */
899
+ getResultSetByCriteria(mode, criteria) {
900
+ const gameMode = this.ctx().config.gameModes[mode];
901
+ if (!gameMode) {
902
+ throw new Error(`Game mode "${mode}" not found in game config.`);
903
+ }
904
+ const resultSet = gameMode.resultSets.find((rs) => rs.criteria === criteria);
905
+ if (!resultSet) {
906
+ throw new Error(
907
+ `Criteria "${criteria}" not found in game mode "${mode}". Available criteria: ${gameMode.resultSets.map((rs) => rs.criteria).join(", ")}`
908
+ );
909
+ }
910
+ return resultSet;
911
+ }
912
+ /**
913
+ * Returns all configured symbols as an array.
914
+ */
915
+ getSymbolArray() {
916
+ return Array.from(this.ctx().config.symbols).map(([n, v]) => v);
917
+ }
918
+ /**
919
+ * Gets the configuration for the current game mode.
920
+ */
921
+ getCurrentGameMode() {
922
+ return this.ctx().config.gameModes[this.ctx().state.currentGameMode];
923
+ }
924
+ /**
925
+ * Ensures the requested number of scatters is valid based on the game configuration.\
1131
926
  * Returns a valid number of scatters.
1132
927
  */
1133
928
  verifyScatterCount(numScatters) {
1134
- const scatterCounts = this.config.scatterToFreespins[this.state.currentSpinType];
929
+ const scatterCounts = this.ctx().config.scatterToFreespins[this.ctx().state.currentSpinType];
1135
930
  if (!scatterCounts) {
1136
931
  throw new Error(
1137
- `No scatter counts defined for spin type "${this.state.currentSpinType}". Please check your game configuration.`
932
+ `No scatter counts defined for spin type "${this.ctx().state.currentSpinType}". Please check your game configuration.`
1138
933
  );
1139
934
  }
1140
935
  const validCounts = Object.keys(scatterCounts).map((key) => parseInt(key, 10));
1141
936
  if (validCounts.length === 0) {
1142
937
  throw new Error(
1143
- `No scatter counts defined for spin type "${this.state.currentSpinType}". Please check your game configuration.`
938
+ `No scatter counts defined for spin type "${this.ctx().state.currentSpinType}". Please check your game configuration.`
1144
939
  );
1145
940
  }
1146
941
  if (numScatters < Math.min(...validCounts)) {
@@ -1151,838 +946,490 @@ var GameState = class extends GameConfig {
1151
946
  }
1152
947
  return numScatters;
1153
948
  }
949
+ /**
950
+ * Increases the freespin count by the specified amount.
951
+ *
952
+ * Also sets `state.triggeredFreespins` to true.
953
+ */
954
+ awardFreespins(amount) {
955
+ this.ctx().state.currentFreespinAmount += amount;
956
+ this.ctx().state.totalFreespinAmount += amount;
957
+ this.ctx().state.triggeredFreespins = true;
958
+ }
1154
959
  };
1155
960
 
1156
- // src/Board.ts
1157
- var StandaloneBoard = class {
1158
- reels;
1159
- paddingTop;
1160
- paddingBottom;
1161
- anticipation;
1162
- ctx;
1163
- constructor(opts) {
1164
- this.reels = [];
1165
- this.paddingTop = [];
1166
- this.paddingBottom = [];
1167
- this.anticipation = [];
1168
- this.ctx = opts.ctx;
961
+ // src/service/wallet.ts
962
+ import assert5 from "assert";
963
+ var WalletService = class extends AbstractService {
964
+ wallet;
965
+ constructor(ctx) {
966
+ super(ctx);
967
+ }
968
+ ensureWallet() {
969
+ assert5(this.wallet, "Wallet not set in WalletService. Call setWallet() first.");
1169
970
  }
1170
971
  /**
1171
- * Updates the context used by this board instance.
972
+ * Intended for internal use only.
1172
973
  */
1173
- context(ctx) {
1174
- this.ctx = ctx;
974
+ _getWallet() {
975
+ this.ensureWallet();
976
+ return this.wallet;
1175
977
  }
1176
978
  /**
1177
- * Resets the board to an empty state.\
1178
- * This is called before drawing a new board.
979
+ * Intended for internal use only.
1179
980
  */
1180
- resetBoard() {
1181
- this.resetReels();
1182
- }
1183
- makeEmptyReels() {
1184
- return Array.from({ length: this.ctx.getCurrentGameMode().reelsAmount }, () => []);
1185
- }
1186
- resetReels() {
1187
- this.reels = this.makeEmptyReels();
1188
- this.anticipation = Array.from(
1189
- { length: this.ctx.getCurrentGameMode().reelsAmount },
1190
- () => 0
1191
- );
1192
- if (this.ctx.config.padSymbols && this.ctx.config.padSymbols > 0) {
1193
- this.paddingTop = this.makeEmptyReels();
1194
- this.paddingBottom = this.makeEmptyReels();
1195
- }
981
+ _setWallet(wallet) {
982
+ this.wallet = wallet;
1196
983
  }
1197
984
  /**
1198
- * Counts how many symbols matching the criteria are on a specific reel.
985
+ * Adds the given amount to the wallet state.
986
+ *
987
+ * After calculating the win for a board, call this method to update the wallet state.\
988
+ * If your game has tumbling mechanics, you should call this method again after every new tumble and win calculation.
1199
989
  */
1200
- countSymbolsOnReel(symbolOrProperties, reelIndex) {
1201
- let total = 0;
1202
- for (const symbol of this.reels[reelIndex]) {
1203
- let matches = true;
1204
- if (symbolOrProperties instanceof GameSymbol) {
1205
- if (symbol.id !== symbolOrProperties.id) matches = false;
1206
- } else {
1207
- for (const [key, value] of Object.entries(symbolOrProperties)) {
1208
- if (!symbol.properties.has(key) || symbol.properties.get(key) !== value) {
1209
- matches = false;
1210
- break;
1211
- }
1212
- }
1213
- }
1214
- if (matches) {
1215
- total++;
1216
- }
1217
- }
1218
- return total;
990
+ addSpinWin(amount) {
991
+ this.ensureWallet();
992
+ this.wallet.addSpinWin(amount);
1219
993
  }
1220
994
  /**
1221
- * Counts how many symbols matching the criteria are on the board.
1222
- *
1223
- * Passing a GameSymbol will compare by ID, passing a properties object will compare by properties.
995
+ * Helps to add tumble wins to the wallet state.
1224
996
  *
1225
- * Returns a tuple where the first element is the total count, and the second element is a record of counts per reel index.
997
+ * This also calls `addSpinWin()` internally, to add the tumble win to the overall spin win.
1226
998
  */
1227
- countSymbolsOnBoard(symbolOrProperties) {
1228
- let total = 0;
1229
- const onReel = {};
1230
- for (const [ridx, reel] of this.reels.entries()) {
1231
- for (const symbol of reel) {
1232
- let matches = true;
1233
- if (symbolOrProperties instanceof GameSymbol) {
1234
- if (symbol.id !== symbolOrProperties.id) matches = false;
1235
- } else {
1236
- for (const [key, value] of Object.entries(symbolOrProperties)) {
1237
- if (!symbol.properties.has(key) || symbol.properties.get(key) !== value) {
1238
- matches = false;
1239
- break;
1240
- }
1241
- }
1242
- }
1243
- if (matches) {
1244
- total++;
1245
- if (onReel[ridx] === void 0) {
1246
- onReel[ridx] = 1;
1247
- } else {
1248
- onReel[ridx]++;
1249
- }
1250
- }
1251
- }
1252
- }
1253
- return [total, onReel];
999
+ addTumbleWin(amount) {
1000
+ this.ensureWallet();
1001
+ this.wallet.addTumbleWin(amount);
1254
1002
  }
1255
1003
  /**
1256
- * Checks if a symbol appears more than once on any reel in the current reel set.
1004
+ * Confirms the wins of the current spin.
1257
1005
  *
1258
- * Useful to check for "forbidden" generations, e.g. 2 scatters on one reel.
1006
+ * Should be called after `addSpinWin()`, and after your tumble events are played out,\
1007
+ * and after a (free) spin is played out to finalize the win.
1259
1008
  */
1260
- isSymbolOnAnyReelMultipleTimes(symbol) {
1261
- for (const reel of this.reels) {
1262
- let count = 0;
1263
- for (const sym of reel) {
1264
- if (sym.id === symbol.id) {
1265
- count++;
1266
- }
1267
- if (count > 1) {
1268
- return true;
1269
- }
1270
- }
1271
- }
1272
- return false;
1009
+ confirmSpinWin() {
1010
+ this.ensureWallet();
1011
+ this.wallet.confirmSpinWin(this.ctx().state.currentSpinType);
1273
1012
  }
1274
1013
  /**
1275
- * Draws a board using specified reel stops.
1014
+ * Gets the total win amount of the current simulation.
1276
1015
  */
1277
- drawBoardWithForcedStops(reels, forcedStops) {
1278
- this.drawBoardMixed(reels, forcedStops);
1016
+ getCurrentWin() {
1017
+ this.ensureWallet();
1018
+ return this.wallet.getCurrentWin();
1279
1019
  }
1280
1020
  /**
1281
- * Draws a board using random reel stops.
1021
+ * Gets the current spin win amount of the ongoing spin.
1282
1022
  */
1283
- drawBoardWithRandomStops(reels) {
1284
- this.drawBoardMixed(reels);
1023
+ getCurrentSpinWin() {
1024
+ this.ensureWallet();
1025
+ return this.wallet.getCurrentSpinWin();
1285
1026
  }
1286
- drawBoardMixed(reels, forcedStops) {
1287
- this.resetReels();
1288
- const finalReelStops = Array.from(
1289
- { length: this.ctx.getCurrentGameMode().reelsAmount },
1290
- () => null
1291
- );
1292
- if (forcedStops) {
1293
- for (const [r, stopPos] of Object.entries(forcedStops)) {
1294
- const reelIdx = Number(r);
1295
- const symCount = this.ctx.getCurrentGameMode().symbolsPerReel[reelIdx];
1296
- finalReelStops[reelIdx] = stopPos - Math.round(this.ctx.state.rng.randomFloat(0, symCount - 1));
1297
- }
1298
- }
1299
- for (let i = 0; i < finalReelStops.length; i++) {
1300
- if (finalReelStops[i] === null) {
1301
- finalReelStops[i] = Math.floor(
1302
- this.ctx.state.rng.randomFloat(0, reels[i].length - 1)
1303
- );
1304
- }
1305
- }
1306
- for (let ridx = 0; ridx < this.ctx.getCurrentGameMode().reelsAmount; ridx++) {
1307
- const reelPos = finalReelStops[ridx];
1308
- if (this.ctx.config.padSymbols && this.ctx.config.padSymbols > 0) {
1309
- for (let p = this.ctx.config.padSymbols - 1; p >= 0; p--) {
1310
- const topPos = (reelPos - (p + 1)) % reels[ridx].length;
1311
- this.paddingTop[ridx].push(reels[ridx][topPos]);
1312
- const bottomPos = (reelPos + this.ctx.getCurrentGameMode().symbolsPerReel[ridx] + p) % reels[ridx].length;
1313
- this.paddingBottom[ridx].unshift(reels[ridx][bottomPos]);
1314
- }
1315
- }
1316
- for (let row = 0; row < this.ctx.getCurrentGameMode().symbolsPerReel[ridx]; row++) {
1317
- const symbol = reels[ridx][(reelPos + row) % reels[ridx].length];
1318
- if (!symbol) {
1319
- throw new Error(`Failed to get symbol at pos ${reelPos + row} on reel ${ridx}`);
1320
- }
1321
- this.reels[ridx][row] = symbol;
1322
- }
1323
- }
1027
+ /**
1028
+ * Gets the current tumble win amount of the ongoing spin.
1029
+ */
1030
+ getCurrentTumbleWin() {
1031
+ this.ensureWallet();
1032
+ return this.wallet.getCurrentTumbleWin();
1324
1033
  }
1325
1034
  };
1326
- var Board = class extends GameState {
1327
- board;
1328
- constructor(opts) {
1329
- super(opts);
1330
- this.board = {
1331
- reels: [],
1332
- paddingTop: [],
1333
- paddingBottom: [],
1334
- anticipation: []
1035
+
1036
+ // src/game-context/index.ts
1037
+ function createGameContext(opts) {
1038
+ const context = {
1039
+ config: opts.config,
1040
+ state: createGameState(opts.state),
1041
+ services: {}
1042
+ };
1043
+ const getContext = () => context;
1044
+ function createServices() {
1045
+ return {
1046
+ game: new GameService(getContext),
1047
+ data: new DataService(getContext),
1048
+ board: new BoardService(getContext),
1049
+ wallet: new WalletService(getContext),
1050
+ rng: new RngService(getContext),
1051
+ ...opts.services
1335
1052
  };
1336
1053
  }
1054
+ context.services = createServices();
1055
+ return context;
1056
+ }
1057
+
1058
+ // src/book/index.ts
1059
+ var Book = class _Book {
1060
+ id;
1061
+ criteria = "N/A";
1062
+ events = [];
1063
+ payout = 0;
1064
+ basegameWins = 0;
1065
+ freespinsWins = 0;
1066
+ constructor(opts) {
1067
+ this.id = opts.id;
1068
+ }
1337
1069
  /**
1338
- * Resets the board to an empty state.\
1339
- * This is called before drawing a new board.
1070
+ * Intended for internal use only.
1340
1071
  */
1341
- resetBoard() {
1342
- this.resetReels();
1343
- }
1344
- makeEmptyReels() {
1345
- return Array.from({ length: this.getCurrentGameMode().reelsAmount }, () => []);
1072
+ setCriteria(criteria) {
1073
+ this.criteria = criteria;
1346
1074
  }
1347
- resetReels() {
1348
- this.board.reels = this.makeEmptyReels();
1349
- this.board.anticipation = Array.from(
1350
- { length: this.getCurrentGameMode().reelsAmount },
1351
- () => 0
1352
- );
1353
- if (this.config.padSymbols && this.config.padSymbols > 0) {
1354
- this.board.paddingTop = this.makeEmptyReels();
1355
- this.board.paddingBottom = this.makeEmptyReels();
1356
- }
1075
+ /**
1076
+ * Adds an event to the book.
1077
+ */
1078
+ addEvent(event) {
1079
+ const index = this.events.length + 1;
1080
+ this.events.push({ index, ...event });
1357
1081
  }
1358
1082
  /**
1359
- * Counts how many symbols matching the criteria are on a specific reel.
1083
+ * Intended for internal use only.
1360
1084
  */
1361
- countSymbolsOnReel(symbolOrProperties, reelIndex) {
1362
- let total = 0;
1363
- for (const symbol of this.board.reels[reelIndex]) {
1364
- let matches = true;
1365
- if (symbolOrProperties instanceof GameSymbol) {
1366
- if (symbol.id !== symbolOrProperties.id) matches = false;
1367
- } else {
1368
- for (const [key, value] of Object.entries(symbolOrProperties)) {
1369
- if (!symbol.properties.has(key) || symbol.properties.get(key) !== value) {
1370
- matches = false;
1371
- break;
1372
- }
1373
- }
1374
- }
1375
- if (matches) {
1376
- total++;
1377
- }
1378
- }
1379
- return total;
1085
+ serialize() {
1086
+ return {
1087
+ id: this.id,
1088
+ criteria: this.criteria,
1089
+ events: this.events,
1090
+ payout: this.payout,
1091
+ basegameWins: this.basegameWins,
1092
+ freespinsWins: this.freespinsWins
1093
+ };
1380
1094
  }
1381
1095
  /**
1382
- * Counts how many symbols matching the criteria are on the board.
1383
- *
1384
- * Passing a GameSymbol will compare by ID, passing a properties object will compare by properties.
1385
- *
1386
- * Returns a tuple where the first element is the total count, and the second element is a record of counts per reel index.
1096
+ * Intended for internal use only.
1387
1097
  */
1388
- countSymbolsOnBoard(symbolOrProperties) {
1389
- let total = 0;
1390
- const onReel = {};
1391
- for (const [ridx, reel] of this.board.reels.entries()) {
1392
- for (const symbol of reel) {
1393
- let matches = true;
1394
- if (symbolOrProperties instanceof GameSymbol) {
1395
- if (symbol.id !== symbolOrProperties.id) matches = false;
1396
- } else {
1397
- for (const [key, value] of Object.entries(symbolOrProperties)) {
1398
- if (!symbol.properties.has(key) || symbol.properties.get(key) !== value) {
1399
- matches = false;
1400
- break;
1401
- }
1402
- }
1403
- }
1404
- if (matches) {
1405
- total++;
1406
- if (onReel[ridx] === void 0) {
1407
- onReel[ridx] = 1;
1408
- } else {
1409
- onReel[ridx]++;
1410
- }
1411
- }
1412
- }
1413
- }
1414
- return [total, onReel];
1098
+ static fromSerialized(data) {
1099
+ const book = new _Book({ id: data.id, criteria: data.criteria });
1100
+ book.events = data.events;
1101
+ book.payout = data.payout;
1102
+ book.basegameWins = data.basegameWins;
1103
+ book.freespinsWins = data.freespinsWins;
1104
+ return book;
1415
1105
  }
1106
+ };
1107
+
1108
+ // src/recorder/index.ts
1109
+ var Recorder = class {
1110
+ records;
1111
+ pendingRecords;
1112
+ constructor() {
1113
+ this.records = [];
1114
+ this.pendingRecords = [];
1115
+ }
1116
+ };
1117
+
1118
+ // src/wallet/index.ts
1119
+ var Wallet = class {
1416
1120
  /**
1417
- * Checks if a symbol appears more than once on any reel in the current reel set.
1121
+ * Total win amount (as the bet multiplier) from all simulations.
1122
+ */
1123
+ cumulativeWins = 0;
1124
+ /**
1125
+ * Total win amount (as the bet multiplier) per spin type.
1418
1126
  *
1419
- * Useful to check for "forbidden" generations, e.g. 2 scatters on one reel.
1127
+ * @example
1128
+ * ```ts
1129
+ * {
1130
+ * basegame: 50,
1131
+ * freespins: 100,
1132
+ * superfreespins: 200,
1133
+ * }
1134
+ * ```
1420
1135
  */
1421
- isSymbolOnAnyReelMultipleTimes(symbol) {
1422
- for (const reel of this.board.reels) {
1423
- let count = 0;
1424
- for (const sym of reel) {
1425
- if (sym.id === symbol.id) {
1426
- count++;
1427
- }
1428
- if (count > 1) {
1429
- return true;
1430
- }
1431
- }
1432
- }
1433
- return false;
1434
- }
1136
+ cumulativeWinsPerSpinType = {
1137
+ [SPIN_TYPE.BASE_GAME]: 0,
1138
+ [SPIN_TYPE.FREE_SPINS]: 0
1139
+ };
1435
1140
  /**
1436
- * Gets all reel stops (positions) where the specified symbol appears in the current reel set.\
1437
- * Returns an array of arrays, where each inner array contains the positions for the corresponding reel.
1141
+ * Current win amount (as the bet multiplier) for the ongoing simulation.
1438
1142
  */
1439
- getReelStopsForSymbol(reels, symbol) {
1440
- const reelStops = [];
1441
- for (let ridx = 0; ridx < reels.length; ridx++) {
1442
- const reel = reels[ridx];
1443
- const positions = [];
1444
- for (let pos = 0; pos < reel.length; pos++) {
1445
- if (reel[pos].id === symbol.id) {
1446
- positions.push(pos);
1447
- }
1448
- }
1449
- reelStops.push(positions);
1450
- }
1451
- return reelStops;
1452
- }
1143
+ currentWin = 0;
1453
1144
  /**
1454
- * Combines multiple arrays of reel stops into a single array of reel stops.\
1145
+ * Current win amount (as the bet multiplier) for the ongoing simulation per spin type.
1146
+ *
1147
+ * @example
1148
+ * ```ts
1149
+ * {
1150
+ * basegame: 50,
1151
+ * freespins: 100,
1152
+ * superfreespins: 200,
1153
+ * }
1154
+ * ```
1455
1155
  */
1456
- combineReelStops(...reelStops) {
1457
- const combined = [];
1458
- for (let ridx = 0; ridx < this.getCurrentGameMode().reelsAmount; ridx++) {
1459
- combined[ridx] = [];
1460
- for (const stops of reelStops) {
1461
- combined[ridx] = combined[ridx].concat(stops[ridx]);
1462
- }
1463
- }
1464
- return combined;
1156
+ currentWinPerSpinType = {
1157
+ [SPIN_TYPE.BASE_GAME]: 0,
1158
+ [SPIN_TYPE.FREE_SPINS]: 0
1159
+ };
1160
+ /**
1161
+ * Holds the current win amount for a single (free) spin.\
1162
+ * After each spin, this amount is added to `currentWinPerSpinType` and then reset to zero.
1163
+ */
1164
+ currentSpinWin = 0;
1165
+ /**
1166
+ * Current win amount (as the bet multiplier) for the ongoing tumble sequence.
1167
+ */
1168
+ currentTumbleWin = 0;
1169
+ constructor() {
1465
1170
  }
1466
1171
  /**
1467
- * From a list of reel stops on reels, selects a random stop for a speficied number of random symbols.
1172
+ * Updates the win for the current spin.
1468
1173
  *
1469
- * Mostly useful for placing scatter symbols on the board.
1174
+ * Should be called after each tumble event, if applicable.\
1175
+ * Or generally call this to add wins during a spin.
1176
+ *
1177
+ * After each (free) spin, this amount should be added to `currentWinPerSpinType` via `confirmSpinWin()`
1470
1178
  */
1471
- getRandomReelStops(reels, reelStops, amount) {
1472
- const reelsAmount = this.getCurrentGameMode().reelsAmount;
1473
- const symProbsOnReels = [];
1474
- const stopPositionsForReels = {};
1475
- for (let ridx = 0; ridx < reelsAmount; ridx++) {
1476
- symProbsOnReels.push(reelStops[ridx].length / reels[ridx].length);
1477
- }
1478
- while (Object.keys(stopPositionsForReels).length !== amount) {
1479
- const possibleReels = [];
1480
- for (let i = 0; i < reelsAmount; i++) {
1481
- if (symProbsOnReels[i] > 0) {
1482
- possibleReels.push(i);
1483
- }
1484
- }
1485
- const possibleProbs = symProbsOnReels.filter((p) => p > 0);
1486
- const weights = Object.fromEntries(
1487
- possibleReels.map((ridx, idx) => [ridx, possibleProbs[idx]])
1488
- );
1489
- const chosenReel = weightedRandom(weights, this.state.rng);
1490
- const chosenStop = randomItem(reelStops[Number(chosenReel)], this.state.rng);
1491
- symProbsOnReels[Number(chosenReel)] = 0;
1492
- stopPositionsForReels[chosenReel] = chosenStop;
1493
- }
1494
- return stopPositionsForReels;
1179
+ addSpinWin(amount) {
1180
+ this.currentSpinWin += amount;
1495
1181
  }
1496
1182
  /**
1497
- * Selects a random reel set based on the configured weights of the current result set.\
1498
- * Returns the reels as arrays of GameSymbols.
1183
+ * Confirms the wins of the current spin.
1184
+ *
1185
+ * Should be called after `addSpinWin()`, and after your tumble events are played out,\
1186
+ * and after a (free) spin is played out to finalize the win.
1499
1187
  */
1500
- getRandomReelset() {
1501
- const weights = this.state.currentResultSet.reelWeights;
1502
- const evalWeights = this.state.currentResultSet.reelWeights.evaluate?.(
1503
- this
1504
- );
1505
- let reelSetId = "";
1506
- if (evalWeights) {
1507
- reelSetId = weightedRandom(evalWeights, this.state.rng);
1508
- } else {
1509
- reelSetId = weightedRandom(weights[this.state.currentSpinType], this.state.rng);
1188
+ confirmSpinWin(spinType) {
1189
+ if (!Object.keys(this.currentWinPerSpinType).includes(spinType)) {
1190
+ throw new Error(`Spin type "${spinType}" does not exist in the wallet.`);
1510
1191
  }
1511
- const reelSet = this.getReelsetById(this.state.currentGameMode, reelSetId);
1512
- return reelSet;
1192
+ this.currentWinPerSpinType[spinType] += this.currentSpinWin;
1193
+ this.currentWin += this.currentSpinWin;
1194
+ this.currentSpinWin = 0;
1195
+ this.currentTumbleWin = 0;
1513
1196
  }
1514
1197
  /**
1515
- * Draws a board using specified reel stops.
1198
+ * Returns the accumulated win amount (as the bet multiplier) from all simulations.
1516
1199
  */
1517
- drawBoardWithForcedStops(reels, forcedStops) {
1518
- this.drawBoardMixed(reels, forcedStops);
1200
+ getCumulativeWins() {
1201
+ return this.cumulativeWins;
1519
1202
  }
1520
1203
  /**
1521
- * Draws a board using random reel stops.
1204
+ * Returns the accumulated win amount (as the bet multiplier) per spin type from all simulations.
1522
1205
  */
1523
- drawBoardWithRandomStops(reels) {
1524
- this.drawBoardMixed(reels);
1525
- }
1526
- drawBoardMixed(reels, forcedStops) {
1527
- this.resetReels();
1528
- const finalReelStops = Array.from(
1529
- { length: this.getCurrentGameMode().reelsAmount },
1530
- () => null
1531
- );
1532
- if (forcedStops) {
1533
- for (const [r, stopPos] of Object.entries(forcedStops)) {
1534
- const reelIdx = Number(r);
1535
- const symCount = this.getCurrentGameMode().symbolsPerReel[reelIdx];
1536
- finalReelStops[reelIdx] = stopPos - Math.round(this.state.rng.randomFloat(0, symCount - 1));
1537
- if (finalReelStops[reelIdx] < 0) {
1538
- finalReelStops[reelIdx] = reels[reelIdx].length + finalReelStops[reelIdx];
1539
- }
1540
- }
1541
- }
1542
- for (let i = 0; i < finalReelStops.length; i++) {
1543
- if (finalReelStops[i] === null) {
1544
- finalReelStops[i] = Math.floor(
1545
- this.state.rng.randomFloat(0, reels[i].length - 1)
1546
- );
1547
- }
1548
- }
1549
- for (let ridx = 0; ridx < this.getCurrentGameMode().reelsAmount; ridx++) {
1550
- const reelPos = finalReelStops[ridx];
1551
- if (this.config.padSymbols && this.config.padSymbols > 0) {
1552
- for (let p = this.config.padSymbols - 1; p >= 0; p--) {
1553
- const topPos = (reelPos - (p + 1)) % reels[ridx].length;
1554
- this.board.paddingTop[ridx].push(reels[ridx][topPos]);
1555
- const bottomPos = (reelPos + this.getCurrentGameMode().symbolsPerReel[ridx] + p) % reels[ridx].length;
1556
- this.board.paddingBottom[ridx].unshift(reels[ridx][bottomPos]);
1557
- }
1558
- }
1559
- for (let row = 0; row < this.getCurrentGameMode().symbolsPerReel[ridx]; row++) {
1560
- const symbol = reels[ridx][(reelPos + row) % reels[ridx].length];
1561
- if (!symbol) {
1562
- throw new Error(`Failed to get symbol at pos ${reelPos + row} on reel ${ridx}`);
1563
- }
1564
- this.board.reels[ridx][row] = symbol;
1565
- }
1566
- }
1567
- }
1568
- };
1569
-
1570
- // src/WinType.ts
1571
- var WinType = class {
1572
- payout;
1573
- winCombinations;
1574
- ctx;
1575
- wildSymbol;
1576
- constructor(opts) {
1577
- this.payout = 0;
1578
- this.winCombinations = [];
1579
- this.wildSymbol = opts?.wildSymbol;
1206
+ getCumulativeWinsPerSpinType() {
1207
+ return this.cumulativeWinsPerSpinType;
1580
1208
  }
1581
1209
  /**
1582
- * Sets the simulation context for this WinType instance.
1583
- *
1584
- * This gives the WinType access to the current board.
1210
+ * Returns the current win amount (as the bet multiplier) for the ongoing simulation.
1585
1211
  */
1586
- context(ctx) {
1587
- this.ctx = ctx;
1588
- return this;
1212
+ getCurrentWin() {
1213
+ return this.currentWin;
1589
1214
  }
1590
- ensureContext() {
1591
- if (!this.ctx) {
1592
- throw new Error("WinType context is not set. Call context(ctx) first.");
1593
- }
1215
+ /**
1216
+ * Returns the current spin win amount (as the bet multiplier) for the ongoing simulation.
1217
+ */
1218
+ getCurrentSpinWin() {
1219
+ return this.currentSpinWin;
1594
1220
  }
1595
1221
  /**
1596
- * Implementation of win evaluation logic. Sets `this.payout` and `this.winCombinations`.
1222
+ * Returns the current tumble win amount (as the bet multiplier) for the ongoing simulation.
1597
1223
  */
1598
- evaluateWins() {
1599
- this.ensureContext();
1600
- return this;
1224
+ getCurrentTumbleWin() {
1225
+ return this.currentTumbleWin;
1601
1226
  }
1602
1227
  /**
1603
- * Custom post-processing of wins, e.g. for handling multipliers.
1228
+ * Returns the current win amount (as the bet multiplier) per spin type for the ongoing simulation.
1604
1229
  */
1605
- postProcess(func) {
1606
- this.ensureContext();
1607
- const result = func(this, this.ctx);
1608
- this.payout = result.payout;
1609
- this.winCombinations = result.winCombinations;
1610
- return this;
1230
+ getCurrentWinPerSpinType() {
1231
+ return this.currentWinPerSpinType;
1611
1232
  }
1612
1233
  /**
1613
- * Returns the total payout and detailed win combinations.
1234
+ * Adds a win to `currentSpinWin` and `currentTumbleWin`.
1235
+ *
1236
+ * After each (free) spin, this amount should be added to `currentWinPerSpinType` via `confirmSpinWin()`
1614
1237
  */
1615
- getWins() {
1616
- return {
1617
- payout: this.payout,
1618
- winCombinations: this.winCombinations
1619
- };
1238
+ addTumbleWin(amount) {
1239
+ this.currentTumbleWin += amount;
1240
+ this.addSpinWin(amount);
1620
1241
  }
1621
- };
1622
-
1623
- // src/winTypes/LinesWinType.ts
1624
- var LinesWinType = class extends WinType {
1625
- lines;
1626
- constructor(opts) {
1627
- super(opts);
1628
- this.lines = opts.lines;
1629
- if (Object.keys(this.lines).length === 0) {
1630
- throw new Error("LinesWinType must have at least one line defined.");
1242
+ /**
1243
+ * Intended for internal use only.
1244
+ *
1245
+ * Resets the current win amounts to zero.
1246
+ */
1247
+ resetCurrentWin() {
1248
+ this.currentWin = 0;
1249
+ this.currentSpinWin = 0;
1250
+ this.currentTumbleWin = 0;
1251
+ for (const spinType of Object.keys(this.currentWinPerSpinType)) {
1252
+ this.currentWinPerSpinType[spinType] = 0;
1631
1253
  }
1632
1254
  }
1633
- validateConfig() {
1634
- const reelsAmount = this.ctx.getCurrentGameMode().reelsAmount;
1635
- const symsPerReel = this.ctx.getCurrentGameMode().symbolsPerReel;
1636
- for (const [lineNum, positions] of Object.entries(this.lines)) {
1637
- if (positions.length !== reelsAmount) {
1638
- throw new Error(
1639
- `Line ${lineNum} has ${positions.length} positions, but the current game mode has ${reelsAmount} reels.`
1640
- );
1641
- }
1642
- for (let i = 0; i < positions.length; i++) {
1643
- if (positions[i] < 0 || positions[i] >= symsPerReel[i]) {
1644
- throw new Error(
1645
- `Line ${lineNum} has an invalid position ${positions[i]} on reel ${i}. Valid range is 0 to ${symsPerReel[i] - 1}.`
1646
- );
1647
- }
1648
- }
1255
+ /**
1256
+ * Intended for internal use only.
1257
+ *
1258
+ * Adds current wins to cumulative wins and resets current wins to zero.
1259
+ */
1260
+ confirmWins(ctx) {
1261
+ function process2(number) {
1262
+ return Math.round(Math.min(number, ctx.config.maxWinX) * 100) / 100;
1649
1263
  }
1650
- const firstLine = Math.min(...Object.keys(this.lines).map(Number));
1651
- if (firstLine !== 1) {
1264
+ this.currentWin = process2(this.currentWin);
1265
+ this.cumulativeWins += this.currentWin;
1266
+ let spinTypeWins = 0;
1267
+ for (const spinType of Object.keys(this.currentWinPerSpinType)) {
1268
+ const st = spinType;
1269
+ const spinTypeWin = process2(this.currentWinPerSpinType[st]);
1270
+ this.cumulativeWinsPerSpinType[st] += spinTypeWin;
1271
+ spinTypeWins += spinTypeWin;
1272
+ }
1273
+ if (process2(spinTypeWins) !== this.currentWin) {
1652
1274
  throw new Error(
1653
- `Lines must start from 1. Found line ${firstLine} as the first line.`
1275
+ `Inconsistent wallet state: currentWin (${this.currentWin}) does not equal spinTypeWins (${spinTypeWins}).`
1654
1276
  );
1655
1277
  }
1278
+ this.resetCurrentWin();
1656
1279
  }
1657
- isWild(symbol) {
1658
- return !!this.wildSymbol && symbol.compare(this.wildSymbol);
1280
+ /**
1281
+ * Intended for internal use only.
1282
+ *
1283
+ * Transfers the win data from the given wallet to the calling book.
1284
+ */
1285
+ writePayoutToBook(ctx) {
1286
+ function process2(number) {
1287
+ return Math.round(Math.min(number, ctx.config.maxWinX) * 100) / 100;
1288
+ }
1289
+ const wallet = ctx.services.wallet._getWallet();
1290
+ const book = ctx.services.data._getBook();
1291
+ book.payout = Math.round(process2(wallet.getCurrentWin()) * 100);
1292
+ book.basegameWins = process2(
1293
+ wallet.getCurrentWinPerSpinType()[SPIN_TYPE.BASE_GAME] || 0
1294
+ );
1295
+ book.freespinsWins = process2(
1296
+ wallet.getCurrentWinPerSpinType()[SPIN_TYPE.FREE_SPINS] || 0
1297
+ );
1659
1298
  }
1660
- evaluateWins() {
1661
- this.ensureContext();
1662
- this.validateConfig();
1663
- const lineWins = [];
1664
- let payout = 0;
1665
- const reels = this.ctx.board.reels;
1666
- const reelsAmount = this.ctx.getCurrentGameMode().reelsAmount;
1667
- for (const [lineNumStr, lineDef] of Object.entries(this.lines)) {
1668
- const lineNum = Number(lineNumStr);
1669
- let baseSymbol = null;
1670
- let leadingWilds = 0;
1671
- const chain = [];
1672
- const details = [];
1673
- for (let ridx = 0; ridx < reelsAmount; ridx++) {
1674
- const rowIdx = lineDef[ridx];
1675
- const sym = reels[ridx][rowIdx];
1676
- if (!sym) throw new Error("Encountered an invalid symbol while evaluating wins.");
1677
- const wild = this.isWild(sym);
1678
- if (ridx === 0) {
1679
- chain.push(sym);
1680
- details.push({ reelIndex: ridx, posIndex: rowIdx, symbol: sym, isWild: wild });
1681
- if (wild) leadingWilds++;
1682
- else baseSymbol = sym;
1683
- continue;
1684
- }
1685
- if (wild) {
1686
- chain.push(sym);
1687
- details.push({
1688
- reelIndex: ridx,
1689
- posIndex: rowIdx,
1690
- symbol: sym,
1691
- isWild: true,
1692
- substitutedFor: baseSymbol || void 0
1693
- });
1694
- continue;
1695
- }
1696
- if (!baseSymbol) {
1697
- baseSymbol = sym;
1698
- chain.push(sym);
1699
- details.push({ reelIndex: ridx, posIndex: rowIdx, symbol: sym, isWild: false });
1700
- continue;
1701
- }
1702
- if (sym.id === baseSymbol.id) {
1703
- chain.push(sym);
1704
- details.push({ reelIndex: ridx, posIndex: rowIdx, symbol: sym, isWild: false });
1705
- continue;
1706
- }
1707
- break;
1708
- }
1709
- if (chain.length === 0) continue;
1710
- const allWild = chain.every((s) => this.isWild(s));
1711
- const wildRepresentative = this.wildSymbol instanceof GameSymbol ? this.wildSymbol : null;
1712
- const len = chain.length;
1713
- let bestPayout = 0;
1714
- let bestType = null;
1715
- let payingSymbol = null;
1716
- if (baseSymbol?.pays && baseSymbol.pays[len]) {
1717
- bestPayout = baseSymbol.pays[len];
1718
- bestType = "substituted";
1719
- payingSymbol = baseSymbol;
1720
- }
1721
- if (allWild && wildRepresentative?.pays && wildRepresentative.pays[len]) {
1722
- const wildPay = wildRepresentative.pays[len];
1723
- if (wildPay > bestPayout) {
1724
- bestPayout = wildPay;
1725
- bestType = "pure-wild";
1726
- payingSymbol = wildRepresentative;
1727
- }
1728
- }
1729
- if (!bestPayout || !bestType || !payingSymbol) continue;
1730
- const minLen = payingSymbol.pays ? Math.min(...Object.keys(payingSymbol.pays).map(Number)) : Infinity;
1731
- if (len < minLen) continue;
1732
- const wildCount = details.filter((d) => d.isWild).length;
1733
- const nonWildCount = len - wildCount;
1734
- lineWins.push({
1735
- lineNumber: lineNum,
1736
- kind: len,
1737
- payout: bestPayout,
1738
- symbol: payingSymbol,
1739
- winType: bestType,
1740
- substitutedBaseSymbol: bestType === "pure-wild" ? null : baseSymbol,
1741
- symbols: details,
1742
- stats: { wildCount, nonWildCount, leadingWilds }
1743
- });
1744
- payout += bestPayout;
1299
+ /**
1300
+ * Intended for internal use only.
1301
+ */
1302
+ serialize() {
1303
+ return {
1304
+ cumulativeWins: this.cumulativeWins,
1305
+ cumulativeWinsPerSpinType: this.cumulativeWinsPerSpinType,
1306
+ currentWin: this.currentWin,
1307
+ currentWinPerSpinType: this.currentWinPerSpinType,
1308
+ currentSpinWin: this.currentSpinWin,
1309
+ currentTumbleWin: this.currentTumbleWin
1310
+ };
1311
+ }
1312
+ /**
1313
+ * Intended for internal use only.
1314
+ */
1315
+ merge(wallet) {
1316
+ this.cumulativeWins += wallet.getCumulativeWins();
1317
+ const otherWinsPerSpinType = wallet.getCumulativeWinsPerSpinType();
1318
+ for (const spinType of Object.keys(this.cumulativeWinsPerSpinType)) {
1319
+ this.cumulativeWinsPerSpinType[spinType] += otherWinsPerSpinType[spinType] || 0;
1745
1320
  }
1746
- for (const win of lineWins) {
1747
- this.ctx.recordSymbolOccurrence({
1748
- kind: win.kind,
1749
- symbolId: win.symbol.id,
1750
- spinType: this.ctx.state.currentSpinType
1751
- });
1321
+ }
1322
+ /**
1323
+ * Intended for internal use only.
1324
+ */
1325
+ mergeSerialized(data) {
1326
+ this.cumulativeWins += data.cumulativeWins;
1327
+ for (const spinType of Object.keys(this.cumulativeWinsPerSpinType)) {
1328
+ this.cumulativeWinsPerSpinType[spinType] += data.cumulativeWinsPerSpinType[spinType] || 0;
1329
+ }
1330
+ this.currentWin += data.currentWin;
1331
+ this.currentSpinWin += data.currentSpinWin;
1332
+ this.currentTumbleWin += data.currentTumbleWin;
1333
+ for (const spinType of Object.keys(this.currentWinPerSpinType)) {
1334
+ this.currentWinPerSpinType[spinType] += data.currentWinPerSpinType[spinType] || 0;
1752
1335
  }
1753
- this.payout = payout;
1754
- this.winCombinations = lineWins;
1755
- return this;
1756
1336
  }
1757
1337
  };
1758
1338
 
1759
- // src/winTypes/ClusterWinType.ts
1760
- var ClusterWinType = class extends WinType {
1761
- };
1762
-
1763
- // src/winTypes/ManywaysWinType.ts
1764
- var ManywaysWinType = class extends WinType {
1765
- };
1766
-
1767
- // src/optimizer/OptimizationConditions.ts
1768
- import assert2 from "assert";
1769
- var OptimizationConditions = class {
1770
- rtp;
1771
- avgWin;
1772
- hitRate;
1773
- searchRange;
1774
- forceSearch;
1775
- priority;
1776
- constructor(opts) {
1777
- let { rtp, avgWin, hitRate, searchConditions, priority } = opts;
1778
- if (rtp == void 0 || rtp === "x") {
1779
- assert2(avgWin !== void 0 && hitRate !== void 0, "If RTP is not specified, hit-rate (hr) and average win amount (av_win) must be given.");
1780
- rtp = Math.round(avgWin / Number(hitRate) * 1e5) / 1e5;
1781
- }
1782
- let noneCount = 0;
1783
- for (const val of [rtp, avgWin, hitRate]) {
1784
- if (val === void 0) noneCount++;
1339
+ // src/simulation/index.ts
1340
+ var completedSimulations = 0;
1341
+ var TEMP_FILENAME = "__temp_compiled_src_IGNORE.js";
1342
+ var Simulation = class {
1343
+ gameConfigOpts;
1344
+ gameConfig;
1345
+ simRunsAmount;
1346
+ concurrency;
1347
+ debug = false;
1348
+ actualSims = 0;
1349
+ library;
1350
+ recorder;
1351
+ wallet;
1352
+ constructor(opts, gameConfigOpts) {
1353
+ this.gameConfig = createGameConfig(gameConfigOpts);
1354
+ this.gameConfigOpts = gameConfigOpts;
1355
+ this.simRunsAmount = opts.simRunsAmount || {};
1356
+ this.concurrency = (opts.concurrency || 6) >= 2 ? opts.concurrency || 6 : 2;
1357
+ this.library = /* @__PURE__ */ new Map();
1358
+ this.recorder = new Recorder();
1359
+ this.wallet = new Wallet();
1360
+ const gameModeKeys = Object.keys(this.gameConfig.gameModes);
1361
+ assert6(
1362
+ Object.values(this.gameConfig.gameModes).map((m) => gameModeKeys.includes(m.name)).every((v) => v === true),
1363
+ "Game mode name must match its key in the gameModes object."
1364
+ );
1365
+ if (isMainThread) {
1366
+ this.preprocessFiles();
1785
1367
  }
1786
- assert2(noneCount <= 1, "Invalid combination of optimization conditions.");
1787
- this.searchRange = [-1, -1];
1788
- this.forceSearch = {};
1789
- if (typeof searchConditions === "number") {
1790
- this.searchRange = [searchConditions, searchConditions];
1368
+ }
1369
+ async runSimulation(opts) {
1370
+ const debug = opts.debug || false;
1371
+ this.debug = debug;
1372
+ const gameModesToSimulate = Object.keys(this.simRunsAmount);
1373
+ const configuredGameModes = Object.keys(this.gameConfig.gameModes);
1374
+ if (gameModesToSimulate.length === 0) {
1375
+ throw new Error("No game modes configured for simulation.");
1791
1376
  }
1792
- if (Array.isArray(searchConditions)) {
1793
- if (searchConditions[0] > searchConditions[1] || searchConditions.length !== 2) {
1794
- throw new Error("Invalid searchConditions range.");
1377
+ this.generateReelsetFiles();
1378
+ if (isMainThread) {
1379
+ const debugDetails = {};
1380
+ for (const mode of gameModesToSimulate) {
1381
+ completedSimulations = 0;
1382
+ this.wallet = new Wallet();
1383
+ this.library = /* @__PURE__ */ new Map();
1384
+ this.recorder = new Recorder();
1385
+ debugDetails[mode] = {};
1386
+ console.log(`
1387
+ Simulating game mode: ${mode}`);
1388
+ console.time(mode);
1389
+ const runs = this.simRunsAmount[mode] || 0;
1390
+ if (runs <= 0) continue;
1391
+ if (!configuredGameModes.includes(mode)) {
1392
+ throw new Error(
1393
+ `Tried to simulate game mode "${mode}", but it's not configured in the game config.`
1394
+ );
1395
+ }
1396
+ const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1397
+ await this.spawnWorkersForGameMode({ mode, simNumsToCriteria });
1398
+ createDirIfNotExists(
1399
+ path.join(process.cwd(), this.gameConfig.outputDir, "optimization_files")
1400
+ );
1401
+ createDirIfNotExists(
1402
+ path.join(process.cwd(), this.gameConfig.outputDir, "publish_files")
1403
+ );
1404
+ this.writeLookupTableCSV(mode);
1405
+ this.writeLookupTableSegmentedCSV(mode);
1406
+ this.writeRecords(mode);
1407
+ await this.writeBooksJson(mode);
1408
+ this.writeIndexJson();
1409
+ debugDetails[mode].rtp = this.wallet.getCumulativeWins() / (runs * this.gameConfig.gameModes[mode].cost);
1410
+ debugDetails[mode].wins = this.wallet.getCumulativeWins();
1411
+ debugDetails[mode].winsPerSpinType = this.wallet.getCumulativeWinsPerSpinType();
1412
+ console.timeEnd(mode);
1795
1413
  }
1796
- this.searchRange = searchConditions;
1797
- }
1798
- if (typeof searchConditions === "object" && !Array.isArray(searchConditions)) {
1799
- this.searchRange = [-1, -1];
1800
- this.forceSearch = searchConditions;
1801
- }
1802
- this.rtp = rtp;
1803
- this.avgWin = avgWin;
1804
- this.hitRate = hitRate;
1805
- this.priority = priority;
1806
- }
1807
- getRtp() {
1808
- return this.rtp;
1809
- }
1810
- getAvgWin() {
1811
- return this.avgWin;
1812
- }
1813
- getHitRate() {
1814
- return this.hitRate;
1815
- }
1816
- getSearchRange() {
1817
- return this.searchRange;
1818
- }
1819
- getForceSearch() {
1820
- return this.forceSearch;
1821
- }
1822
- };
1823
-
1824
- // src/optimizer/OptimizationScaling.ts
1825
- var OptimizationScaling = class {
1826
- config;
1827
- constructor(opts) {
1828
- this.config = opts;
1829
- }
1830
- getConfig() {
1831
- return this.config;
1832
- }
1833
- };
1834
-
1835
- // src/optimizer/OptimizationParameters.ts
1836
- var OptimizationParameters = class _OptimizationParameters {
1837
- parameters;
1838
- constructor(opts) {
1839
- this.parameters = {
1840
- ..._OptimizationParameters.DEFAULT_PARAMETERS,
1841
- ...opts
1842
- };
1843
- }
1844
- static DEFAULT_PARAMETERS = {
1845
- numShowPigs: 5e3,
1846
- numPigsPerFence: 1e4,
1847
- threadsFenceConstruction: 16,
1848
- threadsShowConstruction: 16,
1849
- testSpins: [50, 100, 200],
1850
- testSpinsWeights: [0.3, 0.4, 0.3],
1851
- simulationTrials: 5e3,
1852
- graphIndexes: [],
1853
- run1000Batch: false,
1854
- minMeanToMedian: 4,
1855
- maxMeanToMedian: 8,
1856
- pmbRtp: 1,
1857
- scoreType: "rtp"
1858
- };
1859
- getParameters() {
1860
- return this.parameters;
1861
- }
1862
- };
1863
-
1864
- // src/Simulation.ts
1865
- import fs3 from "fs";
1866
- import path2 from "path";
1867
- import assert3 from "assert";
1868
- import zlib from "zlib";
1869
- import { buildSync } from "esbuild";
1870
- import { Worker, isMainThread as isMainThread2, parentPort, workerData } from "worker_threads";
1871
- var completedSimulations = 0;
1872
- var TEMP_FILENAME = "__temp_compiled_src_IGNORE.js";
1873
- var Simulation = class _Simulation {
1874
- gameConfigOpts;
1875
- gameConfig;
1876
- simRunsAmount;
1877
- concurrency;
1878
- wallet;
1879
- library;
1880
- records;
1881
- debug = false;
1882
- constructor(opts, gameConfigOpts) {
1883
- this.gameConfig = new GameConfig(gameConfigOpts);
1884
- this.gameConfigOpts = gameConfigOpts;
1885
- this.simRunsAmount = opts.simRunsAmount || {};
1886
- this.concurrency = (opts.concurrency || 6) >= 2 ? opts.concurrency || 6 : 2;
1887
- this.wallet = new Wallet();
1888
- this.library = /* @__PURE__ */ new Map();
1889
- this.records = [];
1890
- const gameModeKeys = Object.keys(this.gameConfig.config.gameModes);
1891
- assert3(
1892
- Object.values(this.gameConfig.config.gameModes).map((m) => gameModeKeys.includes(m.name)).every((v) => v === true),
1893
- "Game mode name must match its key in the gameModes object."
1894
- );
1895
- if (isMainThread2) {
1896
- this.preprocessFiles();
1897
- }
1898
- }
1899
- async runSimulation(opts) {
1900
- const debug = opts.debug || false;
1901
- this.debug = debug;
1902
- const gameModesToSimulate = Object.keys(this.simRunsAmount);
1903
- const configuredGameModes = Object.keys(this.gameConfig.config.gameModes);
1904
- if (gameModesToSimulate.length === 0) {
1905
- throw new Error("No game modes configured for simulation.");
1906
- }
1907
- this.gameConfig.generateReelsetFiles();
1908
- if (isMainThread2) {
1909
- const debugDetails = {};
1910
- for (const mode of gameModesToSimulate) {
1911
- completedSimulations = 0;
1912
- this.wallet = new Wallet();
1913
- this.library = /* @__PURE__ */ new Map();
1914
- debugDetails[mode] = {};
1915
- console.log(`
1916
- Simulating game mode: ${mode}`);
1917
- console.time(mode);
1918
- const runs = this.simRunsAmount[mode] || 0;
1919
- if (runs <= 0) continue;
1920
- if (!configuredGameModes.includes(mode)) {
1921
- throw new Error(
1922
- `Tried to simulate game mode "${mode}", but it's not configured in the game config.`
1923
- );
1924
- }
1925
- const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1926
- await this.spawnWorkersForGameMode({ mode, simNumsToCriteria });
1927
- createDirIfNotExists(
1928
- path2.join(
1929
- process.cwd(),
1930
- this.gameConfig.config.outputDir,
1931
- "optimization_files"
1932
- )
1933
- );
1934
- createDirIfNotExists(
1935
- path2.join(process.cwd(), this.gameConfig.config.outputDir, "publish_files")
1936
- );
1937
- _Simulation.writeLookupTableCSV({
1938
- gameMode: mode,
1939
- library: this.library,
1940
- gameConfig: this.gameConfig.config
1941
- });
1942
- _Simulation.writeLookupTableSegmentedCSV({
1943
- gameMode: mode,
1944
- library: this.library,
1945
- gameConfig: this.gameConfig.config
1946
- });
1947
- _Simulation.writeRecords({
1948
- gameMode: mode,
1949
- records: this.records,
1950
- gameConfig: this.gameConfig.config,
1951
- debug: this.debug
1952
- });
1953
- await _Simulation.writeBooksJson({
1954
- gameMode: mode,
1955
- library: this.library,
1956
- gameConfig: this.gameConfig.config
1957
- });
1958
- _Simulation.writeIndexJson({
1959
- gameConfig: this.gameConfig.config
1960
- });
1961
- debugDetails[mode].rtp = this.wallet.getCumulativeWins() / (runs * this.gameConfig.config.gameModes[mode].cost);
1962
- debugDetails[mode].wins = this.wallet.getCumulativeWins();
1963
- debugDetails[mode].winsPerSpinType = this.wallet.getCumulativeWinsPerSpinType();
1964
- console.timeEnd(mode);
1965
- }
1966
- console.log("\n=== SIMULATION SUMMARY ===");
1967
- console.table(debugDetails);
1414
+ console.log("\n=== SIMULATION SUMMARY ===");
1415
+ console.table(debugDetails);
1968
1416
  }
1969
1417
  let desiredSims = 0;
1970
1418
  let actualSims = 0;
1971
1419
  const criteriaToRetries = {};
1972
- if (!isMainThread2) {
1420
+ if (!isMainThread) {
1973
1421
  const { mode, simStart, simEnd, index } = workerData;
1974
1422
  const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1975
1423
  for (let simId = simStart; simId <= simEnd; simId++) {
1976
1424
  if (this.debug) desiredSims++;
1977
1425
  const criteria = simNumsToCriteria[simId] || "N/A";
1978
- const ctx = new SimulationContext(this.gameConfigOpts);
1979
1426
  if (!criteriaToRetries[criteria]) {
1980
1427
  criteriaToRetries[criteria] = 0;
1981
1428
  }
1982
- ctx.runSingleSimulation({ simId, mode, criteria, index });
1429
+ this.runSingleSimulation({ simId, mode, criteria, index });
1983
1430
  if (this.debug) {
1984
- criteriaToRetries[criteria] += ctx.actualSims - 1;
1985
- actualSims += ctx.actualSims;
1431
+ criteriaToRetries[criteria] += this.actualSims - 1;
1432
+ actualSims += this.actualSims;
1986
1433
  }
1987
1434
  }
1988
1435
  if (this.debug) {
@@ -2005,7 +1452,7 @@ Simulating game mode: ${mode}`);
2005
1452
  await Promise.all(
2006
1453
  simRangesPerChunk.map(([simStart, simEnd], index) => {
2007
1454
  return this.callWorker({
2008
- basePath: this.gameConfig.config.outputDir,
1455
+ basePath: this.gameConfig.outputDir,
2009
1456
  mode,
2010
1457
  simStart,
2011
1458
  simEnd,
@@ -2028,7 +1475,7 @@ Simulating game mode: ${mode}`);
2028
1475
  }
2029
1476
  }
2030
1477
  return new Promise((resolve, reject) => {
2031
- const scriptPath = path2.join(process.cwd(), basePath, TEMP_FILENAME);
1478
+ const scriptPath = path.join(process.cwd(), basePath, TEMP_FILENAME);
2032
1479
  const worker = new Worker(scriptPath, {
2033
1480
  workerData: {
2034
1481
  mode,
@@ -2045,7 +1492,7 @@ Simulating game mode: ${mode}`);
2045
1492
  logArrowProgress(completedSimulations, totalSims);
2046
1493
  }
2047
1494
  const book = Book.fromSerialized(msg.book);
2048
- this.library.set(book.id.toString(), book);
1495
+ this.library.set(book.id, book);
2049
1496
  this.wallet.mergeSerialized(msg.wallet);
2050
1497
  this.mergeRecords(msg.records);
2051
1498
  } else if (msg.type === "done") {
@@ -2063,60 +1510,135 @@ Simulating game mode: ${mode}`);
2063
1510
  });
2064
1511
  });
2065
1512
  }
1513
+ /**
1514
+ * Will run a single simulation until the specified criteria is met.
1515
+ */
1516
+ runSingleSimulation(opts) {
1517
+ const { simId, mode, criteria } = opts;
1518
+ const ctx = createGameContext({
1519
+ config: this.gameConfig
1520
+ });
1521
+ ctx.state.currentGameMode = mode;
1522
+ ctx.state.currentSimulationId = simId;
1523
+ ctx.state.isCriteriaMet = false;
1524
+ const resultSet = ctx.services.game.getResultSetByCriteria(
1525
+ ctx.state.currentGameMode,
1526
+ criteria
1527
+ );
1528
+ while (!ctx.state.isCriteriaMet) {
1529
+ this.actualSims++;
1530
+ this.resetSimulation(ctx);
1531
+ ctx.state.currentResultSet = resultSet;
1532
+ this.handleGameFlow(ctx);
1533
+ if (resultSet.meetsCriteria(ctx)) {
1534
+ ctx.state.isCriteriaMet = true;
1535
+ }
1536
+ }
1537
+ ctx.services.wallet._getWallet().writePayoutToBook(ctx);
1538
+ ctx.services.wallet._getWallet().confirmWins(ctx);
1539
+ if (ctx.services.data._getBook().payout >= ctx.config.maxWinX) {
1540
+ ctx.state.triggeredMaxWin = true;
1541
+ }
1542
+ ctx.services.data.record({
1543
+ criteria: resultSet.criteria
1544
+ });
1545
+ ctx.config.hooks.onSimulationAccepted?.(ctx);
1546
+ this.confirmRecords(ctx);
1547
+ parentPort?.postMessage({
1548
+ type: "complete",
1549
+ simId,
1550
+ book: ctx.services.data._getBook().serialize(),
1551
+ wallet: ctx.services.wallet._getWallet().serialize(),
1552
+ records: ctx.services.data._getRecords()
1553
+ });
1554
+ }
1555
+ /**
1556
+ * If a simulation does not meet the required criteria, reset the state to run it again.
1557
+ *
1558
+ * This also runs once before each simulation to ensure a clean state.
1559
+ */
1560
+ resetSimulation(ctx) {
1561
+ this.resetState(ctx);
1562
+ ctx.services.board.resetBoard();
1563
+ ctx.services.data._setRecorder(new Recorder());
1564
+ ctx.services.wallet._setWallet(new Wallet());
1565
+ ctx.services.data._setBook(
1566
+ new Book({
1567
+ id: ctx.state.currentSimulationId,
1568
+ criteria: ctx.state.currentResultSet.criteria
1569
+ })
1570
+ );
1571
+ }
1572
+ resetState(ctx) {
1573
+ ctx.services.rng.setSeedIfDifferent(ctx.state.currentSimulationId);
1574
+ ctx.state.currentSpinType = SPIN_TYPE.BASE_GAME;
1575
+ ctx.state.currentFreespinAmount = 0;
1576
+ ctx.state.totalFreespinAmount = 0;
1577
+ ctx.state.triggeredMaxWin = false;
1578
+ ctx.state.triggeredFreespins = false;
1579
+ ctx.state.userData = ctx.config.userState || {};
1580
+ }
1581
+ /**
1582
+ * Contains and executes the entire game logic:
1583
+ * - Drawing the board
1584
+ * - Evaluating wins
1585
+ * - Updating wallet
1586
+ * - Handling free spins
1587
+ * - Recording events
1588
+ *
1589
+ * You can customize the game flow by implementing the `onHandleGameFlow` hook in the game configuration.
1590
+ */
1591
+ handleGameFlow(ctx) {
1592
+ this.gameConfig.hooks.onHandleGameFlow(ctx);
1593
+ }
2066
1594
  /**
2067
1595
  * Creates a CSV file in the format "simulationId,weight,payout".
2068
1596
  *
2069
1597
  * `weight` defaults to 1.
2070
1598
  */
2071
- static writeLookupTableCSV(opts) {
2072
- const { gameMode, library, gameConfig } = opts;
1599
+ writeLookupTableCSV(gameMode) {
2073
1600
  const rows = [];
2074
- for (const [bookId, book] of library.entries()) {
2075
- rows.push(`${book.id},1,${Math.round(book.getPayout())}`);
1601
+ for (const [bookId, book] of this.library.entries()) {
1602
+ rows.push(`${book.id},1,${Math.round(book.payout)}`);
2076
1603
  }
2077
1604
  rows.sort((a, b) => Number(a.split(",")[0]) - Number(b.split(",")[0]));
2078
1605
  let outputFileName = `lookUpTable_${gameMode}.csv`;
2079
- let outputFilePath = path2.join(gameConfig.outputDir, outputFileName);
1606
+ let outputFilePath = path.join(this.gameConfig.outputDir, outputFileName);
2080
1607
  writeFile(outputFilePath, rows.join("\n"));
2081
1608
  outputFileName = `lookUpTable_${gameMode}_0.csv`;
2082
- outputFilePath = path2.join(gameConfig.outputDir, outputFileName);
1609
+ outputFilePath = path.join(this.gameConfig.outputDir, "publish_files", outputFileName);
2083
1610
  writeFile(outputFilePath, rows.join("\n"));
2084
1611
  return outputFilePath;
2085
1612
  }
2086
1613
  /**
2087
1614
  * Creates a CSV file in the format "simulationId,criteria,payoutBase,payoutFreespins".
2088
1615
  */
2089
- static writeLookupTableSegmentedCSV(opts) {
2090
- const { gameMode, library, gameConfig } = opts;
1616
+ writeLookupTableSegmentedCSV(gameMode) {
2091
1617
  const rows = [];
2092
- for (const [bookId, book] of library.entries()) {
2093
- rows.push(
2094
- `${book.id},${book.criteria},${book.getBasegameWins()},${book.getFreespinsWins()}`
2095
- );
1618
+ for (const [bookId, book] of this.library.entries()) {
1619
+ rows.push(`${book.id},${book.criteria},${book.basegameWins},${book.freespinsWins}`);
2096
1620
  }
2097
1621
  rows.sort((a, b) => Number(a.split(",")[0]) - Number(b.split(",")[0]));
2098
1622
  const outputFileName = `lookUpTableSegmented_${gameMode}.csv`;
2099
- const outputFilePath = path2.join(gameConfig.outputDir, outputFileName);
1623
+ const outputFilePath = path.join(this.gameConfig.outputDir, outputFileName);
2100
1624
  writeFile(outputFilePath, rows.join("\n"));
2101
1625
  return outputFilePath;
2102
1626
  }
2103
- static writeRecords(opts) {
2104
- const { gameMode, fileNameWithoutExtension, records, gameConfig, debug } = opts;
2105
- const outputFileName = fileNameWithoutExtension ? `${fileNameWithoutExtension}.json` : `force_record_${gameMode}.json`;
2106
- const outputFilePath = path2.join(gameConfig.outputDir, outputFileName);
2107
- writeFile(outputFilePath, JSON.stringify(records, null, 2));
2108
- if (debug) _Simulation.logSymbolOccurrences(records);
1627
+ writeRecords(gameMode) {
1628
+ const outputFileName = `force_record_${gameMode}.json`;
1629
+ const outputFilePath = path.join(this.gameConfig.outputDir, outputFileName);
1630
+ writeFile(outputFilePath, JSON.stringify(this.recorder.records, null, 2));
1631
+ if (this.debug) this.logSymbolOccurrences();
2109
1632
  return outputFilePath;
2110
1633
  }
2111
- static writeIndexJson(opts) {
2112
- const { gameConfig } = opts;
2113
- const outputFilePath = path2.join(
1634
+ writeIndexJson() {
1635
+ const outputFilePath = path.join(
2114
1636
  process.cwd(),
2115
- gameConfig.outputDir,
1637
+ this.gameConfig.outputDir,
2116
1638
  "publish_files",
2117
1639
  "index.json"
2118
1640
  );
2119
- const modes = Object.entries(gameConfig.gameModes).map(([mode, modeConfig]) => ({
1641
+ const modes = Object.entries(this.gameConfig.gameModes).map(([mode, modeConfig]) => ({
2120
1642
  name: mode,
2121
1643
  cost: modeConfig.cost,
2122
1644
  events: `books_${mode}.jsonl.zst`,
@@ -2124,30 +1646,29 @@ Simulating game mode: ${mode}`);
2124
1646
  }));
2125
1647
  writeFile(outputFilePath, JSON.stringify({ modes }, null, 2));
2126
1648
  }
2127
- static async writeBooksJson(opts) {
2128
- const { gameMode, fileNameWithoutExtension, library, gameConfig } = opts;
2129
- const outputFileName = fileNameWithoutExtension ? `${fileNameWithoutExtension}.jsonl` : `books_${gameMode}.jsonl`;
2130
- const outputFilePath = path2.join(gameConfig.outputDir, outputFileName);
2131
- const books = Array.from(library.values()).map((b) => b.serialize()).map((b) => ({
1649
+ async writeBooksJson(gameMode) {
1650
+ const outputFileName = `books_${gameMode}.jsonl`;
1651
+ const outputFilePath = path.join(this.gameConfig.outputDir, outputFileName);
1652
+ const books = Array.from(this.library.values()).map((b) => b.serialize()).map((b) => ({
2132
1653
  id: b.id,
2133
1654
  payoutMultiplier: b.payout,
2134
1655
  events: b.events
2135
1656
  })).sort((a, b) => a.id - b.id);
2136
1657
  const contents = JSONL.stringify(books);
2137
1658
  writeFile(outputFilePath, contents);
2138
- const compressedFileName = fileNameWithoutExtension ? `${fileNameWithoutExtension}.jsonl.zst` : `books_${gameMode}.jsonl.zst`;
2139
- const compressedFilePath = path2.join(
1659
+ const compressedFileName = `books_${gameMode}.jsonl.zst`;
1660
+ const compressedFilePath = path.join(
2140
1661
  process.cwd(),
2141
- gameConfig.outputDir,
1662
+ this.gameConfig.outputDir,
2142
1663
  "publish_files",
2143
1664
  compressedFileName
2144
1665
  );
2145
- fs3.rmSync(compressedFilePath, { force: true });
1666
+ fs2.rmSync(compressedFilePath, { force: true });
2146
1667
  const compressed = zlib.zstdCompressSync(Buffer.from(contents));
2147
- fs3.writeFileSync(compressedFilePath, compressed);
1668
+ fs2.writeFileSync(compressedFilePath, compressed);
2148
1669
  }
2149
- static logSymbolOccurrences(records) {
2150
- const validRecords = records.filter(
1670
+ logSymbolOccurrences() {
1671
+ const validRecords = this.recorder.records.filter(
2151
1672
  (r) => r.search.some((s) => s.name === "symbolId") && r.search.some((s) => s.name === "kind")
2152
1673
  );
2153
1674
  const structuredRecords = validRecords.map((r) => {
@@ -2175,13 +1696,13 @@ Simulating game mode: ${mode}`);
2175
1696
  * Compiles user configured game to JS for use in different Node processes
2176
1697
  */
2177
1698
  preprocessFiles() {
2178
- const builtFilePath = path2.join(this.gameConfig.config.outputDir, TEMP_FILENAME);
2179
- fs3.rmSync(builtFilePath, { force: true });
1699
+ const builtFilePath = path.join(this.gameConfig.outputDir, TEMP_FILENAME);
1700
+ fs2.rmSync(builtFilePath, { force: true });
2180
1701
  buildSync({
2181
1702
  entryPoints: [process.cwd()],
2182
1703
  bundle: true,
2183
1704
  platform: "node",
2184
- outfile: path2.join(this.gameConfig.config.outputDir, TEMP_FILENAME),
1705
+ outfile: path.join(this.gameConfig.outputDir, TEMP_FILENAME),
2185
1706
  external: ["esbuild"]
2186
1707
  });
2187
1708
  }
@@ -2201,7 +1722,7 @@ Simulating game mode: ${mode}`);
2201
1722
  }
2202
1723
  mergeRecords(otherRecords) {
2203
1724
  for (const otherRecord of otherRecords) {
2204
- let record = this.records.find((r) => {
1725
+ let record = this.recorder.records.find((r) => {
2205
1726
  if (r.search.length !== otherRecord.search.length) return false;
2206
1727
  for (let i = 0; i < r.search.length; i++) {
2207
1728
  if (r.search[i].name !== otherRecord.search[i].name) return false;
@@ -2215,7 +1736,7 @@ Simulating game mode: ${mode}`);
2215
1736
  timesTriggered: 0,
2216
1737
  bookIds: []
2217
1738
  };
2218
- this.records.push(record);
1739
+ this.recorder.records.push(record);
2219
1740
  }
2220
1741
  record.timesTriggered += otherRecord.timesTriggered;
2221
1742
  for (const bookId of otherRecord.bookIds) {
@@ -2225,76 +1746,59 @@ Simulating game mode: ${mode}`);
2225
1746
  }
2226
1747
  }
2227
1748
  }
2228
- };
2229
- var SimulationContext = class extends Board {
2230
- constructor(opts) {
2231
- super(opts);
2232
- }
2233
- actualSims = 0;
2234
1749
  /**
2235
- * Will run a single simulation until the specified criteria is met.
1750
+ * Generates reelset CSV files for all game modes.
2236
1751
  */
2237
- runSingleSimulation(opts) {
2238
- const { simId, mode, criteria, index } = opts;
2239
- this.state.currentGameMode = mode;
2240
- this.state.currentSimulationId = simId;
2241
- this.state.isCriteriaMet = false;
2242
- const resultSet = this.getResultSetByCriteria(this.state.currentGameMode, criteria);
2243
- while (!this.state.isCriteriaMet) {
2244
- this.actualSims++;
2245
- this.resetSimulation();
2246
- this.state.currentResultSet = resultSet;
2247
- this.state.book.criteria = resultSet.criteria;
2248
- this.handleGameFlow();
2249
- if (resultSet.meetsCriteria(this)) {
2250
- this.state.isCriteriaMet = true;
1752
+ generateReelsetFiles() {
1753
+ for (const mode of Object.values(this.gameConfig.gameModes)) {
1754
+ if (mode.reelSets && mode.reelSets.length > 0) {
1755
+ for (const reelSet of Object.values(mode.reelSets)) {
1756
+ reelSet.associatedGameModeName = mode.name;
1757
+ reelSet.generateReels(this);
1758
+ }
1759
+ } else {
1760
+ throw new Error(
1761
+ `Game mode "${mode.name}" has no reel sets defined. Cannot generate reelset files.`
1762
+ );
2251
1763
  }
2252
1764
  }
2253
- this.wallet.confirmWins(this);
2254
- if (this.state.book.getPayout() >= this.config.maxWinX) {
2255
- this.state.triggeredMaxWin = true;
2256
- }
2257
- this.record({
2258
- criteria: resultSet.criteria
2259
- });
2260
- this.config.hooks.onSimulationAccepted?.(this);
2261
- this.confirmRecords();
2262
- parentPort?.postMessage({
2263
- type: "complete",
2264
- simId,
2265
- book: this.state.book.serialize(),
2266
- wallet: this.wallet.serialize(),
2267
- records: this.getRecords()
2268
- });
2269
- }
2270
- /**
2271
- * If a simulation does not meet the required criteria, reset the state to run it again.
2272
- *
2273
- * This also runs once before each simulation to ensure a clean state.
2274
- */
2275
- resetSimulation() {
2276
- this.resetState();
2277
- this.resetBoard();
2278
1765
  }
2279
1766
  /**
2280
- * Contains and executes the entire game logic:
2281
- * - Drawing the board
2282
- * - Evaluating wins
2283
- * - Updating wallet
2284
- * - Handling free spins
2285
- * - Recording events
2286
- *
2287
- * You can customize the game flow by implementing the `onHandleGameFlow` hook in the game configuration.
1767
+ * Confirms all pending records and adds them to the main records list.
2288
1768
  */
2289
- handleGameFlow() {
2290
- this.config.hooks.onHandleGameFlow(this);
1769
+ confirmRecords(ctx) {
1770
+ const recorder = ctx.services.data._getRecorder();
1771
+ for (const pendingRecord of recorder.pendingRecords) {
1772
+ const search = Object.entries(pendingRecord.properties).map(([name, value]) => ({ name, value })).sort((a, b) => a.name.localeCompare(b.name));
1773
+ let record = recorder.records.find((r) => {
1774
+ if (r.search.length !== search.length) return false;
1775
+ for (let i = 0; i < r.search.length; i++) {
1776
+ if (r.search[i].name !== search[i].name) return false;
1777
+ if (r.search[i].value !== search[i].value) return false;
1778
+ }
1779
+ return true;
1780
+ });
1781
+ if (!record) {
1782
+ record = {
1783
+ search,
1784
+ timesTriggered: 0,
1785
+ bookIds: []
1786
+ };
1787
+ recorder.records.push(record);
1788
+ }
1789
+ record.timesTriggered++;
1790
+ if (!record.bookIds.includes(pendingRecord.bookId)) {
1791
+ record.bookIds.push(pendingRecord.bookId);
1792
+ }
1793
+ }
1794
+ recorder.pendingRecords = [];
2291
1795
  }
2292
1796
  };
2293
1797
 
2294
1798
  // src/analysis/index.ts
2295
- import fs4 from "fs";
2296
- import path3 from "path";
2297
- import assert4 from "assert";
1799
+ import fs3 from "fs";
1800
+ import path2 from "path";
1801
+ import assert7 from "assert";
2298
1802
 
2299
1803
  // src/analysis/utils.ts
2300
1804
  function parseLookupTable(content) {
@@ -2403,7 +1907,7 @@ function getLessBetHitrate(payoutWeights, cost) {
2403
1907
  }
2404
1908
 
2405
1909
  // src/analysis/index.ts
2406
- import { isMainThread as isMainThread3 } from "worker_threads";
1910
+ import { isMainThread as isMainThread2 } from "worker_threads";
2407
1911
  var Analysis = class {
2408
1912
  gameConfig;
2409
1913
  optimizerConfig;
@@ -2414,7 +1918,7 @@ var Analysis = class {
2414
1918
  this.filePaths = {};
2415
1919
  }
2416
1920
  async runAnalysis(gameModes) {
2417
- if (!isMainThread3) return;
1921
+ if (!isMainThread2) return;
2418
1922
  this.filePaths = this.getPathsForModes(gameModes);
2419
1923
  this.getNumberStats(gameModes);
2420
1924
  this.getWinRanges(gameModes);
@@ -2424,28 +1928,28 @@ var Analysis = class {
2424
1928
  const rootPath = process.cwd();
2425
1929
  const paths = {};
2426
1930
  for (const modeStr of gameModes) {
2427
- const lut = path3.join(
1931
+ const lut = path2.join(
2428
1932
  rootPath,
2429
1933
  this.gameConfig.outputDir,
2430
1934
  `lookUpTable_${modeStr}.csv`
2431
1935
  );
2432
- const lutSegmented = path3.join(
1936
+ const lutSegmented = path2.join(
2433
1937
  rootPath,
2434
1938
  this.gameConfig.outputDir,
2435
1939
  `lookUpTableSegmented_${modeStr}.csv`
2436
1940
  );
2437
- const lutOptimized = path3.join(
1941
+ const lutOptimized = path2.join(
2438
1942
  rootPath,
2439
1943
  this.gameConfig.outputDir,
2440
1944
  "publish_files",
2441
1945
  `lookUpTable_${modeStr}_0.csv`
2442
1946
  );
2443
- const booksJsonl = path3.join(
1947
+ const booksJsonl = path2.join(
2444
1948
  rootPath,
2445
1949
  this.gameConfig.outputDir,
2446
1950
  `books_${modeStr}.jsonl`
2447
1951
  );
2448
- const booksJsonlCompressed = path3.join(
1952
+ const booksJsonlCompressed = path2.join(
2449
1953
  rootPath,
2450
1954
  this.gameConfig.outputDir,
2451
1955
  "publish_files",
@@ -2459,8 +1963,8 @@ var Analysis = class {
2459
1963
  booksJsonlCompressed
2460
1964
  };
2461
1965
  for (const p of Object.values(paths[modeStr])) {
2462
- assert4(
2463
- fs4.existsSync(p),
1966
+ assert7(
1967
+ fs3.existsSync(p),
2464
1968
  `File "${p}" does not exist. Run optimization to auto-create it.`
2465
1969
  );
2466
1970
  }
@@ -2472,7 +1976,7 @@ var Analysis = class {
2472
1976
  for (const modeStr of gameModes) {
2473
1977
  const mode = this.getGameModeConfig(modeStr);
2474
1978
  const lutOptimized = parseLookupTable(
2475
- fs4.readFileSync(this.filePaths[modeStr].lutOptimized, "utf-8")
1979
+ fs3.readFileSync(this.filePaths[modeStr].lutOptimized, "utf-8")
2476
1980
  );
2477
1981
  const totalWeight = getTotalLutWeight(lutOptimized);
2478
1982
  const payoutWeights = getPayoutWeights(lutOptimized);
@@ -2493,7 +1997,7 @@ var Analysis = class {
2493
1997
  });
2494
1998
  }
2495
1999
  writeJsonFile(
2496
- path3.join(process.cwd(), this.gameConfig.outputDir, "stats_summary.json"),
2000
+ path2.join(process.cwd(), this.gameConfig.outputDir, "stats_summary.json"),
2497
2001
  stats
2498
2002
  );
2499
2003
  }
@@ -2523,7 +2027,7 @@ var Analysis = class {
2523
2027
  for (const modeStr of gameModes) {
2524
2028
  const mode = this.getGameModeConfig(modeStr);
2525
2029
  const lutOptimized = parseLookupTable(
2526
- fs4.readFileSync(this.filePaths[modeStr].lutOptimized, "utf-8")
2030
+ fs3.readFileSync(this.filePaths[modeStr].lutOptimized, "utf-8")
2527
2031
  );
2528
2032
  const totalWeight = getTotalLutWeight(lutOptimized);
2529
2033
  const payoutWeights = getPayoutWeights(lutOptimized);
@@ -2531,13 +2035,19 @@ var Analysis = class {
2531
2035
  }
2532
2036
  getGameModeConfig(mode) {
2533
2037
  const config = this.gameConfig.gameModes[mode];
2534
- assert4(config, `Game mode "${mode}" not found in game config`);
2038
+ assert7(config, `Game mode "${mode}" not found in game config`);
2535
2039
  return config;
2536
2040
  }
2537
2041
  };
2538
2042
 
2043
+ // src/optimizer/index.ts
2044
+ import path5 from "path";
2045
+ import assert9 from "assert";
2046
+ import { spawn } from "child_process";
2047
+ import { isMainThread as isMainThread3 } from "worker_threads";
2048
+
2539
2049
  // src/utils/math-config.ts
2540
- import path4 from "path";
2050
+ import path3 from "path";
2541
2051
  function makeMathConfig(optimizer, opts = {}) {
2542
2052
  const game = optimizer.getGameConfig();
2543
2053
  const gameModesCfg = optimizer.getOptimizerGameModes();
@@ -2581,14 +2091,14 @@ function makeMathConfig(optimizer, opts = {}) {
2581
2091
  }))
2582
2092
  };
2583
2093
  if (writeToFile) {
2584
- const outPath = path4.join(process.cwd(), game.outputDir, "math_config.json");
2094
+ const outPath = path3.join(process.cwd(), game.outputDir, "math_config.json");
2585
2095
  writeJsonFile(outPath, config);
2586
2096
  }
2587
2097
  return config;
2588
2098
  }
2589
2099
 
2590
2100
  // src/utils/setup-file.ts
2591
- import path5 from "path";
2101
+ import path4 from "path";
2592
2102
  function makeSetupFile(optimizer, gameMode) {
2593
2103
  const gameConfig = optimizer.getGameConfig();
2594
2104
  const optimizerGameModes = optimizer.getOptimizerGameModes();
@@ -2624,32 +2134,125 @@ function makeSetupFile(optimizer, gameMode) {
2624
2134
  `;
2625
2135
  content += `simulation_trials;${params.simulationTrials}
2626
2136
  `;
2627
- content += `user_game_build_path;${path5.join(process.cwd(), gameConfig.outputDir)}
2137
+ content += `user_game_build_path;${path4.join(process.cwd(), gameConfig.outputDir)}
2628
2138
  `;
2629
2139
  content += `pmb_rtp;${params.pmbRtp}
2630
2140
  `;
2631
- const outPath = path5.join(__dirname, "./optimizer-rust/src", "setup.txt");
2141
+ const outPath = path4.join(__dirname, "./optimizer-rust/src", "setup.txt");
2632
2142
  writeFile(outPath, content);
2633
2143
  }
2634
2144
 
2635
- // src/optimizer/index.ts
2636
- import { spawn } from "child_process";
2637
- import path6 from "path";
2638
- import assert5 from "assert";
2639
- import { isMainThread as isMainThread4 } from "worker_threads";
2640
- var Optimizer = class {
2641
- gameConfig;
2642
- gameModes;
2643
- constructor(opts) {
2644
- this.gameConfig = opts.game.getConfig().config;
2645
- this.gameModes = opts.gameModes;
2646
- this.verifyConfig();
2145
+ // src/optimizer/OptimizationConditions.ts
2146
+ import assert8 from "assert";
2147
+ var OptimizationConditions = class {
2148
+ rtp;
2149
+ avgWin;
2150
+ hitRate;
2151
+ searchRange;
2152
+ forceSearch;
2153
+ priority;
2154
+ constructor(opts) {
2155
+ let { rtp, avgWin, hitRate, searchConditions, priority } = opts;
2156
+ if (rtp == void 0 || rtp === "x") {
2157
+ assert8(avgWin !== void 0 && hitRate !== void 0, "If RTP is not specified, hit-rate (hr) and average win amount (av_win) must be given.");
2158
+ rtp = Math.round(avgWin / Number(hitRate) * 1e5) / 1e5;
2159
+ }
2160
+ let noneCount = 0;
2161
+ for (const val of [rtp, avgWin, hitRate]) {
2162
+ if (val === void 0) noneCount++;
2163
+ }
2164
+ assert8(noneCount <= 1, "Invalid combination of optimization conditions.");
2165
+ this.searchRange = [-1, -1];
2166
+ this.forceSearch = {};
2167
+ if (typeof searchConditions === "number") {
2168
+ this.searchRange = [searchConditions, searchConditions];
2169
+ }
2170
+ if (Array.isArray(searchConditions)) {
2171
+ if (searchConditions[0] > searchConditions[1] || searchConditions.length !== 2) {
2172
+ throw new Error("Invalid searchConditions range.");
2173
+ }
2174
+ this.searchRange = searchConditions;
2175
+ }
2176
+ if (typeof searchConditions === "object" && !Array.isArray(searchConditions)) {
2177
+ this.searchRange = [-1, -1];
2178
+ this.forceSearch = searchConditions;
2179
+ }
2180
+ this.rtp = rtp;
2181
+ this.avgWin = avgWin;
2182
+ this.hitRate = hitRate;
2183
+ this.priority = priority;
2184
+ }
2185
+ getRtp() {
2186
+ return this.rtp;
2187
+ }
2188
+ getAvgWin() {
2189
+ return this.avgWin;
2190
+ }
2191
+ getHitRate() {
2192
+ return this.hitRate;
2193
+ }
2194
+ getSearchRange() {
2195
+ return this.searchRange;
2196
+ }
2197
+ getForceSearch() {
2198
+ return this.forceSearch;
2199
+ }
2200
+ };
2201
+
2202
+ // src/optimizer/OptimizationScaling.ts
2203
+ var OptimizationScaling = class {
2204
+ config;
2205
+ constructor(opts) {
2206
+ this.config = opts;
2207
+ }
2208
+ getConfig() {
2209
+ return this.config;
2210
+ }
2211
+ };
2212
+
2213
+ // src/optimizer/OptimizationParameters.ts
2214
+ var OptimizationParameters = class _OptimizationParameters {
2215
+ parameters;
2216
+ constructor(opts) {
2217
+ this.parameters = {
2218
+ ..._OptimizationParameters.DEFAULT_PARAMETERS,
2219
+ ...opts
2220
+ };
2221
+ }
2222
+ static DEFAULT_PARAMETERS = {
2223
+ numShowPigs: 5e3,
2224
+ numPigsPerFence: 1e4,
2225
+ threadsFenceConstruction: 16,
2226
+ threadsShowConstruction: 16,
2227
+ testSpins: [50, 100, 200],
2228
+ testSpinsWeights: [0.3, 0.4, 0.3],
2229
+ simulationTrials: 5e3,
2230
+ graphIndexes: [],
2231
+ run1000Batch: false,
2232
+ minMeanToMedian: 4,
2233
+ maxMeanToMedian: 8,
2234
+ pmbRtp: 1,
2235
+ scoreType: "rtp"
2236
+ };
2237
+ getParameters() {
2238
+ return this.parameters;
2239
+ }
2240
+ };
2241
+
2242
+ // src/optimizer/index.ts
2243
+ var Optimizer = class {
2244
+ gameConfig;
2245
+ gameModes;
2246
+ constructor(opts) {
2247
+ this.gameConfig = opts.game.getConfig();
2248
+ this.gameModes = opts.gameModes;
2249
+ this.verifyConfig();
2647
2250
  }
2648
2251
  /**
2649
2252
  * Runs the optimization process, and runs analysis after.
2650
2253
  */
2651
2254
  async runOptimization({ gameModes }) {
2652
- if (!isMainThread4) return;
2255
+ if (!isMainThread3) return;
2653
2256
  const mathConfig = makeMathConfig(this, { writeToFile: true });
2654
2257
  for (const mode of gameModes) {
2655
2258
  const setupFile = makeSetupFile(this, mode);
@@ -2674,175 +2277,940 @@ var Optimizer = class {
2674
2277
  for (const condition of conditions) {
2675
2278
  if (!configMode.resultSets.find((r) => r.criteria === condition)) {
2676
2279
  throw new Error(
2677
- `Condition "${condition}" defined in optimizer config for game mode "${k}" does not exist as criteria in any ResultSet of the same game mode.`
2280
+ `Condition "${condition}" defined in optimizer config for game mode "${k}" does not exist as criteria in any ResultSet of the same game mode.`
2281
+ );
2282
+ }
2283
+ }
2284
+ const criteria = configMode.resultSets.map((r) => r.criteria);
2285
+ assert9(
2286
+ conditions.every((c) => criteria.includes(c)),
2287
+ `Not all ResultSet criteria in game mode "${k}" are defined as optimization conditions.`
2288
+ );
2289
+ let gameModeRtp = configMode.rtp;
2290
+ let paramRtp = 0;
2291
+ for (const cond of conditions) {
2292
+ const paramConfig = mode.conditions[cond];
2293
+ paramRtp += Number(paramConfig.getRtp());
2294
+ }
2295
+ gameModeRtp = Math.round(gameModeRtp * 1e3) / 1e3;
2296
+ paramRtp = Math.round(paramRtp * 1e3) / 1e3;
2297
+ assert9(
2298
+ gameModeRtp === paramRtp,
2299
+ `Sum of all RTP conditions (${paramRtp}) does not match the game mode RTP (${gameModeRtp}) in game mode "${k}".`
2300
+ );
2301
+ }
2302
+ }
2303
+ getGameConfig() {
2304
+ return this.gameConfig;
2305
+ }
2306
+ getOptimizerGameModes() {
2307
+ return this.gameModes;
2308
+ }
2309
+ };
2310
+ async function rustProgram(...args) {
2311
+ return new Promise((resolve, reject) => {
2312
+ const task = spawn("cargo", ["run", "--release", ...args], {
2313
+ shell: true,
2314
+ cwd: path5.join(__dirname, "./optimizer-rust"),
2315
+ stdio: "pipe"
2316
+ });
2317
+ task.on("error", (error) => {
2318
+ console.error("Error:", error);
2319
+ reject(error);
2320
+ });
2321
+ task.on("exit", () => {
2322
+ resolve(true);
2323
+ });
2324
+ task.on("close", () => {
2325
+ resolve(true);
2326
+ });
2327
+ task.stdout.on("data", (data) => {
2328
+ console.log(data.toString());
2329
+ });
2330
+ task.stderr.on("data", (data) => {
2331
+ console.log(data.toString());
2332
+ });
2333
+ task.stdout.on("error", (data) => {
2334
+ console.log(data.toString());
2335
+ reject(data.toString());
2336
+ });
2337
+ });
2338
+ }
2339
+
2340
+ // src/slot-game/index.ts
2341
+ var SlotGame = class {
2342
+ configOpts;
2343
+ simulation;
2344
+ optimizer;
2345
+ analyzer;
2346
+ constructor(config) {
2347
+ this.configOpts = config;
2348
+ }
2349
+ /**
2350
+ * Sets up the simulation configuration.\
2351
+ * Must be called before `runTasks()`.
2352
+ */
2353
+ configureSimulation(opts) {
2354
+ this.simulation = new Simulation(opts, this.configOpts);
2355
+ }
2356
+ /**
2357
+ * Sets up the optimization configuration.\
2358
+ * Must be called before `runTasks()`.
2359
+ */
2360
+ configureOptimization(opts) {
2361
+ this.optimizer = new Optimizer({
2362
+ game: this,
2363
+ gameModes: opts.gameModes
2364
+ });
2365
+ }
2366
+ /**
2367
+ * Runs the simulation based on the configured settings.
2368
+ */
2369
+ async runSimulation(opts = {}) {
2370
+ if (!this.simulation) {
2371
+ throw new Error(
2372
+ "Simulation is not configured. Do so by calling configureSimulation() first."
2373
+ );
2374
+ }
2375
+ await this.simulation.runSimulation(opts);
2376
+ }
2377
+ /**
2378
+ * Runs the optimization based on the configured settings.
2379
+ */
2380
+ async runOptimization(opts) {
2381
+ if (!this.optimizer) {
2382
+ throw new Error(
2383
+ "Optimization is not configured. Do so by calling configureOptimization() first."
2384
+ );
2385
+ }
2386
+ await this.optimizer.runOptimization(opts);
2387
+ }
2388
+ /**
2389
+ * Runs the analysis based on the configured settings.
2390
+ */
2391
+ async runAnalysis(opts) {
2392
+ if (!this.optimizer) {
2393
+ throw new Error(
2394
+ "Optimization must be configured to run analysis. Do so by calling configureOptimization() first."
2395
+ );
2396
+ }
2397
+ this.analyzer = new Analysis(this.optimizer);
2398
+ await this.analyzer.runAnalysis(opts.gameModes);
2399
+ }
2400
+ /**
2401
+ * Runs the configured tasks: simulation, optimization, and/or analysis.
2402
+ */
2403
+ async runTasks(opts = {}) {
2404
+ if (!opts.doSimulation && !opts.doOptimization && !opts.doAnalysis) {
2405
+ console.log("No tasks to run. Enable either simulation, optimization or analysis.");
2406
+ }
2407
+ if (opts.doSimulation) {
2408
+ await this.runSimulation(opts.simulationOpts || {});
2409
+ }
2410
+ if (opts.doOptimization) {
2411
+ await this.runOptimization(opts.optimizationOpts || { gameModes: [] });
2412
+ }
2413
+ if (opts.doAnalysis) {
2414
+ await this.runAnalysis(opts.analysisOpts || { gameModes: [] });
2415
+ }
2416
+ }
2417
+ /**
2418
+ * Gets the game configuration.
2419
+ */
2420
+ getConfig() {
2421
+ return createGameConfig(this.configOpts);
2422
+ }
2423
+ };
2424
+
2425
+ // src/createSlotGame.ts
2426
+ function createSlotGame(opts) {
2427
+ return new SlotGame(opts);
2428
+ }
2429
+ var defineUserState = (data) => data;
2430
+ var defineSymbols = (symbols) => symbols;
2431
+ var defineGameModes = (gameModes) => gameModes;
2432
+
2433
+ // src/game-mode/index.ts
2434
+ import assert10 from "assert";
2435
+ var GameMode = class {
2436
+ name;
2437
+ reelsAmount;
2438
+ symbolsPerReel;
2439
+ cost;
2440
+ rtp;
2441
+ reelSets;
2442
+ resultSets;
2443
+ isBonusBuy;
2444
+ constructor(opts) {
2445
+ this.name = opts.name;
2446
+ this.reelsAmount = opts.reelsAmount;
2447
+ this.symbolsPerReel = opts.symbolsPerReel;
2448
+ this.cost = opts.cost;
2449
+ this.rtp = opts.rtp;
2450
+ this.reelSets = opts.reelSets;
2451
+ this.resultSets = opts.resultSets;
2452
+ this.isBonusBuy = opts.isBonusBuy;
2453
+ assert10(this.rtp >= 0.9 && this.rtp <= 0.99, "RTP must be between 0.9 and 0.99");
2454
+ assert10(
2455
+ this.symbolsPerReel.length === this.reelsAmount,
2456
+ "symbolsPerReel length must match reelsAmount."
2457
+ );
2458
+ assert10(this.reelSets.length > 0, "GameMode must have at least one ReelSet defined.");
2459
+ }
2460
+ };
2461
+
2462
+ // src/win-types/index.ts
2463
+ var WinType = class {
2464
+ payout;
2465
+ winCombinations;
2466
+ ctx;
2467
+ wildSymbol;
2468
+ constructor(opts) {
2469
+ this.ctx = opts.ctx;
2470
+ this.payout = 0;
2471
+ this.winCombinations = [];
2472
+ this.wildSymbol = opts?.wildSymbol;
2473
+ }
2474
+ /**
2475
+ * Implementation of win evaluation logic. Sets `this.payout` and `this.winCombinations`.
2476
+ */
2477
+ evaluateWins(board) {
2478
+ return this;
2479
+ }
2480
+ /**
2481
+ * Custom post-processing of wins, e.g. for handling multipliers.
2482
+ */
2483
+ postProcess(func) {
2484
+ const result = func(this, this.ctx);
2485
+ this.payout = result.payout;
2486
+ this.winCombinations = result.winCombinations;
2487
+ return this;
2488
+ }
2489
+ /**
2490
+ * Returns the total payout and detailed win combinations.
2491
+ */
2492
+ getWins() {
2493
+ return {
2494
+ payout: this.payout,
2495
+ winCombinations: this.winCombinations
2496
+ };
2497
+ }
2498
+ isWild(symbol) {
2499
+ return !!this.wildSymbol && symbol.compare(this.wildSymbol);
2500
+ }
2501
+ };
2502
+
2503
+ // src/win-types/LinesWinType.ts
2504
+ var LinesWinType = class extends WinType {
2505
+ lines;
2506
+ constructor(opts) {
2507
+ super(opts);
2508
+ this.lines = opts.lines;
2509
+ if (Object.keys(this.lines).length === 0) {
2510
+ throw new Error("LinesWinType must have at least one line defined.");
2511
+ }
2512
+ }
2513
+ validateConfig() {
2514
+ const reelsAmount = this.ctx.services.game.getCurrentGameMode().reelsAmount;
2515
+ const symsPerReel = this.ctx.services.game.getCurrentGameMode().symbolsPerReel;
2516
+ for (const [lineNum, positions] of Object.entries(this.lines)) {
2517
+ if (positions.length !== reelsAmount) {
2518
+ throw new Error(
2519
+ `Line ${lineNum} has ${positions.length} positions, but the current game mode has ${reelsAmount} reels.`
2520
+ );
2521
+ }
2522
+ for (let i = 0; i < positions.length; i++) {
2523
+ if (positions[i] < 0 || positions[i] >= symsPerReel[i]) {
2524
+ throw new Error(
2525
+ `Line ${lineNum} has an invalid position ${positions[i]} on reel ${i}. Valid range is 0 to ${symsPerReel[i] - 1}.`
2526
+ );
2527
+ }
2528
+ }
2529
+ }
2530
+ const firstLine = Math.min(...Object.keys(this.lines).map(Number));
2531
+ if (firstLine !== 1) {
2532
+ throw new Error(
2533
+ `Lines must start from 1. Found line ${firstLine} as the first line.`
2534
+ );
2535
+ }
2536
+ }
2537
+ /**
2538
+ * Calculates wins based on the defined paylines and provided board state.\
2539
+ * Retrieve the results using `getWins()` after.
2540
+ */
2541
+ evaluateWins(board) {
2542
+ this.validateConfig();
2543
+ const lineWins = [];
2544
+ let payout = 0;
2545
+ const reels = board;
2546
+ for (const [lineNumStr, lineDef] of Object.entries(this.lines)) {
2547
+ const lineNum = Number(lineNumStr);
2548
+ let baseSymbol = null;
2549
+ let leadingWilds = 0;
2550
+ const chain = [];
2551
+ const details = [];
2552
+ for (let ridx = 0; ridx < reels.length; ridx++) {
2553
+ const rowIdx = lineDef[ridx];
2554
+ const sym = reels[ridx][rowIdx];
2555
+ if (!sym) throw new Error("Encountered an invalid symbol while evaluating wins.");
2556
+ const wild = this.isWild(sym);
2557
+ if (ridx === 0) {
2558
+ chain.push(sym);
2559
+ details.push({ reelIndex: ridx, posIndex: rowIdx, symbol: sym, isWild: wild });
2560
+ if (wild) leadingWilds++;
2561
+ else baseSymbol = sym;
2562
+ continue;
2563
+ }
2564
+ if (wild) {
2565
+ chain.push(sym);
2566
+ details.push({
2567
+ reelIndex: ridx,
2568
+ posIndex: rowIdx,
2569
+ symbol: sym,
2570
+ isWild: true,
2571
+ substitutedFor: baseSymbol || void 0
2572
+ });
2573
+ continue;
2574
+ }
2575
+ if (!baseSymbol) {
2576
+ baseSymbol = sym;
2577
+ chain.push(sym);
2578
+ details.push({ reelIndex: ridx, posIndex: rowIdx, symbol: sym, isWild: false });
2579
+ continue;
2580
+ }
2581
+ if (sym.id === baseSymbol.id) {
2582
+ chain.push(sym);
2583
+ details.push({ reelIndex: ridx, posIndex: rowIdx, symbol: sym, isWild: false });
2584
+ continue;
2585
+ }
2586
+ break;
2587
+ }
2588
+ if (chain.length === 0) continue;
2589
+ const allWild = chain.every((s) => this.isWild(s));
2590
+ const wildRepresentative = this.wildSymbol instanceof GameSymbol ? this.wildSymbol : null;
2591
+ const len = chain.length;
2592
+ let bestPayout = 0;
2593
+ let bestType = null;
2594
+ let payingSymbol = null;
2595
+ if (baseSymbol?.pays && baseSymbol.pays[len]) {
2596
+ bestPayout = baseSymbol.pays[len];
2597
+ bestType = "substituted";
2598
+ payingSymbol = baseSymbol;
2599
+ }
2600
+ if (allWild && wildRepresentative?.pays && wildRepresentative.pays[len]) {
2601
+ const wildPay = wildRepresentative.pays[len];
2602
+ if (wildPay > bestPayout) {
2603
+ bestPayout = wildPay;
2604
+ bestType = "pure-wild";
2605
+ payingSymbol = wildRepresentative;
2606
+ }
2607
+ }
2608
+ if (!bestPayout || !bestType || !payingSymbol) continue;
2609
+ const minLen = payingSymbol.pays ? Math.min(...Object.keys(payingSymbol.pays).map(Number)) : Infinity;
2610
+ if (len < minLen) continue;
2611
+ const wildCount = details.filter((d) => d.isWild).length;
2612
+ const nonWildCount = len - wildCount;
2613
+ lineWins.push({
2614
+ lineNumber: lineNum,
2615
+ kind: len,
2616
+ payout: bestPayout,
2617
+ symbol: payingSymbol,
2618
+ winType: bestType,
2619
+ substitutedBaseSymbol: bestType === "pure-wild" ? null : baseSymbol,
2620
+ symbols: details,
2621
+ stats: { wildCount, nonWildCount, leadingWilds }
2622
+ });
2623
+ payout += bestPayout;
2624
+ }
2625
+ for (const win of lineWins) {
2626
+ this.ctx.services.data.recordSymbolOccurrence({
2627
+ kind: win.kind,
2628
+ symbolId: win.symbol.id,
2629
+ spinType: this.ctx.state.currentSpinType
2630
+ });
2631
+ }
2632
+ this.payout = payout;
2633
+ this.winCombinations = lineWins;
2634
+ return this;
2635
+ }
2636
+ };
2637
+
2638
+ // src/win-types/ClusterWinType.ts
2639
+ var ClusterWinType = class extends WinType {
2640
+ };
2641
+
2642
+ // src/win-types/ManywaysWinType.ts
2643
+ var ManywaysWinType = class extends WinType {
2644
+ };
2645
+
2646
+ // src/reel-set/GeneratedReelSet.ts
2647
+ import fs5 from "fs";
2648
+ import path7 from "path";
2649
+ import { isMainThread as isMainThread4 } from "worker_threads";
2650
+
2651
+ // src/reel-set/index.ts
2652
+ import fs4 from "fs";
2653
+ import path6 from "path";
2654
+ var ReelSet = class {
2655
+ id;
2656
+ associatedGameModeName;
2657
+ reels;
2658
+ rng;
2659
+ constructor(opts) {
2660
+ this.id = opts.id;
2661
+ this.associatedGameModeName = "";
2662
+ this.reels = [];
2663
+ this.rng = new RandomNumberGenerator();
2664
+ this.rng.setSeed(opts.seed ?? 0);
2665
+ }
2666
+ generateReels(simulation) {
2667
+ throw new Error("Not implemented");
2668
+ }
2669
+ /**
2670
+ * Reads a reelset CSV file and returns the reels as arrays of GameSymbols.
2671
+ */
2672
+ parseReelsetCSV(reelSetPath, config) {
2673
+ if (!fs4.existsSync(reelSetPath)) {
2674
+ throw new Error(`Reelset CSV file not found at path: ${reelSetPath}`);
2675
+ }
2676
+ const allowedExtensions = [".csv"];
2677
+ const ext = path6.extname(reelSetPath).toLowerCase();
2678
+ if (!allowedExtensions.includes(ext)) {
2679
+ throw new Error(
2680
+ `Invalid file extension for reelset CSV: ${ext}. Allowed extensions are: ${allowedExtensions.join(
2681
+ ", "
2682
+ )}`
2683
+ );
2684
+ }
2685
+ const csvData = fs4.readFileSync(reelSetPath, "utf8");
2686
+ const rows = csvData.split("\n").filter((line) => line.trim() !== "");
2687
+ const reels = Array.from(
2688
+ { length: config.gameModes[this.associatedGameModeName].reelsAmount },
2689
+ () => []
2690
+ );
2691
+ rows.forEach((row) => {
2692
+ const symsInRow = row.split(",").map((symbolId) => {
2693
+ const symbol = config.symbols.get(symbolId.trim());
2694
+ if (!symbol) {
2695
+ throw new Error(`Symbol with id "${symbolId}" not found in game config.`);
2696
+ }
2697
+ return symbol;
2698
+ });
2699
+ symsInRow.forEach((symbol, ridx) => {
2700
+ if (ridx >= reels.length) {
2701
+ throw new Error(
2702
+ `Row in reelset CSV has more symbols than expected reels amount (${reels.length})`
2703
+ );
2704
+ }
2705
+ reels[ridx].push(symbol);
2706
+ });
2707
+ });
2708
+ const reelLengths = reels.map((r) => r.length);
2709
+ const uniqueLengths = new Set(reelLengths);
2710
+ if (uniqueLengths.size > 1) {
2711
+ throw new Error(
2712
+ `Inconsistent reel lengths in reelset CSV at ${reelSetPath}: ${[
2713
+ ...uniqueLengths
2714
+ ].join(", ")}`
2715
+ );
2716
+ }
2717
+ return reels;
2718
+ }
2719
+ };
2720
+
2721
+ // src/reel-set/GeneratedReelSet.ts
2722
+ var GeneratedReelSet = class extends ReelSet {
2723
+ symbolWeights = /* @__PURE__ */ new Map();
2724
+ rowsAmount;
2725
+ limitSymbolsToReels;
2726
+ spaceBetweenSameSymbols;
2727
+ spaceBetweenSymbols;
2728
+ preferStackedSymbols;
2729
+ symbolStacks;
2730
+ symbolQuotas;
2731
+ overrideExisting;
2732
+ constructor(opts) {
2733
+ super(opts);
2734
+ this.id = opts.id;
2735
+ this.symbolWeights = new Map(Object.entries(opts.symbolWeights));
2736
+ this.rowsAmount = opts.rowsAmount || 250;
2737
+ if (opts.limitSymbolsToReels) this.limitSymbolsToReels = opts.limitSymbolsToReels;
2738
+ this.overrideExisting = opts.overrideExisting || false;
2739
+ this.spaceBetweenSameSymbols = opts.spaceBetweenSameSymbols;
2740
+ this.spaceBetweenSymbols = opts.spaceBetweenSymbols;
2741
+ this.preferStackedSymbols = opts.preferStackedSymbols;
2742
+ this.symbolStacks = opts.symbolStacks;
2743
+ this.symbolQuotas = opts.symbolQuotas;
2744
+ if (typeof this.spaceBetweenSameSymbols == "number" && (this.spaceBetweenSameSymbols < 1 || this.spaceBetweenSameSymbols > 8) || typeof this.spaceBetweenSameSymbols == "object" && Object.values(this.spaceBetweenSameSymbols).some((v) => v < 1 || v > 8)) {
2745
+ throw new Error(
2746
+ `spaceBetweenSameSymbols must be between 1 and 8, got ${this.spaceBetweenSameSymbols}.`
2747
+ );
2748
+ }
2749
+ if (Object.values(this.spaceBetweenSymbols || {}).some(
2750
+ (o) => Object.values(o).some((v) => v < 1 || v > 8)
2751
+ )) {
2752
+ throw new Error(
2753
+ `spaceBetweenSymbols must be between 1 and 8, got ${this.spaceBetweenSymbols}.`
2754
+ );
2755
+ }
2756
+ if (this.preferStackedSymbols && (this.preferStackedSymbols < 0 || this.preferStackedSymbols > 100)) {
2757
+ throw new Error(
2758
+ `preferStackedSymbols must be between 0 and 100, got ${this.preferStackedSymbols}.`
2759
+ );
2760
+ }
2761
+ }
2762
+ validateConfig(config) {
2763
+ this.symbolWeights.forEach((_, symbol) => {
2764
+ if (!config.symbols.has(symbol)) {
2765
+ throw new Error(
2766
+ `Symbol "${symbol}" of the reel generator ${this.id} for mode ${this.associatedGameModeName} is not defined in the game config`
2767
+ );
2768
+ }
2769
+ });
2770
+ if (this.limitSymbolsToReels && Object.keys(this.limitSymbolsToReels).length == 0) {
2771
+ this.limitSymbolsToReels = void 0;
2772
+ }
2773
+ }
2774
+ isSymbolAllowedOnReel(symbolId, reelIdx) {
2775
+ if (!this.limitSymbolsToReels) return true;
2776
+ const allowedReels = this.limitSymbolsToReels[symbolId];
2777
+ if (!allowedReels || allowedReels.length === 0) return true;
2778
+ return allowedReels.includes(reelIdx);
2779
+ }
2780
+ resolveStacking(symbolId, reelIdx) {
2781
+ const cfg = this.symbolStacks?.[symbolId];
2782
+ if (!cfg) return null;
2783
+ const STACKING_MIN = 1;
2784
+ const STACKING_MAX = 4;
2785
+ const chance = typeof cfg.chance === "number" ? cfg.chance : cfg.chance?.[reelIdx] ?? 0;
2786
+ if (chance <= 0) return null;
2787
+ let min = typeof cfg.min === "number" ? cfg.min : cfg.min?.[reelIdx] ?? STACKING_MIN;
2788
+ let max = typeof cfg.max === "number" ? cfg.max : cfg.max?.[reelIdx] ?? STACKING_MAX;
2789
+ return { chance, min, max };
2790
+ }
2791
+ tryPlaceStack(reel, config, reelIdx, symbolId, startIndex, maxStack) {
2792
+ if (!this.isSymbolAllowedOnReel(symbolId, reelIdx)) return 0;
2793
+ let canPlace = 0;
2794
+ for (let j = 0; j < maxStack; j++) {
2795
+ const idx = (startIndex + j) % this.rowsAmount;
2796
+ if (reel[idx] !== null) break;
2797
+ canPlace++;
2798
+ }
2799
+ if (canPlace === 0) return 0;
2800
+ const symObj = config.symbols.get(symbolId);
2801
+ if (!symObj) {
2802
+ throw new Error(
2803
+ `Symbol with id "${symbolId}" not found in the game config symbols map.`
2804
+ );
2805
+ }
2806
+ for (let j = 0; j < canPlace; j++) {
2807
+ const idx = (startIndex + j) % reel.length;
2808
+ reel[idx] = symObj;
2809
+ }
2810
+ return canPlace;
2811
+ }
2812
+ /**
2813
+ * Checks if a symbol can be placed at the target index without violating spacing rules.
2814
+ */
2815
+ violatesSpacing(reel, symbolId, targetIndex) {
2816
+ const circDist = (a, b) => {
2817
+ const diff = Math.abs(a - b);
2818
+ return Math.min(diff, this.rowsAmount - diff);
2819
+ };
2820
+ const spacingType = this.spaceBetweenSameSymbols ?? void 0;
2821
+ const sameSpacing = typeof spacingType === "number" ? spacingType : spacingType?.[symbolId] ?? 0;
2822
+ for (let i = 0; i <= reel.length; i++) {
2823
+ const placed = reel[i];
2824
+ if (!placed) continue;
2825
+ const dist = circDist(targetIndex, i);
2826
+ if (sameSpacing >= 1 && placed.id === symbolId) {
2827
+ if (dist <= sameSpacing) return true;
2828
+ }
2829
+ if (this.spaceBetweenSymbols) {
2830
+ const forward = this.spaceBetweenSymbols[symbolId]?.[placed.id] ?? 0;
2831
+ if (forward >= 1 && dist <= forward) return true;
2832
+ const reverse = this.spaceBetweenSymbols[placed.id]?.[symbolId] ?? 0;
2833
+ if (reverse >= 1 && dist <= reverse) return true;
2834
+ }
2835
+ }
2836
+ return false;
2837
+ }
2838
+ generateReels({ gameConfig: config }) {
2839
+ this.validateConfig(config);
2840
+ const gameMode = config.gameModes[this.associatedGameModeName];
2841
+ if (!gameMode) {
2842
+ throw new Error(
2843
+ `Error generating reels for game mode "${this.associatedGameModeName}". It's not defined in the game config.`
2844
+ );
2845
+ }
2846
+ const filePath = path7.join(
2847
+ config.outputDir,
2848
+ `reels_${this.associatedGameModeName}-${this.id}.csv`
2849
+ );
2850
+ const exists = fs5.existsSync(filePath);
2851
+ if (exists && !this.overrideExisting) {
2852
+ this.reels = this.parseReelsetCSV(filePath, config);
2853
+ return;
2854
+ }
2855
+ if (!exists && this.symbolWeights.size === 0) {
2856
+ throw new Error(
2857
+ `Cannot generate reels for generator "${this.id}" of mode "${this.associatedGameModeName}" because the symbol weights are empty.`
2858
+ );
2859
+ }
2860
+ const reelsAmount = gameMode.reelsAmount;
2861
+ const weightsObj = Object.fromEntries(this.symbolWeights);
2862
+ for (let ridx = 0; ridx < reelsAmount; ridx++) {
2863
+ const reel = new Array(this.rowsAmount).fill(null);
2864
+ const reelQuotas = {};
2865
+ const quotaCounts = {};
2866
+ let totalReelsQuota = 0;
2867
+ for (const [sym, quotaConf] of Object.entries(this.symbolQuotas || {})) {
2868
+ const q = typeof quotaConf === "number" ? quotaConf : quotaConf[ridx];
2869
+ if (!q) continue;
2870
+ reelQuotas[sym] = q;
2871
+ totalReelsQuota += q;
2872
+ }
2873
+ if (totalReelsQuota > 100) {
2874
+ throw new Error(
2875
+ `Total symbol quotas for reel ${ridx} exceed 100%. Adjust your configuration on reel set "${this.id}".`
2876
+ );
2877
+ }
2878
+ if (totalReelsQuota > 0) {
2879
+ for (const [sym, quota] of Object.entries(reelQuotas)) {
2880
+ const quotaCount = Math.max(1, Math.floor(this.rowsAmount * quota / 100));
2881
+ quotaCounts[sym] = quotaCount;
2882
+ }
2883
+ }
2884
+ for (const [sym, targetCount] of Object.entries(quotaCounts)) {
2885
+ let remaining = targetCount;
2886
+ let attempts = 0;
2887
+ while (remaining > 0) {
2888
+ if (attempts++ > this.rowsAmount * 10) {
2889
+ throw new Error(
2890
+ `Failed to place ${targetCount} of symbol ${sym} on reel ${ridx} (likely spacing/stacking too strict).`
2891
+ );
2892
+ }
2893
+ const pos = Math.round(this.rng.randomFloat(0, this.rowsAmount - 1));
2894
+ const stackCfg = this.resolveStacking(sym, ridx);
2895
+ let placed = 0;
2896
+ if (stackCfg && Math.round(this.rng.randomFloat(1, 100)) <= stackCfg.chance) {
2897
+ const stackSize = Math.max(
2898
+ 0,
2899
+ Math.round(this.rng.randomFloat(stackCfg.min, stackCfg.max))
2900
+ );
2901
+ const toPlace = Math.min(stackSize, remaining);
2902
+ placed = this.tryPlaceStack(reel, config, ridx, sym, pos, toPlace);
2903
+ }
2904
+ if (placed === 0 && reel[pos] === null && this.isSymbolAllowedOnReel(sym, ridx) && !this.violatesSpacing(reel, sym, pos)) {
2905
+ reel[pos] = config.symbols.get(sym);
2906
+ placed = 1;
2907
+ }
2908
+ remaining -= placed;
2909
+ }
2910
+ }
2911
+ for (let r = 0; r < this.rowsAmount; r++) {
2912
+ if (reel[r] !== null) continue;
2913
+ let chosenSymbolId = this.rng.weightedRandom(weightsObj);
2914
+ const nextHasStackCfg = !!this.resolveStacking(chosenSymbolId, ridx);
2915
+ if (!nextHasStackCfg && this.preferStackedSymbols && reel.length > 0) {
2916
+ const prevSymbol = r - 1 >= 0 ? reel[r - 1] : reel[reel.length - 1];
2917
+ if (prevSymbol && Math.round(this.rng.randomFloat(1, 100)) <= this.preferStackedSymbols && (!this.spaceBetweenSameSymbols || !this.violatesSpacing(reel, prevSymbol.id, r))) {
2918
+ chosenSymbolId = prevSymbol.id;
2919
+ }
2920
+ }
2921
+ if (this.preferStackedSymbols && reel.length > 0) {
2922
+ const prevSymbol = r - 1 >= 0 ? reel[r - 1] : reel[reel.length - 1];
2923
+ if (Math.round(this.rng.randomFloat(1, 100)) <= this.preferStackedSymbols && (!this.spaceBetweenSameSymbols || !this.violatesSpacing(reel, prevSymbol.id, r))) {
2924
+ chosenSymbolId = prevSymbol.id;
2925
+ }
2926
+ }
2927
+ const stackCfg = this.resolveStacking(chosenSymbolId, ridx);
2928
+ if (stackCfg && this.isSymbolAllowedOnReel(chosenSymbolId, ridx)) {
2929
+ const roll = Math.round(this.rng.randomFloat(1, 100));
2930
+ if (roll <= stackCfg.chance) {
2931
+ const desiredSize = Math.max(
2932
+ 1,
2933
+ Math.round(this.rng.randomFloat(stackCfg.min, stackCfg.max))
2934
+ );
2935
+ const placed = this.tryPlaceStack(
2936
+ reel,
2937
+ config,
2938
+ ridx,
2939
+ chosenSymbolId,
2940
+ r,
2941
+ desiredSize
2942
+ );
2943
+ if (placed > 0) {
2944
+ r += placed - 1;
2945
+ continue;
2946
+ }
2947
+ }
2948
+ }
2949
+ let tries = 0;
2950
+ const maxTries = 2500;
2951
+ while (!this.isSymbolAllowedOnReel(chosenSymbolId, ridx) || this.violatesSpacing(reel, chosenSymbolId, r)) {
2952
+ if (++tries > maxTries) {
2953
+ throw new Error(
2954
+ [
2955
+ `Failed to place a symbol on reel ${ridx} at position ${r} after ${maxTries} attempts.
2956
+ `,
2957
+ "Try to change the seed or adjust your configuration.\n"
2958
+ ].join(" ")
2959
+ );
2960
+ }
2961
+ chosenSymbolId = this.rng.weightedRandom(weightsObj);
2962
+ const hasStackCfg = !!this.resolveStacking(chosenSymbolId, ridx);
2963
+ if (!hasStackCfg && this.preferStackedSymbols && reel.length > 0) {
2964
+ const prevSymbol = r - 1 >= 0 ? reel[r - 1] : reel[reel.length - 1];
2965
+ if (prevSymbol && Math.round(this.rng.randomFloat(1, 100)) <= this.preferStackedSymbols && (!this.spaceBetweenSameSymbols || !this.violatesSpacing(reel, prevSymbol.id, r))) {
2966
+ chosenSymbolId = prevSymbol.id;
2967
+ }
2968
+ }
2969
+ }
2970
+ const symbol = config.symbols.get(chosenSymbolId);
2971
+ if (!symbol) {
2972
+ throw new Error(
2973
+ `Symbol with id "${chosenSymbolId}" not found in the game config symbols map.`
2678
2974
  );
2679
2975
  }
2976
+ reel[r] = symbol;
2680
2977
  }
2681
- const criteria = configMode.resultSets.map((r) => r.criteria);
2682
- assert5(
2683
- conditions.every((c) => criteria.includes(c)),
2684
- `Not all ResultSet criteria in game mode "${k}" are defined as optimization conditions.`
2685
- );
2686
- let gameModeRtp = configMode.rtp;
2687
- let paramRtp = 0;
2688
- for (const cond of conditions) {
2689
- const paramConfig = mode.conditions[cond];
2690
- paramRtp += Number(paramConfig.getRtp());
2978
+ if (reel.some((s) => s === null)) {
2979
+ throw new Error(`Reel ${ridx} has unfilled positions after generation.`);
2691
2980
  }
2692
- gameModeRtp = Math.round(gameModeRtp * 1e3) / 1e3;
2693
- paramRtp = Math.round(paramRtp * 1e3) / 1e3;
2694
- assert5(
2695
- gameModeRtp === paramRtp,
2696
- `Sum of all RTP conditions (${paramRtp}) does not match the game mode RTP (${gameModeRtp}) in game mode "${k}".`
2981
+ this.reels.push(reel);
2982
+ }
2983
+ const csvRows = Array.from(
2984
+ { length: this.rowsAmount },
2985
+ () => Array.from({ length: reelsAmount }, () => "")
2986
+ );
2987
+ for (let ridx = 0; ridx < reelsAmount; ridx++) {
2988
+ for (let r = 0; r < this.rowsAmount; r++) {
2989
+ csvRows[r][ridx] = this.reels[ridx][r].id;
2990
+ }
2991
+ }
2992
+ const csvString = csvRows.map((row) => row.join(",")).join("\n");
2993
+ if (isMainThread4) {
2994
+ fs5.writeFileSync(filePath, csvString);
2995
+ this.reels = this.parseReelsetCSV(filePath, config);
2996
+ console.log(
2997
+ `Generated reelset ${this.id} for game mode ${this.associatedGameModeName}`
2697
2998
  );
2698
2999
  }
2699
3000
  }
2700
- getGameConfig() {
2701
- return this.gameConfig;
3001
+ };
3002
+
3003
+ // src/reel-set/StaticReelSet.ts
3004
+ import assert11 from "assert";
3005
+ var StaticReelSet = class extends ReelSet {
3006
+ reels;
3007
+ csvPath;
3008
+ _strReels;
3009
+ constructor(opts) {
3010
+ super(opts);
3011
+ this.reels = [];
3012
+ this._strReels = opts.reels || [];
3013
+ this.csvPath = opts.csvPath || "";
3014
+ assert11(
3015
+ opts.reels || opts.csvPath,
3016
+ `Either 'reels' or 'csvPath' must be provided for StaticReelSet ${this.id}`
3017
+ );
2702
3018
  }
2703
- getOptimizerGameModes() {
2704
- return this.gameModes;
3019
+ validateConfig(config) {
3020
+ this.reels.forEach((reel) => {
3021
+ reel.forEach((symbol) => {
3022
+ if (!config.symbols.has(symbol.id)) {
3023
+ throw new Error(
3024
+ `Symbol "${symbol}" of the reel set ${this.id} for mode ${this.associatedGameModeName} is not defined in the game config`
3025
+ );
3026
+ }
3027
+ });
3028
+ });
3029
+ if (this.csvPath && this._strReels.length > 0) {
3030
+ throw new Error(
3031
+ `Both 'csvPath' and 'reels' are provided for StaticReelSet ${this.id}. Please provide only one.`
3032
+ );
3033
+ }
3034
+ }
3035
+ generateReels({ gameConfig: config }) {
3036
+ this.validateConfig(config);
3037
+ if (this._strReels.length > 0) {
3038
+ this.reels = this._strReels.map((reel) => {
3039
+ return reel.map((symbolId) => {
3040
+ const symbol = config.symbols.get(symbolId);
3041
+ if (!symbol) {
3042
+ throw new Error(
3043
+ `Symbol "${symbolId}" of the reel set ${this.id} for mode ${this.associatedGameModeName} is not defined in the game config`
3044
+ );
3045
+ }
3046
+ return symbol;
3047
+ });
3048
+ });
3049
+ }
3050
+ if (this.csvPath) {
3051
+ this.reels = this.parseReelsetCSV(this.csvPath, config);
3052
+ }
2705
3053
  }
2706
3054
  };
2707
- async function rustProgram(...args) {
2708
- return new Promise((resolve, reject) => {
2709
- const task = spawn("cargo", ["run", "--release", ...args], {
2710
- shell: true,
2711
- cwd: path6.join(__dirname, "./optimizer-rust"),
2712
- stdio: "pipe"
2713
- });
2714
- task.on("error", (error) => {
2715
- console.error("Error:", error);
2716
- reject(error);
2717
- });
2718
- task.on("exit", () => {
2719
- resolve(true);
2720
- });
2721
- task.on("close", () => {
2722
- resolve(true);
2723
- });
2724
- task.stdout.on("data", (data) => {
2725
- console.log(data.toString());
2726
- });
2727
- task.stderr.on("data", (data) => {
2728
- console.log(data.toString());
2729
- });
2730
- task.stdout.on("error", (data) => {
2731
- console.log(data.toString());
2732
- reject(data.toString());
2733
- });
2734
- });
2735
- }
2736
3055
 
2737
- // src/SlotGame.ts
2738
- var SlotGame = class {
2739
- gameConfigOpts;
2740
- simulation;
2741
- optimizer;
2742
- analyzer;
2743
- constructor(config) {
2744
- this.gameConfigOpts = config;
3056
+ // src/board/StandaloneBoard.ts
3057
+ var StandaloneBoard = class {
3058
+ board;
3059
+ ctx;
3060
+ reelsAmount;
3061
+ symbolsPerReel;
3062
+ padSymbols;
3063
+ constructor(opts) {
3064
+ this.board = new Board();
3065
+ this.ctx = opts.ctx;
3066
+ this.reelsAmount = opts.reelsAmount;
3067
+ this.symbolsPerReel = opts.symbolsPerReel;
3068
+ this.padSymbols = opts.padSymbols;
2745
3069
  }
2746
3070
  /**
2747
- * Sets up the simulation configuration.\
2748
- * Must be called before `runTasks()`.
3071
+ * Resets the board to an empty state.\
3072
+ * This is called before drawing a new board.
2749
3073
  */
2750
- configureSimulation(opts) {
2751
- this.simulation = new Simulation(opts, this.gameConfigOpts);
3074
+ resetBoard() {
3075
+ this.resetReels();
3076
+ this.board.lastDrawnReelStops = [];
2752
3077
  }
2753
3078
  /**
2754
- * Sets up the optimization configuration.\
2755
- * Must be called before `runTasks()`.
3079
+ * Gets the current reels and symbols on the board.
2756
3080
  */
2757
- configureOptimization(opts) {
2758
- this.optimizer = new Optimizer({
2759
- game: this,
2760
- gameModes: opts.gameModes
3081
+ getBoardReels() {
3082
+ return this.board.reels;
3083
+ }
3084
+ getPaddingTop() {
3085
+ return this.board.paddingTop;
3086
+ }
3087
+ getPaddingBottom() {
3088
+ return this.board.paddingBottom;
3089
+ }
3090
+ resetReels() {
3091
+ this.board.resetReels({
3092
+ ctx: this.ctx
2761
3093
  });
2762
3094
  }
2763
3095
  /**
2764
- * Runs the simulation based on the configured settings.
3096
+ * Sets the anticipation value for a specific reel.
2765
3097
  */
2766
- async runSimulation(opts = {}) {
2767
- if (!this.simulation) {
2768
- throw new Error(
2769
- "Simulation is not configured. Do so by calling configureSimulation() first."
2770
- );
2771
- }
2772
- await this.simulation.runSimulation(opts);
3098
+ setAnticipationForReel(reelIndex, value) {
3099
+ this.board.anticipation[reelIndex] = value;
2773
3100
  }
2774
3101
  /**
2775
- * Runs the optimization based on the configured settings.
3102
+ * Counts how many symbols matching the criteria are on a specific reel.
2776
3103
  */
2777
- async runOptimization(opts) {
2778
- if (!this.optimizer) {
2779
- throw new Error(
2780
- "Optimization is not configured. Do so by calling configureOptimization() first."
2781
- );
2782
- }
2783
- await this.optimizer.runOptimization(opts);
3104
+ countSymbolsOnReel(symbolOrProperties, reelIndex) {
3105
+ return this.board.countSymbolsOnReel(symbolOrProperties, reelIndex);
2784
3106
  }
2785
3107
  /**
2786
- * Runs the analysis based on the configured settings.
3108
+ * Counts how many symbols matching the criteria are on the board.
3109
+ *
3110
+ * Passing a GameSymbol will compare by ID, passing a properties object will compare by properties.
3111
+ *
3112
+ * Returns a tuple where the first element is the total count, and the second element is a record of counts per reel index.
2787
3113
  */
2788
- async runAnalysis(opts) {
2789
- if (!this.optimizer) {
2790
- throw new Error(
2791
- "Optimization must be configured to run analysis. Do so by calling configureOptimization() first."
2792
- );
2793
- }
2794
- this.analyzer = new Analysis(this.optimizer);
2795
- await this.analyzer.runAnalysis(opts.gameModes);
3114
+ countSymbolsOnBoard(symbolOrProperties) {
3115
+ return this.board.countSymbolsOnBoard(symbolOrProperties);
2796
3116
  }
2797
- async runTasks(opts = {}) {
2798
- if (!opts.doSimulation && !opts.doOptimization && !opts.doAnalysis) {
2799
- console.log("No tasks to run. Enable either simulation, optimization or analysis.");
2800
- }
2801
- if (opts.doSimulation) {
2802
- await this.runSimulation(opts.simulationOpts || {});
2803
- }
2804
- if (opts.doOptimization) {
2805
- await this.runOptimization(opts.optimizationOpts || { gameModes: [] });
2806
- }
2807
- if (opts.doAnalysis) {
2808
- await this.runAnalysis(opts.analysisOpts || { gameModes: [] });
2809
- }
3117
+ /**
3118
+ * Checks if a symbol appears more than once on any reel in the current reel set.
3119
+ *
3120
+ * Useful to check for "forbidden" generations, e.g. 2 scatters on one reel.
3121
+ */
3122
+ isSymbolOnAnyReelMultipleTimes(symbol) {
3123
+ return this.board.isSymbolOnAnyReelMultipleTimes(symbol);
2810
3124
  }
2811
3125
  /**
2812
- * Gets the game configuration.
3126
+ * Gets all reel stops (positions) where the specified symbol appears in the current reel set.\
3127
+ * Returns an array of arrays, where each inner array contains the positions for the corresponding reel.
2813
3128
  */
2814
- getConfig() {
2815
- return new GameConfig(this.gameConfigOpts);
3129
+ getReelStopsForSymbol(reels, symbol) {
3130
+ return this.board.getReelStopsForSymbol(reels, symbol);
3131
+ }
3132
+ /**
3133
+ * Combines multiple arrays of reel stops into a single array of reel stops.\
3134
+ */
3135
+ combineReelStops(...reelStops) {
3136
+ return this.board.combineReelStops({
3137
+ ctx: this.ctx,
3138
+ reelStops
3139
+ });
3140
+ }
3141
+ /**
3142
+ * From a list of reel stops on reels, selects a random stop for a speficied number of random symbols.
3143
+ *
3144
+ * Mostly useful for placing scatter symbols on the board.
3145
+ */
3146
+ getRandomReelStops(reels, reelStops, amount) {
3147
+ return this.board.getRandomReelStops({
3148
+ ctx: this.ctx,
3149
+ reels,
3150
+ reelsAmount: this.reelsAmount,
3151
+ reelStops,
3152
+ amount
3153
+ });
3154
+ }
3155
+ /**
3156
+ * Selects a random reel set based on the configured weights of the current result set.\
3157
+ * Returns the reels as arrays of GameSymbols.
3158
+ */
3159
+ getRandomReelset() {
3160
+ return this.board.getRandomReelset(this.ctx);
3161
+ }
3162
+ /**
3163
+ * Draws a board using specified reel stops.
3164
+ */
3165
+ drawBoardWithForcedStops(reels, forcedStops) {
3166
+ this.drawBoardMixed(reels, forcedStops);
3167
+ }
3168
+ /**
3169
+ * Draws a board using random reel stops.
3170
+ */
3171
+ drawBoardWithRandomStops(reels) {
3172
+ this.drawBoardMixed(reels);
3173
+ }
3174
+ drawBoardMixed(reels, forcedStops) {
3175
+ this.board.drawBoardMixed({
3176
+ ctx: this.ctx,
3177
+ reels,
3178
+ forcedStops,
3179
+ reelsAmount: this.reelsAmount,
3180
+ symbolsPerReel: this.symbolsPerReel,
3181
+ padSymbols: this.padSymbols
3182
+ });
3183
+ }
3184
+ /**
3185
+ * Tumbles the board. All given symbols will be deleted and new symbols will fall from the top.
3186
+ */
3187
+ tumbleBoard(symbolsToDelete) {
3188
+ this.board.tumbleBoard({
3189
+ ctx: this.ctx,
3190
+ symbolsToDelete,
3191
+ reelsAmount: this.reelsAmount,
3192
+ symbolsPerReel: this.symbolsPerReel,
3193
+ padSymbols: this.padSymbols
3194
+ });
2816
3195
  }
2817
3196
  };
2818
-
2819
- // index.ts
2820
- function createSlotGame(opts) {
2821
- return new SlotGame(opts);
2822
- }
2823
- var defineUserState = (data) => data;
2824
- var defineSymbols = (symbols) => symbols;
2825
- var defineGameModes = (gameModes) => gameModes;
2826
- var defineReelSets = (reelSets) => reelSets;
2827
3197
  export {
2828
3198
  ClusterWinType,
2829
- GameConfig,
2830
3199
  GameMode,
2831
3200
  GameSymbol,
3201
+ GeneratedReelSet,
2832
3202
  LinesWinType,
2833
3203
  ManywaysWinType,
2834
3204
  OptimizationConditions,
2835
3205
  OptimizationParameters,
2836
3206
  OptimizationScaling,
2837
- ReelGenerator,
2838
3207
  ResultSet,
3208
+ SPIN_TYPE,
2839
3209
  StandaloneBoard,
2840
- WinType,
3210
+ StaticReelSet,
2841
3211
  createSlotGame,
2842
3212
  defineGameModes,
2843
- defineReelSets,
2844
3213
  defineSymbols,
2845
- defineUserState,
2846
- weightedRandom
3214
+ defineUserState
2847
3215
  };
2848
3216
  //# sourceMappingURL=index.mjs.map