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