@slot-engine/core 0.1.14 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,11 +1,63 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/constants.ts
2
9
  var SPIN_TYPE = {
3
10
  BASE_GAME: "basegame",
4
11
  FREE_SPINS: "freespins"
5
12
  };
13
+ var CLI_ARGS = {
14
+ RUN: "slot-engine-run"
15
+ };
6
16
 
7
17
  // src/game-config/index.ts
8
18
  import assert from "assert";
19
+
20
+ // src/utils/file-paths.ts
21
+ import path from "path";
22
+ function createPermanentFilePaths(basePath) {
23
+ return {
24
+ base: basePath,
25
+ books: (mode) => path.join(basePath, `books_${mode}.jsonl`),
26
+ booksIndexMeta: (mode) => path.join(basePath, `books_${mode}.index.meta.json`),
27
+ booksIndex: (mode, worker) => path.join(basePath, "books_chunks", `books_${mode}_${worker}.index.txt`),
28
+ booksChunk: (mode, worker, chunk) => path.join(
29
+ basePath,
30
+ "books_chunks",
31
+ `books_${mode}_chunk_${worker}-${chunk}.jsonl.zst`
32
+ ),
33
+ booksCompressed: (mode) => path.join(basePath, "publish_files", `books_${mode}.jsonl.zst`),
34
+ booksUncompressed: (mode) => path.join(basePath, `books_${mode}.jsonl`),
35
+ lookupTable: (mode) => path.join(basePath, `lookUpTable_${mode}.csv`),
36
+ lookupTableIndex: (mode) => path.join(basePath, `lookUpTable_${mode}.index`),
37
+ lookupTableSegmented: (mode) => path.join(basePath, `lookUpTableSegmented_${mode}.csv`),
38
+ lookupTableSegmentedIndex: (mode) => path.join(basePath, `lookUpTableSegmented_${mode}.index`),
39
+ lookupTablePublish: (mode) => path.join(basePath, "publish_files", `lookUpTable_${mode}_0.csv`),
40
+ forceRecords: (mode) => path.join(basePath, `force_record_${mode}.json`),
41
+ forceKeys: (mode) => path.join(basePath, `force_keys_${mode}.json`),
42
+ indexJson: path.join(basePath, "publish_files", "index.json"),
43
+ optimizationFiles: path.join(basePath, "optimization_files"),
44
+ publishFiles: path.join(basePath, "publish_files"),
45
+ simulationSummary: path.join(basePath, "simulation_summary.json"),
46
+ statsPayouts: path.join(basePath, "stats_payouts.json"),
47
+ statsSummary: path.join(basePath, "stats_summary.json")
48
+ };
49
+ }
50
+ function createTemporaryFilePaths(basePath, tempFolder) {
51
+ return {
52
+ tempBooks: (mode, i) => path.join(basePath, tempFolder, `temp_books_${mode}_${i}.jsonl`),
53
+ tempLookupTable: (mode, i) => path.join(basePath, tempFolder, `temp_lookup_${mode}_${i}.csv`),
54
+ tempLookupTableSegmented: (mode, i) => path.join(basePath, tempFolder, `temp_lookup_segmented_${mode}_${i}.csv`),
55
+ tempRecords: (mode) => path.join(basePath, tempFolder, `temp_records_${mode}.jsonl`)
56
+ };
57
+ }
58
+
59
+ // src/game-config/index.ts
60
+ import path2 from "path";
9
61
  function createGameConfig(opts) {
10
62
  const symbols = /* @__PURE__ */ new Map();
11
63
  for (const [key, value] of Object.entries(opts.symbols)) {
@@ -15,70 +67,42 @@ function createGameConfig(opts) {
15
67
  const getAnticipationTrigger = (spinType) => {
16
68
  return Math.min(...Object.keys(opts.scatterToFreespins[spinType] || {}).map(Number)) - 1;
17
69
  };
70
+ const rootDir = opts.rootDir || process.cwd();
71
+ const outputDir = "__build__";
72
+ const basePath = path2.join(rootDir, outputDir);
18
73
  return {
19
- padSymbols: opts.padSymbols || 1,
20
- userState: opts.userState || {},
21
- ...opts,
22
- symbols,
23
- anticipationTriggers: {
24
- [SPIN_TYPE.BASE_GAME]: getAnticipationTrigger(SPIN_TYPE.BASE_GAME),
25
- [SPIN_TYPE.FREE_SPINS]: getAnticipationTrigger(SPIN_TYPE.FREE_SPINS)
74
+ config: {
75
+ padSymbols: opts.padSymbols || 1,
76
+ userState: opts.userState || {},
77
+ ...opts,
78
+ symbols,
79
+ anticipationTriggers: {
80
+ [SPIN_TYPE.BASE_GAME]: getAnticipationTrigger(SPIN_TYPE.BASE_GAME),
81
+ [SPIN_TYPE.FREE_SPINS]: getAnticipationTrigger(SPIN_TYPE.FREE_SPINS)
82
+ }
26
83
  },
27
- outputDir: "__build__",
28
- rootDir: opts.rootDir || process.cwd()
84
+ metadata: {
85
+ outputDir,
86
+ rootDir,
87
+ isCustomRoot: !!opts.rootDir,
88
+ paths: createPermanentFilePaths(basePath)
89
+ }
29
90
  };
30
91
  }
31
92
 
32
93
  // src/simulation/index.ts
33
- import fs2 from "fs";
34
- import path from "path";
35
- import assert7 from "assert";
94
+ import fs3 from "fs";
95
+ import path3 from "path";
96
+ import assert5 from "assert";
36
97
  import zlib from "zlib";
37
- import readline2 from "readline";
98
+ import readline from "readline";
38
99
  import { buildSync } from "esbuild";
39
- import { Worker, isMainThread, parentPort, workerData } from "worker_threads";
100
+ import { Worker, isMainThread as isMainThread2, parentPort as parentPort2, workerData } from "worker_threads";
40
101
 
41
102
  // src/result-set/index.ts
42
103
  import assert2 from "assert";
43
104
 
44
- // src/service/index.ts
45
- var AbstractService = class {
46
- /**
47
- * Function that returns the current game context.
48
- */
49
- ctx;
50
- constructor(ctx) {
51
- this.ctx = ctx;
52
- }
53
- };
54
-
55
- // src/service/rng.ts
56
- var RngService = class extends AbstractService {
57
- rng = new RandomNumberGenerator();
58
- constructor(ctx) {
59
- super(ctx);
60
- }
61
- /**
62
- * Random weighted selection from a set of items.
63
- */
64
- weightedRandom = this.rng.weightedRandom.bind(this.rng);
65
- /**
66
- * Selects a random item from an array.
67
- */
68
- randomItem = this.rng.randomItem.bind(this.rng);
69
- /**
70
- * Shuffles an array.
71
- */
72
- shuffle = this.rng.shuffle.bind(this.rng);
73
- /**
74
- * Generates a random float between two values.
75
- */
76
- randomFloat = this.rng.randomFloat.bind(this.rng);
77
- /**
78
- * Sets the seed for the RNG.
79
- */
80
- setSeedIfDifferent = this.rng.setSeedIfDifferent.bind(this.rng);
81
- };
105
+ // src/rng/index.ts
82
106
  var RandomNumberGenerator = class {
83
107
  mIdum;
84
108
  mIy;
@@ -270,10 +294,10 @@ var ResultSet = class {
270
294
  const freespinsMet = this.forceFreespins ? ctx.state.triggeredFreespins : true;
271
295
  const wallet = ctx.services.wallet._getWallet();
272
296
  const multiplierMet = this.forceMaxWin ? true : this.multiplier !== void 0 ? wallet.getCurrentWin() === this.multiplier : wallet.getCurrentWin() > 0;
273
- const maxWinMet = this.forceMaxWin ? wallet.getCurrentWin() >= ctx.config.maxWinX : true;
274
- const coreCriteriaMet = freespinsMet && multiplierMet && maxWinMet;
297
+ const respectsMaxWin = this.forceMaxWin ? wallet.getCurrentWin() >= ctx.config.maxWinX : wallet.getCurrentWin() < ctx.config.maxWinX;
298
+ const coreCriteriaMet = freespinsMet && multiplierMet && respectsMaxWin;
275
299
  const finalResult = customEval !== void 0 ? coreCriteriaMet && customEval === true : coreCriteriaMet;
276
- if (this.forceMaxWin && maxWinMet) {
300
+ if (this.forceMaxWin && respectsMaxWin) {
277
301
  ctx.services.data.record({
278
302
  maxwin: true
279
303
  });
@@ -308,13 +332,34 @@ function createGameState(opts) {
308
332
  // src/recorder/index.ts
309
333
  var Recorder = class {
310
334
  records;
335
+ recordsMap;
311
336
  pendingRecords;
312
337
  constructor() {
313
338
  this.records = [];
339
+ this.recordsMap = /* @__PURE__ */ new Map();
340
+ this.pendingRecords = [];
341
+ }
342
+ /**
343
+ * Intended for internal use only.
344
+ */
345
+ _reset() {
346
+ this.records = [];
347
+ this.recordsMap.clear();
314
348
  this.pendingRecords = [];
315
349
  }
316
350
  };
317
351
 
352
+ // src/service/index.ts
353
+ var AbstractService = class {
354
+ /**
355
+ * Function that returns the current game context.
356
+ */
357
+ ctx;
358
+ constructor(ctx) {
359
+ this.ctx = ctx;
360
+ }
361
+ };
362
+
318
363
  // src/board/index.ts
319
364
  import assert3 from "assert";
320
365
 
@@ -903,19 +948,13 @@ var BoardService = class extends AbstractService {
903
948
  };
904
949
 
905
950
  // src/service/data.ts
906
- import assert4 from "assert";
951
+ import { isMainThread, parentPort } from "worker_threads";
907
952
  var DataService = class extends AbstractService {
908
953
  recorder;
909
954
  book;
910
955
  constructor(ctx) {
911
956
  super(ctx);
912
957
  }
913
- ensureRecorder() {
914
- assert4(this.recorder, "Recorder not set in DataService. Call setRecorder() first.");
915
- }
916
- ensureBook() {
917
- assert4(this.book, "Book not set in DataService. Call setBook() first.");
918
- }
919
958
  /**
920
959
  * Intended for internal use only.
921
960
  */
@@ -944,14 +983,12 @@ var DataService = class extends AbstractService {
944
983
  * Intended for internal use only.
945
984
  */
946
985
  _getRecords() {
947
- this.ensureRecorder();
948
986
  return this.recorder.records;
949
987
  }
950
988
  /**
951
989
  * Record data for statistical analysis.
952
990
  */
953
991
  record(data) {
954
- this.ensureRecorder();
955
992
  this.recorder.pendingRecords.push({
956
993
  bookId: this.ctx().state.currentSimulationId,
957
994
  properties: Object.fromEntries(
@@ -971,14 +1008,19 @@ var DataService = class extends AbstractService {
971
1008
  * Adds an event to the book.
972
1009
  */
973
1010
  addBookEvent(event) {
974
- this.ensureBook();
975
1011
  this.book.addEvent(event);
976
1012
  }
1013
+ /**
1014
+ * Write a log message to the terminal UI.
1015
+ */
1016
+ log(message) {
1017
+ if (isMainThread) return;
1018
+ parentPort?.postMessage({ type: "user-log", message });
1019
+ }
977
1020
  /**
978
1021
  * Intended for internal use only.
979
1022
  */
980
1023
  _clearPendingRecords() {
981
- this.ensureRecorder();
982
1024
  this.recorder.pendingRecords = [];
983
1025
  }
984
1026
  };
@@ -1119,21 +1161,44 @@ var GameService = class extends AbstractService {
1119
1161
  }
1120
1162
  };
1121
1163
 
1164
+ // src/service/rng.ts
1165
+ var RngService = class extends AbstractService {
1166
+ rng = new RandomNumberGenerator();
1167
+ constructor(ctx) {
1168
+ super(ctx);
1169
+ }
1170
+ /**
1171
+ * Random weighted selection from a set of items.
1172
+ */
1173
+ weightedRandom = this.rng.weightedRandom.bind(this.rng);
1174
+ /**
1175
+ * Selects a random item from an array.
1176
+ */
1177
+ randomItem = this.rng.randomItem.bind(this.rng);
1178
+ /**
1179
+ * Shuffles an array.
1180
+ */
1181
+ shuffle = this.rng.shuffle.bind(this.rng);
1182
+ /**
1183
+ * Generates a random float between two values.
1184
+ */
1185
+ randomFloat = this.rng.randomFloat.bind(this.rng);
1186
+ /**
1187
+ * Sets the seed for the RNG.
1188
+ */
1189
+ setSeedIfDifferent = this.rng.setSeedIfDifferent.bind(this.rng);
1190
+ };
1191
+
1122
1192
  // src/service/wallet.ts
1123
- import assert5 from "assert";
1124
1193
  var WalletService = class extends AbstractService {
1125
1194
  wallet;
1126
1195
  constructor(ctx) {
1127
1196
  super(ctx);
1128
1197
  }
1129
- ensureWallet() {
1130
- assert5(this.wallet, "Wallet not set in WalletService. Call setWallet() first.");
1131
- }
1132
1198
  /**
1133
1199
  * Intended for internal use only.
1134
1200
  */
1135
1201
  _getWallet() {
1136
- this.ensureWallet();
1137
1202
  return this.wallet;
1138
1203
  }
1139
1204
  /**
@@ -1149,7 +1214,6 @@ var WalletService = class extends AbstractService {
1149
1214
  * If your game has tumbling mechanics, you should call this method again after every new tumble and win calculation.
1150
1215
  */
1151
1216
  addSpinWin(amount) {
1152
- this.ensureWallet();
1153
1217
  this.wallet.addSpinWin(amount);
1154
1218
  }
1155
1219
  /**
@@ -1158,7 +1222,6 @@ var WalletService = class extends AbstractService {
1158
1222
  * This also calls `addSpinWin()` internally, to add the tumble win to the overall spin win.
1159
1223
  */
1160
1224
  addTumbleWin(amount) {
1161
- this.ensureWallet();
1162
1225
  this.wallet.addTumbleWin(amount);
1163
1226
  }
1164
1227
  /**
@@ -1168,28 +1231,24 @@ var WalletService = class extends AbstractService {
1168
1231
  * and after a (free) spin is played out to finalize the win.
1169
1232
  */
1170
1233
  confirmSpinWin() {
1171
- this.ensureWallet();
1172
1234
  this.wallet.confirmSpinWin(this.ctx().state.currentSpinType);
1173
1235
  }
1174
1236
  /**
1175
1237
  * Gets the total win amount of the current simulation.
1176
1238
  */
1177
1239
  getCurrentWin() {
1178
- this.ensureWallet();
1179
1240
  return this.wallet.getCurrentWin();
1180
1241
  }
1181
1242
  /**
1182
1243
  * Gets the current spin win amount of the ongoing spin.
1183
1244
  */
1184
1245
  getCurrentSpinWin() {
1185
- this.ensureWallet();
1186
1246
  return this.wallet.getCurrentSpinWin();
1187
1247
  }
1188
1248
  /**
1189
1249
  * Gets the current tumble win amount of the ongoing spin.
1190
1250
  */
1191
1251
  getCurrentTumbleWin() {
1192
- this.ensureWallet();
1193
1252
  return this.wallet.getCurrentTumbleWin();
1194
1253
  }
1195
1254
  };
@@ -1218,7 +1277,6 @@ function createGameContext(opts) {
1218
1277
 
1219
1278
  // utils.ts
1220
1279
  import fs from "fs";
1221
- import readline from "readline";
1222
1280
  function createDirIfNotExists(dirPath) {
1223
1281
  if (!fs.existsSync(dirPath)) {
1224
1282
  fs.mkdirSync(dirPath, { recursive: true });
@@ -1243,37 +1301,6 @@ function writeFile(filePath, data) {
1243
1301
  function copy(obj) {
1244
1302
  return JSON.parse(JSON.stringify(obj));
1245
1303
  }
1246
- var JSONL = class {
1247
- static stringify(array) {
1248
- return array.map((object) => JSON.stringify(object)).join("\n");
1249
- }
1250
- static parse(jsonl) {
1251
- return jsonl.split("\n").filter((s) => s !== "").map((str) => JSON.parse(str));
1252
- }
1253
- static async convertToJson(inputPath, outputPath) {
1254
- const writeStream = fs.createWriteStream(outputPath, { encoding: "utf-8" });
1255
- writeStream.write("[\n");
1256
- const rl = readline.createInterface({
1257
- input: fs.createReadStream(inputPath),
1258
- crlfDelay: Infinity
1259
- });
1260
- let isFirst = true;
1261
- for await (const line of rl) {
1262
- if (line.trim() === "") continue;
1263
- if (!isFirst) {
1264
- writeStream.write(",\n");
1265
- }
1266
- writeStream.write(line);
1267
- isFirst = false;
1268
- }
1269
- writeStream.write("\n]");
1270
- writeStream.end();
1271
- return new Promise((resolve, reject) => {
1272
- writeStream.on("finish", () => resolve());
1273
- writeStream.on("error", reject);
1274
- });
1275
- }
1276
- };
1277
1304
  function round(value, decimals) {
1278
1305
  return Number(Math.round(Number(value + "e" + decimals)) + "e-" + decimals);
1279
1306
  }
@@ -1293,16 +1320,26 @@ var Book = class {
1293
1320
  /**
1294
1321
  * Intended for internal use only.
1295
1322
  */
1296
- setCriteria(criteria) {
1323
+ _reset(id, criteria) {
1324
+ this.id = id;
1325
+ this.criteria = criteria;
1326
+ this.events = [];
1327
+ this.payout = 0;
1328
+ this.basegameWins = 0;
1329
+ this.freespinsWins = 0;
1330
+ }
1331
+ /**
1332
+ * Intended for internal use only.
1333
+ */
1334
+ _setCriteria(criteria) {
1297
1335
  this.criteria = criteria;
1298
1336
  }
1299
1337
  /**
1300
1338
  * Adds an event to the book.
1301
1339
  */
1302
1340
  addEvent(event) {
1303
- const index = this.events.length + 1;
1304
1341
  this.events.push({
1305
- index,
1342
+ index: this.events.length + 1,
1306
1343
  type: event.type,
1307
1344
  data: copy(event.data)
1308
1345
  });
@@ -1310,7 +1347,7 @@ var Book = class {
1310
1347
  /**
1311
1348
  * Intended for internal use only.
1312
1349
  */
1313
- serialize() {
1350
+ _serialize() {
1314
1351
  return {
1315
1352
  id: this.id,
1316
1353
  criteria: this.criteria,
@@ -1375,6 +1412,23 @@ var Wallet = class {
1375
1412
  currentTumbleWin = 0;
1376
1413
  constructor() {
1377
1414
  }
1415
+ /**
1416
+ * Intended for internal use only.
1417
+ */
1418
+ _reset() {
1419
+ this.cumulativeWins = 0;
1420
+ this.cumulativeWinsPerSpinType = {
1421
+ [SPIN_TYPE.BASE_GAME]: 0,
1422
+ [SPIN_TYPE.FREE_SPINS]: 0
1423
+ };
1424
+ this.currentWin = 0;
1425
+ this.currentWinPerSpinType = {
1426
+ [SPIN_TYPE.BASE_GAME]: 0,
1427
+ [SPIN_TYPE.FREE_SPINS]: 0
1428
+ };
1429
+ this.currentSpinWin = 0;
1430
+ this.currentTumbleWin = 0;
1431
+ }
1378
1432
  /**
1379
1433
  * Updates the win for the current spin.
1380
1434
  *
@@ -1547,7 +1601,9 @@ var Wallet = class {
1547
1601
  import { pipeline } from "stream/promises";
1548
1602
 
1549
1603
  // src/simulation/utils.ts
1550
- import assert6 from "assert";
1604
+ import fs2 from "fs";
1605
+ import assert4 from "assert";
1606
+ import chalk from "chalk";
1551
1607
  function hashStringToInt(input) {
1552
1608
  let h = 2166136261;
1553
1609
  for (let i = 0; i < input.length; i++) {
@@ -1560,7 +1616,7 @@ function splitCountsAcrossChunks(totalCounts, chunkSizes) {
1560
1616
  const total = chunkSizes.reduce((a, b) => a + b, 0);
1561
1617
  const allCriteria = Object.keys(totalCounts);
1562
1618
  const totalCountsSum = allCriteria.reduce((s, c) => s + (totalCounts[c] ?? 0), 0);
1563
- assert6(
1619
+ assert4(
1564
1620
  totalCountsSum === total,
1565
1621
  `Counts (${totalCountsSum}) must match chunk total (${total}).`
1566
1622
  );
@@ -1593,9 +1649,9 @@ function splitCountsAcrossChunks(totalCounts, chunkSizes) {
1593
1649
  for (let target = 0; target < chunkSizes.length; target++) {
1594
1650
  while (deficits[target] > 0) {
1595
1651
  const src = deficits.findIndex((d) => d < 0);
1596
- assert6(src !== -1, "No surplus chunk found, but deficits remain.");
1652
+ assert4(src !== -1, "No surplus chunk found, but deficits remain.");
1597
1653
  const crit = allCriteria.find((c) => (perChunk[src][c] ?? 0) > 0);
1598
- assert6(crit, `No movable criteria found from surplus chunk ${src}.`);
1654
+ assert4(crit, `No movable criteria found from surplus chunk ${src}.`);
1599
1655
  perChunk[src][crit] -= 1;
1600
1656
  perChunk[target][crit] = (perChunk[target][crit] ?? 0) + 1;
1601
1657
  totals[src] -= 1;
@@ -1606,14 +1662,14 @@ function splitCountsAcrossChunks(totalCounts, chunkSizes) {
1606
1662
  }
1607
1663
  totals = chunkTotals();
1608
1664
  for (let i = 0; i < chunkSizes.length; i++) {
1609
- assert6(
1665
+ assert4(
1610
1666
  totals[i] === chunkSizes[i],
1611
1667
  `Chunk ${i} size mismatch. Expected ${chunkSizes[i]}, got ${totals[i]}`
1612
1668
  );
1613
1669
  }
1614
1670
  for (const c of allCriteria) {
1615
1671
  const sum = perChunk.reduce((s, m) => s + (m[c] ?? 0), 0);
1616
- assert6(sum === (totalCounts[c] ?? 0), `Chunk split mismatch for criteria "${c}"`);
1672
+ assert4(sum === (totalCounts[c] ?? 0), `Chunk split mismatch for criteria "${c}"`);
1617
1673
  }
1618
1674
  return perChunk;
1619
1675
  }
@@ -1644,8 +1700,306 @@ function createCriteriaSampler(counts, seed) {
1644
1700
  return keys.find((k) => (remaining[k] ?? 0) > 0) ?? "N/A";
1645
1701
  };
1646
1702
  }
1703
+ async function makeLutIndexFromPublishLut(lutPublishPath, lutIndexPath) {
1704
+ console.log(chalk.gray(`Regenerating LUT index file...`));
1705
+ if (!fs2.existsSync(lutPublishPath)) {
1706
+ console.warn(
1707
+ chalk.yellow(
1708
+ `LUT publish file does not exist when regenerating index file: ${lutPublishPath}`
1709
+ )
1710
+ );
1711
+ return;
1712
+ }
1713
+ try {
1714
+ const lutPublishStream = fs2.createReadStream(lutPublishPath, {
1715
+ highWaterMark: 500 * 1024 * 1024
1716
+ });
1717
+ const rl = __require("readline").createInterface({
1718
+ input: lutPublishStream,
1719
+ crlfDelay: Infinity
1720
+ });
1721
+ const lutIndexStream = fs2.createWriteStream(lutIndexPath, {
1722
+ highWaterMark: 500 * 1024 * 1024
1723
+ });
1724
+ let offset = 0n;
1725
+ for await (const line of rl) {
1726
+ if (!line.trim()) continue;
1727
+ const indexBuffer = Buffer.alloc(8);
1728
+ indexBuffer.writeBigUInt64LE(offset);
1729
+ if (!lutIndexStream.write(indexBuffer)) {
1730
+ await new Promise((resolve) => lutIndexStream.once("drain", resolve));
1731
+ }
1732
+ offset += BigInt(Buffer.byteLength(line + "\n", "utf8"));
1733
+ }
1734
+ lutIndexStream.end();
1735
+ await new Promise((resolve) => lutIndexStream.on("finish", resolve));
1736
+ } catch (error) {
1737
+ throw new Error(`Error generating LUT index from publish LUT: ${error}`);
1738
+ }
1739
+ }
1647
1740
 
1648
1741
  // src/simulation/index.ts
1742
+ import { io } from "socket.io-client";
1743
+ import chalk2 from "chalk";
1744
+
1745
+ // src/tui/index.ts
1746
+ var TerminalUi = class {
1747
+ progress = 0;
1748
+ timeRemaining = 0;
1749
+ gameMode;
1750
+ currentSims = 0;
1751
+ totalSims = 0;
1752
+ logs = [];
1753
+ logScrollOffset = 0;
1754
+ isScrolled = false;
1755
+ maxLogs = 500;
1756
+ totalLogs = 0;
1757
+ minWidth = 50;
1758
+ minHeight = 12;
1759
+ isRendering = false;
1760
+ renderInterval = null;
1761
+ resizeHandler;
1762
+ sigintHandler;
1763
+ keyHandler;
1764
+ constructor(opts) {
1765
+ this.gameMode = opts.gameMode;
1766
+ this.resizeHandler = () => {
1767
+ this.clearScreen();
1768
+ this.render();
1769
+ };
1770
+ this.sigintHandler = () => {
1771
+ this.stop();
1772
+ process.exit(0);
1773
+ };
1774
+ this.keyHandler = (data) => {
1775
+ const key = data.toString();
1776
+ if (key === "j" || key === "\x1B[A") {
1777
+ this.scrollUp();
1778
+ } else if (key === "k" || key === "\x1B[B") {
1779
+ this.scrollDown();
1780
+ } else if (key === "l") {
1781
+ this.scrollToBottom();
1782
+ } else if (key === "q" || key === "") {
1783
+ this.stop();
1784
+ process.exit(0);
1785
+ }
1786
+ };
1787
+ process.stdout.on("resize", this.resizeHandler);
1788
+ process.on("SIGINT", this.sigintHandler);
1789
+ }
1790
+ get terminalWidth() {
1791
+ return process.stdout.columns || 80;
1792
+ }
1793
+ get terminalHeight() {
1794
+ return process.stdout.rows || 24;
1795
+ }
1796
+ get isTooSmall() {
1797
+ return this.terminalWidth < this.minWidth || this.terminalHeight < this.minHeight;
1798
+ }
1799
+ start() {
1800
+ this.enterAltScreen();
1801
+ this.hideCursor();
1802
+ this.clearScreen();
1803
+ if (process.stdin.isTTY) {
1804
+ process.stdin.setRawMode(true);
1805
+ process.stdin.resume();
1806
+ process.stdin.on("data", this.keyHandler);
1807
+ }
1808
+ this.render();
1809
+ this.renderInterval = setInterval(() => this.render(), 100);
1810
+ }
1811
+ stop() {
1812
+ if (this.renderInterval) {
1813
+ clearInterval(this.renderInterval);
1814
+ this.renderInterval = null;
1815
+ }
1816
+ if (process.stdin.isTTY) {
1817
+ process.stdin.off("data", this.keyHandler);
1818
+ process.stdin.setRawMode(false);
1819
+ process.stdin.pause();
1820
+ }
1821
+ this.showCursor();
1822
+ this.clearScreen();
1823
+ this.exitAltScreen();
1824
+ process.stdout.off("resize", this.resizeHandler);
1825
+ process.off("SIGINT", this.sigintHandler);
1826
+ }
1827
+ setProgress(progress, timeRemaining, completedSims) {
1828
+ this.progress = Math.max(0, Math.min(100, progress));
1829
+ this.timeRemaining = Math.max(0, timeRemaining);
1830
+ this.currentSims = completedSims;
1831
+ }
1832
+ setDetails(opts) {
1833
+ this.gameMode = opts.gameMode;
1834
+ this.totalSims = opts.totalSims;
1835
+ }
1836
+ log(message) {
1837
+ this.logs.push({ i: this.totalLogs, m: message });
1838
+ this.totalLogs++;
1839
+ if (this.logs.length > this.maxLogs) {
1840
+ const excess = this.logs.length - this.maxLogs;
1841
+ this.logs.splice(0, excess);
1842
+ this.logScrollOffset = Math.min(
1843
+ this.logScrollOffset,
1844
+ Math.max(0, this.logs.length - this.getLogAreaHeight())
1845
+ );
1846
+ }
1847
+ if (!this.isScrolled) this.scrollToBottom();
1848
+ }
1849
+ scrollUp(lines = 1) {
1850
+ this.logScrollOffset = Math.max(0, this.logScrollOffset - lines);
1851
+ this.isScrolled = true;
1852
+ }
1853
+ scrollDown(lines = 1) {
1854
+ const maxOffset = Math.max(0, this.logs.length - this.getLogAreaHeight());
1855
+ this.logScrollOffset = Math.min(maxOffset, this.logScrollOffset + lines);
1856
+ if (this.logScrollOffset >= maxOffset) {
1857
+ this.isScrolled = false;
1858
+ }
1859
+ }
1860
+ scrollToBottom() {
1861
+ const maxOffset = Math.max(0, this.logs.length - this.getLogAreaHeight());
1862
+ this.logScrollOffset = maxOffset;
1863
+ this.isScrolled = false;
1864
+ }
1865
+ clearLogs() {
1866
+ this.logs = [];
1867
+ this.logScrollOffset = 0;
1868
+ }
1869
+ getLogAreaHeight() {
1870
+ return Math.max(1, this.terminalHeight - 8);
1871
+ }
1872
+ enterAltScreen() {
1873
+ process.stdout.write("\x1B[?1049h");
1874
+ }
1875
+ exitAltScreen() {
1876
+ process.stdout.write("\x1B[?1049l");
1877
+ }
1878
+ hideCursor() {
1879
+ process.stdout.write("\x1B[?25l");
1880
+ }
1881
+ showCursor() {
1882
+ process.stdout.write("\x1B[?25h");
1883
+ }
1884
+ clearScreen() {
1885
+ process.stdout.write("\x1B[2J\x1B[H");
1886
+ }
1887
+ moveTo(row, col) {
1888
+ process.stdout.write(`\x1B[${row};${col}H`);
1889
+ }
1890
+ render() {
1891
+ if (this.isRendering) return;
1892
+ this.isRendering = true;
1893
+ try {
1894
+ this.moveTo(1, 1);
1895
+ if (this.isTooSmall) {
1896
+ this.clearScreen();
1897
+ let msg = "Terminal too small.";
1898
+ let row = Math.floor(this.terminalHeight / 2);
1899
+ let col = Math.max(1, Math.floor((this.terminalWidth - msg.length) / 2));
1900
+ this.moveTo(row, col);
1901
+ process.stdout.write(msg);
1902
+ msg = "Try resizing or restarting the terminal.";
1903
+ row += 1;
1904
+ col = Math.max(1, Math.floor((this.terminalWidth - msg.length) / 2));
1905
+ this.moveTo(row, col);
1906
+ process.stdout.write(msg);
1907
+ return;
1908
+ }
1909
+ const lines = [];
1910
+ const width = this.terminalWidth;
1911
+ lines.push(this.boxLine("top", width));
1912
+ const canScrollUp = this.logScrollOffset > 0;
1913
+ const topHint = canScrollUp ? "\u2191 scroll up (j)" : "";
1914
+ lines.push(this.contentLine(this.centerText(topHint, width - 2), width));
1915
+ const logAreaHeight = this.getLogAreaHeight();
1916
+ const visibleLogs = this.getVisibleLogs(logAreaHeight);
1917
+ for (const log of visibleLogs) {
1918
+ lines.push(
1919
+ this.contentLine(` (${log.i}) ` + this.truncate(log.m, width - 4), width)
1920
+ );
1921
+ }
1922
+ for (let i = visibleLogs.length; i < logAreaHeight; i++) {
1923
+ lines.push(this.contentLine("", width));
1924
+ }
1925
+ const canScrollDown = this.logScrollOffset < Math.max(0, this.logs.length - logAreaHeight);
1926
+ const bottomHint = canScrollDown ? "\u2193 scroll down (k) \u2193 jump to newest (l)" : "";
1927
+ lines.push(this.contentLine(this.centerText(bottomHint, width - 2), width));
1928
+ lines.push(this.boxLine("middle", width));
1929
+ const modeText = `Mode: ${this.gameMode}`;
1930
+ const simsText = `${this.currentSims}/${this.totalSims}`;
1931
+ const infoLine = this.createInfoLine(modeText, simsText, width);
1932
+ lines.push(infoLine);
1933
+ lines.push(this.boxLine("middle", width));
1934
+ lines.push(this.createProgressLine(width));
1935
+ lines.push(this.boxLine("bottom", width));
1936
+ this.moveTo(1, 1);
1937
+ process.stdout.write(lines.join("\n"));
1938
+ } finally {
1939
+ this.isRendering = false;
1940
+ }
1941
+ }
1942
+ getVisibleLogs(height) {
1943
+ const start = this.logScrollOffset;
1944
+ const end = start + height;
1945
+ return this.logs.slice(start, end);
1946
+ }
1947
+ boxLine(type, width) {
1948
+ const chars = {
1949
+ top: { left: "\u250C", right: "\u2510", fill: "\u2500" },
1950
+ middle: { left: "\u251C", right: "\u2524", fill: "\u2500" },
1951
+ bottom: { left: "\u2514", right: "\u2518", fill: "\u2500" }
1952
+ };
1953
+ const c = chars[type];
1954
+ return c.left + c.fill.repeat(width - 2) + c.right;
1955
+ }
1956
+ contentLine(content, width) {
1957
+ const innerWidth = width - 2;
1958
+ const paddedContent = content.padEnd(innerWidth).slice(0, innerWidth);
1959
+ return "\u2502" + paddedContent + "\u2502";
1960
+ }
1961
+ createInfoLine(left, right, width) {
1962
+ const innerWidth = width - 2;
1963
+ const separator = " \u2502 ";
1964
+ const availableForText = innerWidth - separator.length;
1965
+ const leftWidth = Math.floor(availableForText / 2);
1966
+ const rightWidth = availableForText - leftWidth;
1967
+ const leftTruncated = this.truncate(left, leftWidth);
1968
+ const rightTruncated = this.truncate(right, rightWidth);
1969
+ const leftPadded = this.centerText(leftTruncated, leftWidth);
1970
+ const rightPadded = this.centerText(rightTruncated, rightWidth);
1971
+ return "\u2502" + leftPadded + separator + rightPadded + "\u2502";
1972
+ }
1973
+ createProgressLine(width) {
1974
+ const innerWidth = width - 2;
1975
+ const timeStr = this.formatTime(this.timeRemaining);
1976
+ const percentStr = `${this.progress.toFixed(2)}%`;
1977
+ const rightInfo = `${timeStr} ${percentStr}`;
1978
+ const barWidth = Math.max(10, innerWidth - rightInfo.length - 3);
1979
+ const filledWidth = Math.round(this.progress / 100 * barWidth);
1980
+ const emptyWidth = barWidth - filledWidth;
1981
+ const bar = "\u2588".repeat(filledWidth) + "-".repeat(emptyWidth);
1982
+ const content = ` ${bar} ${rightInfo}`;
1983
+ return this.contentLine(content, width);
1984
+ }
1985
+ formatTime(seconds) {
1986
+ return new Date(seconds * 1e3).toISOString().substr(11, 8);
1987
+ }
1988
+ centerText(text, width) {
1989
+ if (text.length >= width) return text.slice(0, width);
1990
+ const padding = width - text.length;
1991
+ const leftPad = Math.floor(padding / 2);
1992
+ const rightPad = padding - leftPad;
1993
+ return " ".repeat(leftPad) + text + " ".repeat(rightPad);
1994
+ }
1995
+ truncate(text, maxLength) {
1996
+ if (text.length <= maxLength) return text;
1997
+ return text.slice(0, maxLength - 3) + "...";
1998
+ }
1999
+ };
2000
+
2001
+ // src/simulation/index.ts
2002
+ import { Readable } from "stream";
1649
2003
  var completedSimulations = 0;
1650
2004
  var TEMP_FILENAME = "__temp_compiled_src_IGNORE.js";
1651
2005
  var TEMP_FOLDER = "temp_files";
@@ -1654,70 +2008,114 @@ var Simulation = class {
1654
2008
  gameConfig;
1655
2009
  simRunsAmount;
1656
2010
  concurrency;
2011
+ makeUncompressedBooks;
1657
2012
  debug = false;
1658
2013
  actualSims = 0;
1659
- wallet;
2014
+ wallet = new Wallet();
2015
+ summary = {};
1660
2016
  recordsWriteStream;
1661
2017
  hasWrittenRecord = false;
1662
2018
  streamHighWaterMark = 500 * 1024 * 1024;
1663
2019
  maxPendingSims;
1664
2020
  maxHighWaterMark;
2021
+ panelPort = 7770;
2022
+ panelActive = false;
2023
+ panelWsUrl;
2024
+ socket;
2025
+ tui;
2026
+ tempBookIndexPaths = [];
2027
+ bookIndexMetas = [];
1665
2028
  PATHS = {};
1666
2029
  // Worker related
1667
2030
  credits = 0;
1668
2031
  creditWaiters = [];
1669
2032
  creditListenerInit = false;
2033
+ bookBuffers = /* @__PURE__ */ new Map();
2034
+ bookBufferSizes = /* @__PURE__ */ new Map();
2035
+ bookChunkIndexes = /* @__PURE__ */ new Map();
1670
2036
  constructor(opts, gameConfigOpts) {
1671
- this.gameConfig = createGameConfig(gameConfigOpts);
2037
+ const { config, metadata } = createGameConfig(gameConfigOpts);
2038
+ this.gameConfig = { ...config, ...metadata };
1672
2039
  this.gameConfigOpts = gameConfigOpts;
2040
+ this.makeUncompressedBooks = opts.makeUncompressedBooks || false;
1673
2041
  this.simRunsAmount = opts.simRunsAmount || {};
1674
2042
  this.concurrency = (opts.concurrency || 6) >= 2 ? opts.concurrency || 6 : 2;
1675
- this.wallet = new Wallet();
1676
- this.maxPendingSims = Math.max(10, opts.maxPendingSims ?? 250);
1677
- this.maxHighWaterMark = (opts.maxDiskBuffer ?? 50) * 1024 * 1024;
2043
+ this.maxPendingSims = opts.maxPendingSims ?? 25;
2044
+ this.maxHighWaterMark = (opts.maxDiskBuffer ?? 150) * 1024 * 1024;
1678
2045
  const gameModeKeys = Object.keys(this.gameConfig.gameModes);
1679
- assert7(
2046
+ assert5(
1680
2047
  Object.values(this.gameConfig.gameModes).map((m) => gameModeKeys.includes(m.name)).every((v) => v === true),
1681
2048
  "Game mode name must match its key in the gameModes object."
1682
2049
  );
1683
- this.PATHS.base = path.join(this.gameConfig.rootDir, this.gameConfig.outputDir);
2050
+ const basePath = path3.join(this.gameConfig.rootDir, this.gameConfig.outputDir);
1684
2051
  this.PATHS = {
1685
- ...this.PATHS,
1686
- books: (mode) => path.join(this.PATHS.base, `books_${mode}.jsonl`),
1687
- booksCompressed: (mode) => path.join(this.PATHS.base, "publish_files", `books_${mode}.jsonl.zst`),
1688
- tempBooks: (mode, i) => path.join(this.PATHS.base, TEMP_FOLDER, `temp_books_${mode}_${i}.jsonl`),
1689
- lookupTable: (mode) => path.join(this.PATHS.base, `lookUpTable_${mode}.csv`),
1690
- tempLookupTable: (mode, i) => path.join(this.PATHS.base, TEMP_FOLDER, `temp_lookup_${mode}_${i}.csv`),
1691
- lookupTableSegmented: (mode) => path.join(this.PATHS.base, `lookUpTableSegmented_${mode}.csv`),
1692
- tempLookupTableSegmented: (mode, i) => path.join(this.PATHS.base, TEMP_FOLDER, `temp_lookup_segmented_${mode}_${i}.csv`),
1693
- lookupTablePublish: (mode) => path.join(this.PATHS.base, "publish_files", `lookUpTable_${mode}_0.csv`),
1694
- tempRecords: (mode) => path.join(this.PATHS.base, TEMP_FOLDER, `temp_records_${mode}.jsonl`),
1695
- forceRecords: (mode) => path.join(this.PATHS.base, `force_record_${mode}.json`),
1696
- indexJson: path.join(this.PATHS.base, "publish_files", "index.json"),
1697
- optimizationFiles: path.join(this.PATHS.base, "optimization_files"),
1698
- publishFiles: path.join(this.PATHS.base, "publish_files")
2052
+ ...createPermanentFilePaths(basePath),
2053
+ ...createTemporaryFilePaths(basePath, TEMP_FOLDER)
1699
2054
  };
2055
+ this.tui = new TerminalUi({
2056
+ gameMode: "N/A"
2057
+ });
1700
2058
  }
1701
2059
  async runSimulation(opts) {
1702
2060
  const debug = opts.debug || false;
1703
2061
  this.debug = debug;
2062
+ let statusMessage = "";
1704
2063
  const gameModesToSimulate = Object.keys(this.simRunsAmount);
1705
2064
  const configuredGameModes = Object.keys(this.gameConfig.gameModes);
1706
2065
  if (gameModesToSimulate.length === 0) {
1707
2066
  throw new Error("No game modes configured for simulation.");
1708
2067
  }
1709
2068
  this.generateReelsetFiles();
1710
- if (isMainThread) {
2069
+ if (isMainThread2) {
1711
2070
  this.preprocessFiles();
1712
- const debugDetails = {};
2071
+ this.panelPort = opts.panelPort || 7770;
2072
+ this.panelWsUrl = `http://localhost:${this.panelPort}`;
2073
+ await new Promise((resolve) => {
2074
+ try {
2075
+ this.socket = io(this.panelWsUrl, {
2076
+ path: "/ws",
2077
+ transports: ["websocket", "polling"],
2078
+ withCredentials: true,
2079
+ autoConnect: false,
2080
+ reconnection: false
2081
+ });
2082
+ this.socket.connect();
2083
+ this.socket.once("connect", () => {
2084
+ this.panelActive = true;
2085
+ resolve();
2086
+ });
2087
+ this.socket.once("connect_error", () => {
2088
+ this.socket?.close();
2089
+ this.socket = void 0;
2090
+ resolve();
2091
+ });
2092
+ } catch (error) {
2093
+ this.socket = void 0;
2094
+ resolve();
2095
+ }
2096
+ });
2097
+ this.tui?.start();
2098
+ fs3.rmSync(path3.join(this.PATHS.base, "books_chunks"), {
2099
+ recursive: true,
2100
+ force: true
2101
+ });
1713
2102
  for (const mode of gameModesToSimulate) {
1714
2103
  completedSimulations = 0;
1715
2104
  this.wallet = new Wallet();
2105
+ this.tui?.setDetails({
2106
+ gameMode: mode,
2107
+ totalSims: this.simRunsAmount[mode] || 0
2108
+ });
1716
2109
  this.hasWrittenRecord = false;
1717
- debugDetails[mode] = {};
1718
- console.log(`
1719
- Simulating game mode: ${mode}`);
1720
- console.time(mode);
2110
+ this.bookIndexMetas = [];
2111
+ this.tempBookIndexPaths = [];
2112
+ this.bookChunkIndexes = /* @__PURE__ */ new Map();
2113
+ this.bookBuffers = /* @__PURE__ */ new Map();
2114
+ this.bookBufferSizes = /* @__PURE__ */ new Map();
2115
+ const startTime = Date.now();
2116
+ statusMessage = `Simulating mode "${mode}" with ${this.simRunsAmount[mode]} runs.`;
2117
+ this.tui?.log(statusMessage);
2118
+ this.sendSimulationStatus(statusMessage);
1721
2119
  const runs = this.simRunsAmount[mode] || 0;
1722
2120
  if (runs <= 0) continue;
1723
2121
  if (!configuredGameModes.includes(mode)) {
@@ -1725,16 +2123,19 @@ Simulating game mode: ${mode}`);
1725
2123
  `Tried to simulate game mode "${mode}", but it's not configured in the game config.`
1726
2124
  );
1727
2125
  }
1728
- const booksPath = this.PATHS.books(mode);
2126
+ this.summary[mode] = {
2127
+ total: { numSims: runs, bsWins: 0, fsWins: 0, rtp: 0 },
2128
+ criteria: {}
2129
+ };
1729
2130
  const tempRecordsPath = this.PATHS.tempRecords(mode);
1730
2131
  createDirIfNotExists(this.PATHS.base);
1731
- createDirIfNotExists(path.join(this.PATHS.base, TEMP_FOLDER));
1732
- this.recordsWriteStream = fs2.createWriteStream(tempRecordsPath, {
2132
+ createDirIfNotExists(path3.join(this.PATHS.base, TEMP_FOLDER));
2133
+ this.recordsWriteStream = fs3.createWriteStream(tempRecordsPath, {
1733
2134
  highWaterMark: this.maxHighWaterMark
1734
2135
  }).setMaxListeners(30);
1735
2136
  const criteriaCounts = ResultSet.getNumberOfSimsForCriteria(this, mode);
1736
2137
  const totalSims = Object.values(criteriaCounts).reduce((a, b) => a + b, 0);
1737
- assert7(
2138
+ assert5(
1738
2139
  totalSims === runs,
1739
2140
  `Criteria mismatch for mode "${mode}". Expected ${runs}, got ${totalSims}`
1740
2141
  );
@@ -1749,46 +2150,52 @@ Simulating game mode: ${mode}`);
1749
2150
  });
1750
2151
  createDirIfNotExists(this.PATHS.optimizationFiles);
1751
2152
  createDirIfNotExists(this.PATHS.publishFiles);
1752
- console.log(
1753
- `Writing final files for game mode "${mode}". This may take a while...`
2153
+ statusMessage = `Writing final files for game mode "${mode}". This may take a while...`;
2154
+ this.tui?.log(statusMessage);
2155
+ this.sendSimulationStatus(statusMessage);
2156
+ writeFile(
2157
+ this.PATHS.booksIndexMeta(mode),
2158
+ JSON.stringify(
2159
+ this.bookIndexMetas.sort((a, b) => a.worker - b.worker),
2160
+ null,
2161
+ 2
2162
+ )
1754
2163
  );
2164
+ const booksPath = this.PATHS.booksCompressed(mode);
1755
2165
  try {
1756
- const finalBookStream = fs2.createWriteStream(booksPath, {
2166
+ const finalBookStream = fs3.createWriteStream(booksPath, {
1757
2167
  highWaterMark: this.streamHighWaterMark
1758
2168
  });
1759
- let isFirstChunk = true;
1760
- for (let i = 0; i < chunks.length; i++) {
1761
- const tempBookPath = this.PATHS.tempBooks(mode, i);
1762
- if (fs2.existsSync(tempBookPath)) {
1763
- if (!isFirstChunk) {
1764
- if (!finalBookStream.write("\n")) {
1765
- await new Promise((r) => finalBookStream.once("drain", r));
1766
- }
2169
+ for (const { worker, chunks: chunks2 } of this.bookIndexMetas) {
2170
+ for (let chunk = 0; chunk < chunks2; chunk++) {
2171
+ const bookChunkPath = this.PATHS.booksChunk(mode, worker, chunk);
2172
+ if (!fs3.existsSync(bookChunkPath)) continue;
2173
+ const chunkData = fs3.readFileSync(bookChunkPath);
2174
+ if (!finalBookStream.write(chunkData)) {
2175
+ await new Promise((r) => finalBookStream.once("drain", () => r()));
1767
2176
  }
1768
- const content = fs2.createReadStream(tempBookPath);
1769
- for await (const chunk of content) {
1770
- if (!finalBookStream.write(chunk)) {
1771
- await new Promise((r) => finalBookStream.once("drain", r));
1772
- }
1773
- }
1774
- fs2.rmSync(tempBookPath);
1775
- isFirstChunk = false;
1776
2177
  }
1777
2178
  }
1778
2179
  finalBookStream.end();
1779
- await new Promise((resolve) => finalBookStream.on("finish", resolve));
2180
+ await new Promise((r) => finalBookStream.on("finish", () => r()));
1780
2181
  } catch (error) {
1781
2182
  throw new Error(`Error merging book files: ${error.message}`);
1782
2183
  }
1783
2184
  const lutPath = this.PATHS.lookupTable(mode);
1784
2185
  const lutPathPublish = this.PATHS.lookupTablePublish(mode);
1785
2186
  const lutSegmentedPath = this.PATHS.lookupTableSegmented(mode);
1786
- await this.mergeCsv(chunks, lutPath, (i) => `temp_lookup_${mode}_${i}.csv`);
1787
- fs2.copyFileSync(lutPath, lutPathPublish);
2187
+ await this.mergeCsv(
2188
+ chunks,
2189
+ lutPath,
2190
+ (i) => `temp_lookup_${mode}_${i}.csv`,
2191
+ this.PATHS.lookupTableIndex(mode)
2192
+ );
2193
+ fs3.copyFileSync(lutPath, lutPathPublish);
1788
2194
  await this.mergeCsv(
1789
2195
  chunks,
1790
2196
  lutSegmentedPath,
1791
- (i) => `temp_lookup_segmented_${mode}_${i}.csv`
2197
+ (i) => `temp_lookup_segmented_${mode}_${i}.csv`,
2198
+ this.PATHS.lookupTableSegmentedIndex(mode)
1792
2199
  );
1793
2200
  if (this.recordsWriteStream) {
1794
2201
  await new Promise((resolve) => {
@@ -1799,29 +2206,52 @@ Simulating game mode: ${mode}`);
1799
2206
  this.recordsWriteStream = void 0;
1800
2207
  }
1801
2208
  await this.writeRecords(mode);
1802
- await this.writeBooksJson(mode);
1803
2209
  this.writeIndexJson();
1804
- console.log(`Mode ${mode} done!`);
1805
- debugDetails[mode].rtp = round(
1806
- this.wallet.getCumulativeWins() / (runs * this.gameConfig.gameModes[mode].cost),
1807
- 3
1808
- );
1809
- debugDetails[mode].wins = round(this.wallet.getCumulativeWins(), 3);
1810
- debugDetails[mode].winsPerSpinType = Object.fromEntries(
1811
- Object.entries(this.wallet.getCumulativeWinsPerSpinType()).map(([k, v]) => [
1812
- k,
1813
- round(v, 3)
1814
- ])
1815
- );
1816
- console.timeEnd(mode);
2210
+ if (this.makeUncompressedBooks) {
2211
+ statusMessage = `Creating decompressed book file for mode "${mode}". This may take a while...`;
2212
+ this.tui?.log(statusMessage);
2213
+ this.sendSimulationStatus(statusMessage);
2214
+ const uncompressedBooksPath = this.PATHS.booksUncompressed(mode);
2215
+ const outputStream = fs3.createWriteStream(uncompressedBooksPath, {
2216
+ highWaterMark: this.streamHighWaterMark
2217
+ });
2218
+ try {
2219
+ for (const { worker, chunks: chunks2 } of this.bookIndexMetas) {
2220
+ for (let chunk = 0; chunk < chunks2; chunk++) {
2221
+ const bookChunkPath = this.PATHS.booksChunk(mode, worker, chunk);
2222
+ if (!fs3.existsSync(bookChunkPath)) continue;
2223
+ const inputStream = fs3.createReadStream(bookChunkPath);
2224
+ const compress = zlib.createZstdDecompress();
2225
+ for await (const decompChunk of inputStream.pipe(compress)) {
2226
+ if (!outputStream.write(decompChunk)) {
2227
+ await new Promise((r) => outputStream.once("drain", () => r()));
2228
+ }
2229
+ }
2230
+ }
2231
+ }
2232
+ outputStream.end();
2233
+ await new Promise((r) => outputStream.on("finish", () => r()));
2234
+ } catch (error) {
2235
+ statusMessage = chalk2.yellow(
2236
+ `Error creating uncompressed book file: ${error.message}`
2237
+ );
2238
+ this.tui?.log(statusMessage);
2239
+ this.sendSimulationStatus(statusMessage);
2240
+ }
2241
+ }
2242
+ const endTime = Date.now();
2243
+ const prettyTime = new Date(endTime - startTime).toISOString().slice(11, -1);
2244
+ statusMessage = `Mode ${mode} done! Time taken: ${prettyTime}`;
2245
+ this.tui?.log(statusMessage);
2246
+ this.sendSimulationStatus(statusMessage);
1817
2247
  }
1818
- console.log("\n=== SIMULATION SUMMARY ===");
1819
- console.table(debugDetails);
2248
+ this.tui?.stop();
2249
+ await this.printSimulationSummary();
1820
2250
  }
1821
2251
  let desiredSims = 0;
1822
2252
  let actualSims = 0;
1823
2253
  const criteriaToRetries = {};
1824
- if (!isMainThread) {
2254
+ if (!isMainThread2) {
1825
2255
  const { mode, simStart, simEnd, index, criteriaCounts } = workerData;
1826
2256
  const seed = hashStringToInt(mode) + index >>> 0;
1827
2257
  const nextCriteria = createCriteriaSampler(criteriaCounts, seed);
@@ -1836,16 +2266,16 @@ Simulating game mode: ${mode}`);
1836
2266
  actualSims += this.actualSims;
1837
2267
  }
1838
2268
  }
1839
- if (this.debug) {
1840
- console.log(`Desired ${desiredSims}, Actual ${actualSims}`);
1841
- console.log(`Retries per criteria:`, criteriaToRetries);
1842
- }
1843
- parentPort?.postMessage({
2269
+ parentPort2?.postMessage({
1844
2270
  type: "done",
1845
2271
  workerNum: index
1846
2272
  });
1847
- parentPort?.removeAllListeners();
1848
- parentPort?.close();
2273
+ parentPort2?.removeAllListeners();
2274
+ parentPort2?.close();
2275
+ }
2276
+ if (this.socket && this.panelActive) {
2277
+ await new Promise((resolve) => setTimeout(resolve, 500));
2278
+ this.socket?.close();
1849
2279
  }
1850
2280
  }
1851
2281
  /**
@@ -1853,39 +2283,36 @@ Simulating game mode: ${mode}`);
1853
2283
  */
1854
2284
  async spawnWorkersForGameMode(opts) {
1855
2285
  const { mode, chunks, chunkCriteriaCounts, totalSims } = opts;
1856
- await Promise.all(
1857
- chunks.map(([simStart, simEnd], index) => {
1858
- return this.callWorker({
1859
- basePath: this.PATHS.base,
1860
- mode,
1861
- simStart,
1862
- simEnd,
1863
- index,
1864
- totalSims,
1865
- criteriaCounts: chunkCriteriaCounts[index]
1866
- });
1867
- })
1868
- );
2286
+ try {
2287
+ await Promise.all(
2288
+ chunks.map(([simStart, simEnd], index) => {
2289
+ return this.callWorker({
2290
+ basePath: this.PATHS.base,
2291
+ mode,
2292
+ simStart,
2293
+ simEnd,
2294
+ index,
2295
+ totalSims,
2296
+ criteriaCounts: chunkCriteriaCounts[index]
2297
+ });
2298
+ })
2299
+ );
2300
+ } catch (error) {
2301
+ this.tui?.stop();
2302
+ throw error;
2303
+ }
1869
2304
  }
1870
2305
  async callWorker(opts) {
1871
2306
  const { mode, simEnd, simStart, basePath, index, totalSims, criteriaCounts } = opts;
1872
- function logArrowProgress(current, total) {
1873
- const percentage = current / total * 100;
1874
- const progressBarLength = 50;
1875
- const filledLength = Math.round(progressBarLength * current / total);
1876
- const bar = "\u2588".repeat(filledLength) + "-".repeat(progressBarLength - filledLength);
1877
- process.stdout.write(`\r[${bar}] ${percentage.toFixed(2)}% (${current}/${total})`);
1878
- if (current === total) {
1879
- process.stdout.write("\n");
1880
- }
1881
- }
1882
2307
  const write = async (stream, chunk) => {
1883
2308
  if (!stream.write(chunk)) {
1884
2309
  await new Promise((resolve) => stream.once("drain", resolve));
1885
2310
  }
1886
2311
  };
1887
2312
  return new Promise((resolve, reject) => {
1888
- const scriptPath = path.join(basePath, TEMP_FILENAME);
2313
+ const scriptPath = path3.join(basePath, TEMP_FILENAME);
2314
+ createDirIfNotExists(path3.join(this.PATHS.base, "books_chunks"));
2315
+ const startTime = Date.now();
1889
2316
  const worker = new Worker(scriptPath, {
1890
2317
  workerData: {
1891
2318
  mode,
@@ -1896,50 +2323,137 @@ Simulating game mode: ${mode}`);
1896
2323
  }
1897
2324
  });
1898
2325
  worker.postMessage({ type: "credit", amount: this.maxPendingSims });
1899
- const tempBookPath = this.PATHS.tempBooks(mode, index);
1900
- const bookStream = fs2.createWriteStream(tempBookPath, {
2326
+ const flushBookChunk = async () => {
2327
+ if (this.bookBuffers.get(index)?.length === 0) return;
2328
+ if (!this.bookChunkIndexes.has(index)) {
2329
+ this.bookChunkIndexes.set(index, 0);
2330
+ }
2331
+ const chunkIndex = this.bookChunkIndexes.get(index);
2332
+ const bookChunkPath = this.PATHS.booksChunk(mode, index, chunkIndex);
2333
+ const data = this.bookBuffers.get(index).join("\n") + "\n";
2334
+ await pipeline(
2335
+ Readable.from([Buffer.from(data, "utf8")]),
2336
+ zlib.createZstdCompress(),
2337
+ fs3.createWriteStream(bookChunkPath)
2338
+ );
2339
+ this.bookBuffers.set(index, []);
2340
+ this.bookBufferSizes.set(index, 0);
2341
+ this.bookChunkIndexes.set(index, chunkIndex + 1);
2342
+ };
2343
+ const booksIndexPath = this.PATHS.booksIndex(mode, index);
2344
+ const booksIndexStream = fs3.createWriteStream(booksIndexPath, {
1901
2345
  highWaterMark: this.maxHighWaterMark
1902
2346
  });
1903
2347
  const tempLookupPath = this.PATHS.tempLookupTable(mode, index);
1904
- const lookupStream = fs2.createWriteStream(tempLookupPath, {
2348
+ const lookupStream = fs3.createWriteStream(tempLookupPath, {
1905
2349
  highWaterMark: this.maxHighWaterMark
1906
2350
  });
1907
2351
  const tempLookupSegPath = this.PATHS.tempLookupTableSegmented(mode, index);
1908
- const lookupSegmentedStream = fs2.createWriteStream(tempLookupSegPath, {
2352
+ const lookupSegmentedStream = fs3.createWriteStream(tempLookupSegPath, {
1909
2353
  highWaterMark: this.maxHighWaterMark
1910
2354
  });
1911
2355
  let writeChain = Promise.resolve();
1912
2356
  worker.on("message", (msg) => {
1913
- if (msg.type === "log") {
2357
+ if (msg.type === "log" || msg.type === "user-log") {
2358
+ this.tui?.log(msg.message);
1914
2359
  return;
1915
2360
  }
2361
+ if (msg.type === "log-exit") {
2362
+ this.tui?.log(msg.message);
2363
+ this.tui?.stop();
2364
+ console.log(msg.message);
2365
+ process.exit(1);
2366
+ }
1916
2367
  if (msg.type === "complete") {
1917
- writeChain = writeChain.then(async () => {
1918
- completedSimulations++;
1919
- if (completedSimulations % 250 === 0) {
1920
- logArrowProgress(completedSimulations, totalSims);
2368
+ completedSimulations++;
2369
+ if (completedSimulations % 250 === 0 || completedSimulations === totalSims) {
2370
+ const percentage = completedSimulations / totalSims * 100;
2371
+ this.tui?.setProgress(
2372
+ percentage,
2373
+ this.getTimeRemaining(startTime, totalSims),
2374
+ completedSimulations
2375
+ );
2376
+ }
2377
+ if (this.socket && this.panelActive) {
2378
+ if (completedSimulations % 1e3 === 0 || completedSimulations === totalSims) {
2379
+ this.socket.emit("simulationProgress", {
2380
+ mode,
2381
+ percentage: completedSimulations / totalSims * 100,
2382
+ current: completedSimulations,
2383
+ total: totalSims,
2384
+ timeRemaining: this.getTimeRemaining(startTime, totalSims)
2385
+ });
2386
+ this.socket.emit(
2387
+ "simulationShouldStop",
2388
+ this.gameConfig.id,
2389
+ (shouldStop) => {
2390
+ if (shouldStop) {
2391
+ worker.terminate();
2392
+ }
2393
+ }
2394
+ );
1921
2395
  }
2396
+ }
2397
+ writeChain = writeChain.then(async () => {
1922
2398
  const book = msg.book;
1923
2399
  const bookData = {
1924
2400
  id: book.id,
1925
2401
  payoutMultiplier: book.payout,
1926
2402
  events: book.events
1927
2403
  };
1928
- const prefix = book.id === simStart ? "" : "\n";
1929
- await write(bookStream, prefix + JSONL.stringify([bookData]));
1930
- await write(lookupStream, `${book.id},1,${Math.round(book.payout)}
1931
- `);
1932
- await write(
1933
- lookupSegmentedStream,
1934
- `${book.id},${book.criteria},${book.basegameWins},${book.freespinsWins}
2404
+ if (!this.summary[mode]?.criteria[book.criteria]) {
2405
+ this.summary[mode].criteria[book.criteria] = {
2406
+ numSims: 0,
2407
+ bsWins: 0,
2408
+ fsWins: 0,
2409
+ rtp: 0
2410
+ };
2411
+ }
2412
+ const bsWins = round(book.basegameWins, 4);
2413
+ const fsWins = round(book.freespinsWins, 4);
2414
+ this.summary[mode].criteria[book.criteria].numSims += 1;
2415
+ this.summary[mode].total.bsWins += bsWins;
2416
+ this.summary[mode].total.fsWins += fsWins;
2417
+ this.summary[mode].criteria[book.criteria].bsWins += bsWins;
2418
+ this.summary[mode].criteria[book.criteria].fsWins += fsWins;
2419
+ const bookLine = JSON.stringify(bookData);
2420
+ const lineSize = Buffer.byteLength(bookLine + "\n", "utf8");
2421
+ if (this.bookBuffers.has(index)) {
2422
+ this.bookBuffers.get(index).push(bookLine);
2423
+ this.bookBufferSizes.set(
2424
+ index,
2425
+ this.bookBufferSizes.get(index) + lineSize
2426
+ );
2427
+ } else {
2428
+ this.bookBuffers.set(index, [bookLine]);
2429
+ this.bookBufferSizes.set(index, lineSize);
2430
+ }
2431
+ if (!this.tempBookIndexPaths.includes(booksIndexPath)) {
2432
+ this.tempBookIndexPaths.push(booksIndexPath);
2433
+ }
2434
+ await Promise.all([
2435
+ write(
2436
+ booksIndexStream,
2437
+ `${book.id},${index},${this.bookChunkIndexes.get(index) || 0}
1935
2438
  `
1936
- );
2439
+ ),
2440
+ write(lookupStream, `${book.id},1,${Math.round(book.payout)}
2441
+ `),
2442
+ write(
2443
+ lookupSegmentedStream,
2444
+ `${book.id},${book.criteria},${book.basegameWins},${book.freespinsWins}
2445
+ `
2446
+ )
2447
+ ]);
2448
+ if (this.bookBufferSizes.get(index) >= 10 * 1024 * 1024) {
2449
+ await flushBookChunk();
2450
+ }
1937
2451
  if (this.recordsWriteStream) {
1938
2452
  for (const record of msg.records) {
1939
2453
  const recordPrefix = this.hasWrittenRecord ? "\n" : "";
1940
2454
  await write(
1941
2455
  this.recordsWriteStream,
1942
- recordPrefix + JSONL.stringify([record])
2456
+ recordPrefix + JSON.stringify(record)
1943
2457
  );
1944
2458
  this.hasWrittenRecord = true;
1945
2459
  }
@@ -1951,31 +2465,35 @@ Simulating game mode: ${mode}`);
1951
2465
  }
1952
2466
  if (msg.type === "done") {
1953
2467
  writeChain.then(async () => {
1954
- bookStream.end();
2468
+ await flushBookChunk();
1955
2469
  lookupStream.end();
1956
2470
  lookupSegmentedStream.end();
2471
+ booksIndexStream.end();
1957
2472
  await Promise.all([
1958
- new Promise((r) => bookStream.on("finish", () => r())),
1959
2473
  new Promise((r) => lookupStream.on("finish", () => r())),
1960
- new Promise((r) => lookupSegmentedStream.on("finish", () => r()))
2474
+ new Promise((r) => lookupSegmentedStream.on("finish", () => r())),
2475
+ new Promise((r) => booksIndexStream.on("finish", () => r()))
1961
2476
  ]);
2477
+ const bookIndexMeta = {
2478
+ worker: index,
2479
+ chunks: this.bookChunkIndexes.get(index) + 2,
2480
+ simStart,
2481
+ simEnd
2482
+ };
2483
+ this.bookIndexMetas.push(bookIndexMeta);
1962
2484
  resolve(true);
1963
2485
  }).catch(reject);
1964
2486
  return;
1965
2487
  }
1966
2488
  });
1967
2489
  worker.on("error", (error) => {
1968
- process.stdout.write(`
1969
- ${error.message}
1970
- `);
1971
- process.stdout.write(`
1972
- ${error.stack}
1973
- `);
1974
- reject(error);
2490
+ this.tui?.log(error.message);
2491
+ resolve(error);
1975
2492
  });
1976
2493
  worker.on("exit", (code) => {
1977
2494
  if (code !== 0) {
1978
- reject(new Error(`Worker stopped with exit code ${code}`));
2495
+ this.tui?.log(chalk2.yellow(`Worker stopped with exit code ${code}`));
2496
+ resolve(false);
1979
2497
  }
1980
2498
  });
1981
2499
  });
@@ -1985,12 +2503,21 @@ ${error.stack}
1985
2503
  */
1986
2504
  runSingleSimulation(opts) {
1987
2505
  const { simId, mode, criteria } = opts;
2506
+ let retries = 0;
1988
2507
  const ctx = createGameContext({
1989
2508
  config: this.gameConfig
1990
2509
  });
1991
2510
  ctx.state.currentGameMode = mode;
1992
2511
  ctx.state.currentSimulationId = simId;
1993
2512
  ctx.state.isCriteriaMet = false;
2513
+ ctx.services.data._setBook(
2514
+ new Book({
2515
+ id: simId,
2516
+ criteria
2517
+ })
2518
+ );
2519
+ ctx.services.wallet._setWallet(new Wallet());
2520
+ ctx.services.data._setRecorder(new Recorder());
1994
2521
  const resultSet = ctx.services.game.getResultSetByCriteria(
1995
2522
  ctx.state.currentGameMode,
1996
2523
  criteria
@@ -2003,6 +2530,21 @@ ${error.stack}
2003
2530
  if (resultSet.meetsCriteria(ctx)) {
2004
2531
  ctx.state.isCriteriaMet = true;
2005
2532
  }
2533
+ retries++;
2534
+ if (!ctx.state.isCriteriaMet && retries % 1e4 === 0) {
2535
+ parentPort2?.postMessage({
2536
+ type: "log",
2537
+ message: chalk2.yellow(
2538
+ `Excessive retries @ #${simId} @ criteria "${criteria}": ${retries} retries`
2539
+ )
2540
+ });
2541
+ }
2542
+ if (!ctx.state.isCriteriaMet && retries % 5e4 === 0) {
2543
+ parentPort2?.postMessage({
2544
+ type: "log-exit",
2545
+ message: chalk2.red("Possible infinite loop detected, exiting simulation.")
2546
+ });
2547
+ }
2006
2548
  }
2007
2549
  ctx.services.wallet._getWallet().writePayoutToBook(ctx);
2008
2550
  ctx.services.wallet._getWallet().confirmWins(ctx);
@@ -2014,10 +2556,10 @@ ${error.stack}
2014
2556
  });
2015
2557
  ctx.config.hooks.onSimulationAccepted?.(ctx);
2016
2558
  this.confirmRecords(ctx);
2017
- parentPort?.postMessage({
2559
+ parentPort2?.postMessage({
2018
2560
  type: "complete",
2019
2561
  simId,
2020
- book: ctx.services.data._getBook().serialize(),
2562
+ book: ctx.services.data._getBook()._serialize(),
2021
2563
  wallet: ctx.services.wallet._getWallet().serialize(),
2022
2564
  records: ctx.services.data._getRecords()
2023
2565
  });
@@ -2025,7 +2567,7 @@ ${error.stack}
2025
2567
  initCreditListener() {
2026
2568
  if (this.creditListenerInit) return;
2027
2569
  this.creditListenerInit = true;
2028
- parentPort?.on("message", (msg) => {
2570
+ parentPort2?.on("message", (msg) => {
2029
2571
  if (msg?.type !== "credit") return;
2030
2572
  const amount = Number(msg?.amount ?? 0);
2031
2573
  if (!Number.isFinite(amount) || amount <= 0) return;
@@ -2055,17 +2597,10 @@ ${error.stack}
2055
2597
  resetSimulation(ctx) {
2056
2598
  this.resetState(ctx);
2057
2599
  ctx.services.board.resetBoard();
2058
- ctx.services.data._setRecorder(new Recorder());
2059
- ctx.services.wallet._setWallet(new Wallet());
2060
- ctx.services.data._setBook(
2061
- new Book({
2062
- id: ctx.state.currentSimulationId,
2063
- criteria: ctx.state.currentResultSet.criteria
2064
- })
2065
- );
2066
- Object.values(ctx.config.gameModes).forEach((mode) => {
2067
- mode._resetTempValues();
2068
- });
2600
+ ctx.services.data._getRecorder()._reset();
2601
+ ctx.services.wallet._getWallet()._reset();
2602
+ ctx.services.data._getBook()._reset(ctx.state.currentSimulationId, ctx.state.currentResultSet.criteria);
2603
+ ctx.services.game.getCurrentGameMode()._resetTempValues();
2069
2604
  }
2070
2605
  resetState(ctx) {
2071
2606
  ctx.services.rng.setSeedIfDifferent(ctx.state.currentSimulationId);
@@ -2092,18 +2627,25 @@ ${error.stack}
2092
2627
  async writeRecords(mode) {
2093
2628
  const tempRecordsPath = this.PATHS.tempRecords(mode);
2094
2629
  const forceRecordsPath = this.PATHS.forceRecords(mode);
2630
+ const allSearchKeysAndValues = /* @__PURE__ */ new Map();
2095
2631
  const aggregatedRecords = /* @__PURE__ */ new Map();
2096
- if (fs2.existsSync(tempRecordsPath)) {
2097
- const fileStream = fs2.createReadStream(tempRecordsPath, {
2632
+ if (fs3.existsSync(tempRecordsPath)) {
2633
+ const fileStream = fs3.createReadStream(tempRecordsPath, {
2098
2634
  highWaterMark: this.streamHighWaterMark
2099
2635
  });
2100
- const rl = readline2.createInterface({
2636
+ const rl = readline.createInterface({
2101
2637
  input: fileStream,
2102
2638
  crlfDelay: Infinity
2103
2639
  });
2104
2640
  for await (const line of rl) {
2105
2641
  if (line.trim() === "") continue;
2106
2642
  const record = JSON.parse(line);
2643
+ for (const entry of record.search) {
2644
+ if (!allSearchKeysAndValues.has(entry.name)) {
2645
+ allSearchKeysAndValues.set(entry.name, /* @__PURE__ */ new Set());
2646
+ }
2647
+ allSearchKeysAndValues.get(entry.name).add(String(entry.value));
2648
+ }
2107
2649
  const key = JSON.stringify(record.search);
2108
2650
  let existing = aggregatedRecords.get(key);
2109
2651
  if (!existing) {
@@ -2120,8 +2662,9 @@ ${error.stack}
2120
2662
  }
2121
2663
  }
2122
2664
  }
2123
- fs2.rmSync(forceRecordsPath, { force: true });
2124
- const writeStream = fs2.createWriteStream(forceRecordsPath, { encoding: "utf-8" });
2665
+ fs3.rmSync(forceRecordsPath, { force: true });
2666
+ fs3.rmSync(this.PATHS.forceKeys(mode), { force: true });
2667
+ const writeStream = fs3.createWriteStream(forceRecordsPath, { encoding: "utf-8" });
2125
2668
  writeStream.write("[\n");
2126
2669
  let isFirst = true;
2127
2670
  for (const record of aggregatedRecords.values()) {
@@ -2136,13 +2679,17 @@ ${error.stack}
2136
2679
  await new Promise((resolve) => {
2137
2680
  writeStream.on("finish", () => resolve());
2138
2681
  });
2139
- fs2.rmSync(tempRecordsPath, { force: true });
2682
+ const forceJson = Object.fromEntries(
2683
+ Array.from(allSearchKeysAndValues.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([key, values]) => [key, Array.from(values)])
2684
+ );
2685
+ writeFile(this.PATHS.forceKeys(mode), JSON.stringify(forceJson, null, 2));
2686
+ fs3.rmSync(tempRecordsPath, { force: true });
2140
2687
  }
2141
2688
  writeIndexJson() {
2142
2689
  const outputFilePath = this.PATHS.indexJson;
2143
2690
  const modes = Object.keys(this.simRunsAmount).map((id) => {
2144
2691
  const mode = this.gameConfig.gameModes[id];
2145
- assert7(mode, `Game mode "${id}" not found in game config.`);
2692
+ assert5(mode, `Game mode "${id}" not found in game config.`);
2146
2693
  return {
2147
2694
  name: mode.name,
2148
2695
  cost: mode.cost,
@@ -2152,38 +2699,26 @@ ${error.stack}
2152
2699
  });
2153
2700
  writeFile(outputFilePath, JSON.stringify({ modes }, null, 2));
2154
2701
  }
2155
- async writeBooksJson(gameMode) {
2156
- const outputFilePath = this.PATHS.books(gameMode);
2157
- const compressedFilePath = this.PATHS.booksCompressed(gameMode);
2158
- fs2.rmSync(compressedFilePath, { force: true });
2159
- if (fs2.existsSync(outputFilePath)) {
2160
- await pipeline(
2161
- fs2.createReadStream(outputFilePath),
2162
- zlib.createZstdCompress(),
2163
- fs2.createWriteStream(compressedFilePath)
2164
- );
2165
- }
2166
- }
2167
2702
  /**
2168
2703
  * Compiles user configured game to JS for use in different Node processes
2169
2704
  */
2170
2705
  preprocessFiles() {
2171
- const builtFilePath = path.join(
2706
+ const builtFilePath = path3.join(
2172
2707
  this.gameConfig.rootDir,
2173
2708
  this.gameConfig.outputDir,
2174
2709
  TEMP_FILENAME
2175
2710
  );
2176
- fs2.rmSync(builtFilePath, { force: true });
2711
+ fs3.rmSync(builtFilePath, { force: true });
2177
2712
  buildSync({
2178
2713
  entryPoints: [this.gameConfig.rootDir],
2179
2714
  bundle: true,
2180
2715
  platform: "node",
2181
- outfile: path.join(
2716
+ outfile: path3.join(
2182
2717
  this.gameConfig.rootDir,
2183
2718
  this.gameConfig.outputDir,
2184
2719
  TEMP_FILENAME
2185
2720
  ),
2186
- external: ["esbuild"]
2721
+ external: ["esbuild", "yargs"]
2187
2722
  });
2188
2723
  }
2189
2724
  getSimRangesForChunks(total, chunks) {
@@ -2218,27 +2753,62 @@ ${error.stack}
2218
2753
  }
2219
2754
  }
2220
2755
  }
2221
- async mergeCsv(chunks, outPath, tempName) {
2756
+ getTimeRemaining(startTime, totalSims) {
2757
+ const elapsedTime = Date.now() - startTime;
2758
+ const simsLeft = totalSims - completedSimulations;
2759
+ const timePerSim = elapsedTime / completedSimulations;
2760
+ const timeRemaining = Math.round(simsLeft * timePerSim / 1e3);
2761
+ return timeRemaining;
2762
+ }
2763
+ async mergeCsv(chunks, outPath, tempName, lutIndexPath) {
2222
2764
  try {
2223
- fs2.rmSync(outPath, { force: true });
2224
- const out = fs2.createWriteStream(outPath, {
2765
+ fs3.rmSync(outPath, { force: true });
2766
+ const lutStream = fs3.createWriteStream(outPath, {
2225
2767
  highWaterMark: this.streamHighWaterMark
2226
2768
  });
2769
+ const lutIndexStream = lutIndexPath ? fs3.createWriteStream(lutIndexPath, {
2770
+ highWaterMark: this.streamHighWaterMark
2771
+ }) : void 0;
2772
+ let offset = 0n;
2227
2773
  for (let i = 0; i < chunks.length; i++) {
2228
- const p = path.join(this.PATHS.base, TEMP_FOLDER, tempName(i));
2229
- if (!fs2.existsSync(p)) continue;
2230
- const rs = fs2.createReadStream(p, {
2231
- highWaterMark: this.streamHighWaterMark
2232
- });
2233
- for await (const buf of rs) {
2234
- if (!out.write(buf)) {
2235
- await new Promise((resolve) => out.once("drain", resolve));
2774
+ const tempLutChunk = path3.join(this.PATHS.base, TEMP_FOLDER, tempName(i));
2775
+ if (!fs3.existsSync(tempLutChunk)) continue;
2776
+ if (lutIndexStream) {
2777
+ const rl = readline.createInterface({
2778
+ input: fs3.createReadStream(tempLutChunk),
2779
+ crlfDelay: Infinity
2780
+ });
2781
+ for await (const line of rl) {
2782
+ if (!line.trim()) continue;
2783
+ const indexBuffer = Buffer.alloc(8);
2784
+ indexBuffer.writeBigUInt64LE(offset);
2785
+ if (!lutIndexStream.write(indexBuffer)) {
2786
+ await new Promise((resolve) => lutIndexStream.once("drain", resolve));
2787
+ }
2788
+ const lineWithNewline = line + "\n";
2789
+ if (!lutStream.write(lineWithNewline)) {
2790
+ await new Promise((resolve) => lutStream.once("drain", resolve));
2791
+ }
2792
+ offset += BigInt(Buffer.byteLength(lineWithNewline, "utf8"));
2793
+ }
2794
+ } else {
2795
+ const tempChunkStream = fs3.createReadStream(tempLutChunk, {
2796
+ highWaterMark: this.streamHighWaterMark
2797
+ });
2798
+ for await (const buf of tempChunkStream) {
2799
+ if (!lutStream.write(buf)) {
2800
+ await new Promise((resolve) => lutStream.once("drain", resolve));
2801
+ }
2236
2802
  }
2237
2803
  }
2238
- fs2.rmSync(p);
2804
+ fs3.rmSync(tempLutChunk);
2239
2805
  }
2240
- out.end();
2241
- await new Promise((resolve) => out.on("finish", resolve));
2806
+ lutStream.end();
2807
+ lutIndexStream?.end();
2808
+ await Promise.all([
2809
+ new Promise((resolve) => lutStream.on("finish", resolve)),
2810
+ lutIndexStream ? new Promise((resolve) => lutIndexStream.on("finish", resolve)) : Promise.resolve()
2811
+ ]);
2242
2812
  } catch (error) {
2243
2813
  throw new Error(`Error merging CSV files: ${error.message}`);
2244
2814
  }
@@ -2249,21 +2819,16 @@ ${error.stack}
2249
2819
  confirmRecords(ctx) {
2250
2820
  const recorder = ctx.services.data._getRecorder();
2251
2821
  for (const pendingRecord of recorder.pendingRecords) {
2252
- const search = Object.entries(pendingRecord.properties).map(([name, value]) => ({ name, value })).sort((a, b) => a.name.localeCompare(b.name));
2253
- let record = recorder.records.find((r) => {
2254
- if (r.search.length !== search.length) return false;
2255
- for (let i = 0; i < r.search.length; i++) {
2256
- if (r.search[i].name !== search[i].name) return false;
2257
- if (r.search[i].value !== search[i].value) return false;
2258
- }
2259
- return true;
2260
- });
2822
+ const key = Object.keys(pendingRecord.properties).sort().map((k) => `${k}:${pendingRecord.properties[k]}`).join("|");
2823
+ let record = recorder.recordsMap.get(key);
2261
2824
  if (!record) {
2825
+ const search = Object.entries(pendingRecord.properties).map(([name, value]) => ({ name, value })).sort((a, b) => a.name.localeCompare(b.name));
2262
2826
  record = {
2263
2827
  search,
2264
2828
  timesTriggered: 0,
2265
2829
  bookIds: []
2266
2830
  };
2831
+ recorder.recordsMap.set(key, record);
2267
2832
  recorder.records.push(record);
2268
2833
  }
2269
2834
  record.timesTriggered++;
@@ -2273,18 +2838,76 @@ ${error.stack}
2273
2838
  }
2274
2839
  recorder.pendingRecords = [];
2275
2840
  }
2841
+ async printSimulationSummary() {
2842
+ Object.entries(this.summary).forEach(([mode, modeSummary]) => {
2843
+ const modeCost = this.gameConfig.gameModes[mode].cost;
2844
+ Object.entries(modeSummary.criteria).forEach(([criteria, criteriaSummary]) => {
2845
+ const totalWins2 = criteriaSummary.bsWins + criteriaSummary.fsWins;
2846
+ const rtp2 = totalWins2 / (criteriaSummary.numSims * modeCost);
2847
+ this.summary[mode].criteria[criteria].rtp = round(rtp2, 4);
2848
+ this.summary[mode].criteria[criteria].bsWins = round(criteriaSummary.bsWins, 4);
2849
+ this.summary[mode].criteria[criteria].fsWins = round(criteriaSummary.fsWins, 4);
2850
+ });
2851
+ const totalWins = modeSummary.total.bsWins + modeSummary.total.fsWins;
2852
+ const rtp = totalWins / (modeSummary.total.numSims * modeCost);
2853
+ this.summary[mode].total.rtp = round(rtp, 4);
2854
+ this.summary[mode].total.bsWins = round(modeSummary.total.bsWins, 4);
2855
+ this.summary[mode].total.fsWins = round(modeSummary.total.fsWins, 4);
2856
+ });
2857
+ const maxLineLength = 50;
2858
+ let output = chalk2.green.bold("\nSimulation Summary\n");
2859
+ for (const [mode, modeSummary] of Object.entries(this.summary)) {
2860
+ output += "-".repeat(maxLineLength) + "\n\n";
2861
+ output += chalk2.bold.bgWhite(`Mode: ${mode}
2862
+ `);
2863
+ output += `Simulations: ${modeSummary.total.numSims}
2864
+ `;
2865
+ output += `Basegame Wins: ${modeSummary.total.bsWins}
2866
+ `;
2867
+ output += `Freespins Wins: ${modeSummary.total.fsWins}
2868
+ `;
2869
+ output += `RTP (unoptimized): ${modeSummary.total.rtp}
2870
+ `;
2871
+ output += chalk2.bold("\n Result Set Summary:\n");
2872
+ for (const [criteria, criteriaSummary] of Object.entries(modeSummary.criteria)) {
2873
+ output += chalk2.gray(" " + "-".repeat(maxLineLength - 4)) + "\n";
2874
+ output += chalk2.bold(` Criteria: ${criteria}
2875
+ `);
2876
+ output += ` Simulations: ${criteriaSummary.numSims}
2877
+ `;
2878
+ output += ` Basegame Wins: ${criteriaSummary.bsWins}
2879
+ `;
2880
+ output += ` Freespins Wins: ${criteriaSummary.fsWins}
2881
+ `;
2882
+ output += ` RTP (unoptimized): ${criteriaSummary.rtp}
2883
+ `;
2884
+ }
2885
+ }
2886
+ console.log(output);
2887
+ writeFile(this.PATHS.simulationSummary, JSON.stringify(this.summary, null, 2));
2888
+ if (this.socket && this.panelActive) {
2889
+ this.socket.emit("simulationSummary", {
2890
+ summary: this.summary
2891
+ });
2892
+ }
2893
+ }
2894
+ sendSimulationStatus(message) {
2895
+ if (this.socket && this.panelActive) {
2896
+ this.socket.emit("simulationStatus", message);
2897
+ }
2898
+ }
2276
2899
  };
2277
2900
 
2278
2901
  // src/analysis/index.ts
2279
- import fs3 from "fs";
2280
- import path2 from "path";
2281
- import assert8 from "assert";
2902
+ import fs4 from "fs";
2903
+ import assert6 from "assert";
2282
2904
 
2283
2905
  // src/analysis/utils.ts
2284
2906
  function parseLookupTable(content) {
2285
2907
  const lines = content.trim().split("\n");
2286
2908
  const lut = [];
2287
2909
  for (const line of lines) {
2910
+ if (!line.trim()) continue;
2288
2911
  const [indexStr, weightStr, payoutStr] = line.split(",");
2289
2912
  const index = parseInt(indexStr.trim());
2290
2913
  const weight = parseInt(weightStr.trim());
@@ -2297,11 +2920,12 @@ function parseLookupTableSegmented(content) {
2297
2920
  const lines = content.trim().split("\n");
2298
2921
  const lut = [];
2299
2922
  for (const line of lines) {
2300
- const [indexStr, criteria, weightStr, payoutStr] = line.split(",");
2923
+ if (!line.trim()) continue;
2924
+ const [indexStr, criteria, bsWinsStr, fsWinsStr] = line.split(",");
2301
2925
  const index = parseInt(indexStr.trim());
2302
- const weight = parseInt(weightStr.trim());
2303
- const payout = parseFloat(payoutStr.trim());
2304
- lut.push([index, criteria, weight, payout]);
2926
+ const bsWins = parseFloat(bsWinsStr.trim());
2927
+ const fsWins = parseFloat(fsWinsStr.trim());
2928
+ lut.push([index, criteria, bsWins, fsWins]);
2305
2929
  }
2306
2930
  return lut;
2307
2931
  }
@@ -2335,19 +2959,19 @@ function getPayoutWeights(lut, opts = {}) {
2335
2959
  function getNonZeroHitrate(payoutWeights) {
2336
2960
  const totalWeight = getTotalWeight(payoutWeights);
2337
2961
  if (Math.min(...Object.keys(payoutWeights).map(Number)) == 0) {
2338
- return totalWeight / (totalWeight - (payoutWeights[0] ?? 0) / totalWeight);
2962
+ return round(totalWeight / (totalWeight - (payoutWeights[0] ?? 0) / totalWeight), 4);
2339
2963
  } else {
2340
2964
  return 1;
2341
2965
  }
2342
2966
  }
2343
2967
  function getNullHitrate(payoutWeights) {
2344
- return payoutWeights[0] ?? 0;
2968
+ return round(payoutWeights[0] ?? 0, 4);
2345
2969
  }
2346
2970
  function getMaxwinHitrate(payoutWeights) {
2347
2971
  const totalWeight = getTotalWeight(payoutWeights);
2348
2972
  const maxWin = Math.max(...Object.keys(payoutWeights).map(Number));
2349
2973
  const hitRate = (payoutWeights[maxWin] || 0) / totalWeight;
2350
- return 1 / hitRate;
2974
+ return round(1 / hitRate, 4);
2351
2975
  }
2352
2976
  function getUniquePayouts(payoutWeights) {
2353
2977
  return Object.keys(payoutWeights).length;
@@ -2366,15 +2990,15 @@ function getAvgWin(payoutWeights) {
2366
2990
  const payout = parseFloat(payoutStr);
2367
2991
  avgWin += payout * weight;
2368
2992
  }
2369
- return avgWin;
2993
+ return round(avgWin, 4);
2370
2994
  }
2371
2995
  function getRtp(payoutWeights, cost) {
2372
2996
  const avgWin = getAvgWin(payoutWeights);
2373
- return avgWin / cost;
2997
+ return round(avgWin / cost, 4);
2374
2998
  }
2375
2999
  function getStandardDeviation(payoutWeights) {
2376
3000
  const variance = getVariance(payoutWeights);
2377
- return Math.sqrt(variance);
3001
+ return round(Math.sqrt(variance), 4);
2378
3002
  }
2379
3003
  function getVariance(payoutWeights) {
2380
3004
  const totalWeight = getTotalWeight(payoutWeights);
@@ -2384,7 +3008,7 @@ function getVariance(payoutWeights) {
2384
3008
  const payout = parseFloat(payoutStr);
2385
3009
  variance += Math.pow(payout - avgWin, 2) * (weight / totalWeight);
2386
3010
  }
2387
- return variance;
3011
+ return round(variance, 4);
2388
3012
  }
2389
3013
  function getLessBetHitrate(payoutWeights, cost) {
2390
3014
  let lessBetWeight = 0;
@@ -2395,80 +3019,29 @@ function getLessBetHitrate(payoutWeights, cost) {
2395
3019
  lessBetWeight += weight;
2396
3020
  }
2397
3021
  }
2398
- return lessBetWeight / totalWeight;
3022
+ return round(lessBetWeight / totalWeight, 4);
2399
3023
  }
2400
3024
 
2401
3025
  // src/analysis/index.ts
2402
- import { isMainThread as isMainThread2 } from "worker_threads";
3026
+ import { isMainThread as isMainThread3 } from "worker_threads";
2403
3027
  var Analysis = class {
2404
- gameConfig;
2405
- optimizerConfig;
2406
- filePaths;
2407
- constructor(optimizer) {
2408
- this.gameConfig = optimizer.getGameConfig();
2409
- this.optimizerConfig = optimizer.getOptimizerGameModes();
2410
- this.filePaths = {};
3028
+ game;
3029
+ constructor(game) {
3030
+ this.game = game;
2411
3031
  }
2412
3032
  async runAnalysis(gameModes) {
2413
- if (!isMainThread2) return;
2414
- this.filePaths = this.getPathsForModes(gameModes);
3033
+ if (!isMainThread3) return;
2415
3034
  this.getNumberStats(gameModes);
2416
3035
  this.getWinRanges(gameModes);
2417
3036
  console.log("Analysis complete. Files written to build directory.");
2418
3037
  }
2419
- getPathsForModes(gameModes) {
2420
- const rootPath = this.gameConfig.rootDir;
2421
- const paths = {};
2422
- for (const modeStr of gameModes) {
2423
- const lut = path2.join(
2424
- rootPath,
2425
- this.gameConfig.outputDir,
2426
- `lookUpTable_${modeStr}.csv`
2427
- );
2428
- const lutSegmented = path2.join(
2429
- rootPath,
2430
- this.gameConfig.outputDir,
2431
- `lookUpTableSegmented_${modeStr}.csv`
2432
- );
2433
- const lutOptimized = path2.join(
2434
- rootPath,
2435
- this.gameConfig.outputDir,
2436
- "publish_files",
2437
- `lookUpTable_${modeStr}_0.csv`
2438
- );
2439
- const booksJsonl = path2.join(
2440
- rootPath,
2441
- this.gameConfig.outputDir,
2442
- `books_${modeStr}.jsonl`
2443
- );
2444
- const booksJsonlCompressed = path2.join(
2445
- rootPath,
2446
- this.gameConfig.outputDir,
2447
- "publish_files",
2448
- `books_${modeStr}.jsonl.zst`
2449
- );
2450
- paths[modeStr] = {
2451
- lut,
2452
- lutSegmented,
2453
- lutOptimized,
2454
- booksJsonl,
2455
- booksJsonlCompressed
2456
- };
2457
- for (const p of Object.values(paths[modeStr])) {
2458
- assert8(
2459
- fs3.existsSync(p),
2460
- `File "${p}" does not exist. Run optimization to auto-create it.`
2461
- );
2462
- }
2463
- }
2464
- return paths;
2465
- }
2466
3038
  getNumberStats(gameModes) {
3039
+ const meta = this.game.getMetadata();
2467
3040
  const stats = [];
2468
3041
  for (const modeStr of gameModes) {
2469
3042
  const mode = this.getGameModeConfig(modeStr);
2470
3043
  const lutOptimized = parseLookupTable(
2471
- fs3.readFileSync(this.filePaths[modeStr].lutOptimized, "utf-8")
3044
+ fs4.readFileSync(meta.paths.lookupTablePublish(modeStr), "utf-8")
2472
3045
  );
2473
3046
  const totalWeight = getTotalLutWeight(lutOptimized);
2474
3047
  const payoutWeights = getPayoutWeights(lutOptimized);
@@ -2488,10 +3061,7 @@ var Analysis = class {
2488
3061
  uniquePayouts: getUniquePayouts(payoutWeights)
2489
3062
  });
2490
3063
  }
2491
- writeJsonFile(
2492
- path2.join(this.gameConfig.rootDir, this.gameConfig.outputDir, "stats_summary.json"),
2493
- stats
2494
- );
3064
+ writeJsonFile(meta.paths.statsSummary, stats);
2495
3065
  }
2496
3066
  getWinRanges(gameModes) {
2497
3067
  const winRanges = [
@@ -2514,14 +3084,31 @@ var Analysis = class {
2514
3084
  [7500, 9999.99],
2515
3085
  [1e4, 14999.99],
2516
3086
  [15e3, 19999.99],
2517
- [2e4, 24999.99]
3087
+ [2e4, 24999.99],
3088
+ [25e3, 49999.99],
3089
+ [5e4, 74999.99],
3090
+ [75e3, 99999.99],
3091
+ [1e5, Infinity]
2518
3092
  ];
2519
- const payoutRanges = {};
3093
+ const payoutRanges = [];
3094
+ const meta = this.game.getMetadata();
2520
3095
  for (const modeStr of gameModes) {
2521
- payoutRanges[modeStr] = { overall: {}, criteria: {} };
2522
3096
  const lutSegmented = parseLookupTableSegmented(
2523
- fs3.readFileSync(this.filePaths[modeStr].lutSegmented, "utf-8")
3097
+ fs4.readFileSync(meta.paths.lookupTableSegmented(modeStr), "utf-8")
2524
3098
  );
3099
+ const range = {
3100
+ gameMode: modeStr,
3101
+ allPayouts: {
3102
+ overall: {},
3103
+ criteria: {}
3104
+ },
3105
+ uniquePayouts: {
3106
+ overall: {},
3107
+ criteria: {}
3108
+ }
3109
+ };
3110
+ const uniquePayoutsOverall = /* @__PURE__ */ new Map();
3111
+ const uniquePayoutsCriteria = /* @__PURE__ */ new Map();
2525
3112
  lutSegmented.forEach(([, criteria, bp, fsp]) => {
2526
3113
  const basePayout = bp;
2527
3114
  const freeSpinPayout = fsp;
@@ -2529,32 +3116,54 @@ var Analysis = class {
2529
3116
  for (const [min, max] of winRanges) {
2530
3117
  if (payout >= min && payout <= max) {
2531
3118
  const rangeKey = `${min}-${max}`;
2532
- if (!payoutRanges[modeStr].overall[rangeKey]) {
2533
- payoutRanges[modeStr].overall[rangeKey] = 0;
3119
+ if (!range.allPayouts.overall[rangeKey]) {
3120
+ range.allPayouts.overall[rangeKey] = 0;
2534
3121
  }
2535
- payoutRanges[modeStr].overall[rangeKey] += 1;
2536
- if (!payoutRanges[modeStr].criteria[criteria]) {
2537
- payoutRanges[modeStr].criteria[criteria] = {};
3122
+ range.allPayouts.overall[rangeKey] += 1;
3123
+ if (!range.allPayouts.criteria[criteria]) {
3124
+ range.allPayouts.criteria[criteria] = {};
2538
3125
  }
2539
- if (!payoutRanges[modeStr].criteria[criteria][rangeKey]) {
2540
- payoutRanges[modeStr].criteria[criteria][rangeKey] = 0;
3126
+ if (!range.allPayouts.criteria[criteria][rangeKey]) {
3127
+ range.allPayouts.criteria[criteria][rangeKey] = 0;
2541
3128
  }
2542
- payoutRanges[modeStr].criteria[criteria][rangeKey] += 1;
3129
+ range.allPayouts.criteria[criteria][rangeKey] += 1;
3130
+ if (!uniquePayoutsOverall.has(rangeKey)) {
3131
+ uniquePayoutsOverall.set(rangeKey, /* @__PURE__ */ new Set());
3132
+ }
3133
+ uniquePayoutsOverall.get(rangeKey).add(payout);
3134
+ if (!uniquePayoutsCriteria.has(criteria)) {
3135
+ uniquePayoutsCriteria.set(criteria, /* @__PURE__ */ new Map());
3136
+ }
3137
+ if (!uniquePayoutsCriteria.get(criteria).has(rangeKey)) {
3138
+ uniquePayoutsCriteria.get(criteria).set(rangeKey, /* @__PURE__ */ new Set());
3139
+ }
3140
+ uniquePayoutsCriteria.get(criteria).get(rangeKey).add(payout);
2543
3141
  break;
2544
3142
  }
2545
3143
  }
2546
3144
  });
2547
- const orderedOverall = {};
2548
- Object.keys(payoutRanges[modeStr].overall).sort((a, b) => {
3145
+ uniquePayoutsOverall.forEach((payoutSet, rangeKey) => {
3146
+ range.uniquePayouts.overall[rangeKey] = payoutSet.size;
3147
+ });
3148
+ uniquePayoutsCriteria.forEach((rangeMap, criteria) => {
3149
+ if (!range.uniquePayouts.criteria[criteria]) {
3150
+ range.uniquePayouts.criteria[criteria] = {};
3151
+ }
3152
+ rangeMap.forEach((payoutSet, rangeKey) => {
3153
+ range.uniquePayouts.criteria[criteria][rangeKey] = payoutSet.size;
3154
+ });
3155
+ });
3156
+ const orderedAllOverall = {};
3157
+ Object.keys(range.allPayouts.overall).sort((a, b) => {
2549
3158
  const [aMin] = a.split("-").map(Number);
2550
3159
  const [bMin] = b.split("-").map(Number);
2551
3160
  return aMin - bMin;
2552
3161
  }).forEach((key) => {
2553
- orderedOverall[key] = payoutRanges[modeStr].overall[key];
3162
+ orderedAllOverall[key] = range.allPayouts.overall[key];
2554
3163
  });
2555
- const orderedCriteria = {};
2556
- Object.keys(payoutRanges[modeStr].criteria).forEach((crit) => {
2557
- const critMap = payoutRanges[modeStr].criteria[crit];
3164
+ const orderedAllCriteria = {};
3165
+ Object.keys(range.allPayouts.criteria).forEach((crit) => {
3166
+ const critMap = range.allPayouts.criteria[crit];
2558
3167
  const orderedCritMap = {};
2559
3168
  Object.keys(critMap).sort((a, b) => {
2560
3169
  const [aMin] = a.split("-").map(Number);
@@ -2563,35 +3172,61 @@ var Analysis = class {
2563
3172
  }).forEach((key) => {
2564
3173
  orderedCritMap[key] = critMap[key];
2565
3174
  });
2566
- orderedCriteria[crit] = orderedCritMap;
3175
+ orderedAllCriteria[crit] = orderedCritMap;
3176
+ });
3177
+ const orderedUniqueOverall = {};
3178
+ Object.keys(range.uniquePayouts.overall).sort((a, b) => {
3179
+ const [aMin] = a.split("-").map(Number);
3180
+ const [bMin] = b.split("-").map(Number);
3181
+ return aMin - bMin;
3182
+ }).forEach((key) => {
3183
+ orderedUniqueOverall[key] = range.uniquePayouts.overall[key];
3184
+ });
3185
+ const orderedUniqueCriteria = {};
3186
+ Object.keys(range.uniquePayouts.criteria).forEach((crit) => {
3187
+ const critMap = range.uniquePayouts.criteria[crit];
3188
+ const orderedCritMap = {};
3189
+ Object.keys(critMap).sort((a, b) => {
3190
+ const [aMin] = a.split("-").map(Number);
3191
+ const [bMin] = b.split("-").map(Number);
3192
+ return aMin - bMin;
3193
+ }).forEach((key) => {
3194
+ orderedCritMap[key] = critMap[key];
3195
+ });
3196
+ orderedUniqueCriteria[crit] = orderedCritMap;
3197
+ });
3198
+ payoutRanges.push({
3199
+ gameMode: modeStr,
3200
+ allPayouts: {
3201
+ overall: orderedAllOverall,
3202
+ criteria: orderedAllCriteria
3203
+ },
3204
+ uniquePayouts: {
3205
+ overall: orderedUniqueOverall,
3206
+ criteria: orderedUniqueCriteria
3207
+ }
2567
3208
  });
2568
- payoutRanges[modeStr] = {
2569
- overall: orderedOverall,
2570
- criteria: {}
2571
- };
2572
3209
  }
2573
- writeJsonFile(
2574
- path2.join(this.gameConfig.rootDir, this.gameConfig.outputDir, "stats_payouts.json"),
2575
- payoutRanges
2576
- );
3210
+ writeJsonFile(meta.paths.statsPayouts, payoutRanges);
2577
3211
  }
2578
3212
  getGameModeConfig(mode) {
2579
- const config = this.gameConfig.gameModes[mode];
2580
- assert8(config, `Game mode "${mode}" not found in game config`);
3213
+ const config = this.game.getConfig().gameModes[mode];
3214
+ assert6(config, `Game mode "${mode}" not found in game config`);
2581
3215
  return config;
2582
3216
  }
2583
3217
  };
2584
3218
 
2585
3219
  // src/optimizer/index.ts
2586
- import path5 from "path";
2587
- import assert10 from "assert";
3220
+ import path6 from "path";
3221
+ import assert8 from "assert";
2588
3222
  import { spawn } from "child_process";
2589
- import { isMainThread as isMainThread3 } from "worker_threads";
3223
+ import { isMainThread as isMainThread4 } from "worker_threads";
2590
3224
 
2591
3225
  // src/utils/math-config.ts
2592
- import path3 from "path";
3226
+ import path4 from "path";
2593
3227
  function makeMathConfig(optimizer, opts = {}) {
2594
3228
  const game = optimizer.getGameConfig();
3229
+ const meta = optimizer.getGameMeta();
2595
3230
  const gameModesCfg = optimizer.getOptimizerGameModes();
2596
3231
  const { writeToFile } = opts;
2597
3232
  const isDefined = (v) => v !== void 0;
@@ -2633,16 +3268,17 @@ function makeMathConfig(optimizer, opts = {}) {
2633
3268
  }))
2634
3269
  };
2635
3270
  if (writeToFile) {
2636
- const outPath = path3.join(game.rootDir, game.outputDir, "math_config.json");
3271
+ const outPath = path4.join(meta.rootDir, meta.outputDir, "math_config.json");
2637
3272
  writeJsonFile(outPath, config);
2638
3273
  }
2639
3274
  return config;
2640
3275
  }
2641
3276
 
2642
3277
  // src/utils/setup-file.ts
2643
- import path4 from "path";
3278
+ import path5 from "path";
2644
3279
  function makeSetupFile(optimizer, gameMode) {
2645
3280
  const gameConfig = optimizer.getGameConfig();
3281
+ const gameMeta = optimizer.getGameMeta();
2646
3282
  const optimizerGameModes = optimizer.getOptimizerGameModes();
2647
3283
  const modeConfig = optimizerGameModes[gameMode];
2648
3284
  if (!modeConfig) {
@@ -2676,16 +3312,16 @@ function makeSetupFile(optimizer, gameMode) {
2676
3312
  `;
2677
3313
  content += `simulation_trials;${params.simulationTrials}
2678
3314
  `;
2679
- content += `user_game_build_path;${path4.join(gameConfig.rootDir, gameConfig.outputDir)}
3315
+ content += `user_game_build_path;${path5.join(gameMeta.rootDir, gameMeta.outputDir)}
2680
3316
  `;
2681
3317
  content += `pmb_rtp;${params.pmbRtp}
2682
3318
  `;
2683
- const outPath = path4.join(__dirname, "./optimizer-rust/src", "setup.txt");
3319
+ const outPath = path5.join(__dirname, "./optimizer-rust/src", "setup.txt");
2684
3320
  writeFile(outPath, content);
2685
3321
  }
2686
3322
 
2687
3323
  // src/optimizer/OptimizationConditions.ts
2688
- import assert9 from "assert";
3324
+ import assert7 from "assert";
2689
3325
  var OptimizationConditions = class {
2690
3326
  rtp;
2691
3327
  avgWin;
@@ -2696,14 +3332,14 @@ var OptimizationConditions = class {
2696
3332
  constructor(opts) {
2697
3333
  let { rtp, avgWin, hitRate, searchConditions, priority } = opts;
2698
3334
  if (rtp == void 0 || rtp === "x") {
2699
- assert9(avgWin !== void 0 && hitRate !== void 0, "If RTP is not specified, hit-rate (hr) and average win amount (av_win) must be given.");
3335
+ assert7(avgWin !== void 0 && hitRate !== void 0, "If RTP is not specified, hit-rate (hr) and average win amount (av_win) must be given.");
2700
3336
  rtp = Math.round(avgWin / Number(hitRate) * 1e5) / 1e5;
2701
3337
  }
2702
3338
  let noneCount = 0;
2703
3339
  for (const val of [rtp, avgWin, hitRate]) {
2704
3340
  if (val === void 0) noneCount++;
2705
3341
  }
2706
- assert9(noneCount <= 1, "Invalid combination of optimization conditions.");
3342
+ assert7(noneCount <= 1, "Invalid combination of optimization conditions.");
2707
3343
  this.searchRange = [-1, -1];
2708
3344
  this.forceSearch = {};
2709
3345
  if (typeof searchConditions === "number") {
@@ -2784,9 +3420,11 @@ var OptimizationParameters = class _OptimizationParameters {
2784
3420
  // src/optimizer/index.ts
2785
3421
  var Optimizer = class {
2786
3422
  gameConfig;
3423
+ gameMeta;
2787
3424
  gameModes;
2788
3425
  constructor(opts) {
2789
3426
  this.gameConfig = opts.game.getConfig();
3427
+ this.gameMeta = opts.game.getMetadata();
2790
3428
  this.gameModes = opts.gameModes;
2791
3429
  this.verifyConfig();
2792
3430
  }
@@ -2794,11 +3432,15 @@ var Optimizer = class {
2794
3432
  * Runs the optimization process, and runs analysis after.
2795
3433
  */
2796
3434
  async runOptimization({ gameModes }) {
2797
- if (!isMainThread3) return;
3435
+ if (!isMainThread4) return;
2798
3436
  const mathConfig = makeMathConfig(this, { writeToFile: true });
2799
3437
  for (const mode of gameModes) {
2800
3438
  const setupFile = makeSetupFile(this, mode);
2801
3439
  await this.runSingleOptimization();
3440
+ await makeLutIndexFromPublishLut(
3441
+ this.gameMeta.paths.lookupTablePublish(mode),
3442
+ this.gameMeta.paths.lookupTableIndex(mode)
3443
+ );
2802
3444
  }
2803
3445
  console.log("Optimization complete. Files written to build directory.");
2804
3446
  }
@@ -2831,7 +3473,7 @@ var Optimizer = class {
2831
3473
  }
2832
3474
  gameModeRtp = Math.round(gameModeRtp * 1e3) / 1e3;
2833
3475
  paramRtp = Math.round(paramRtp * 1e3) / 1e3;
2834
- assert10(
3476
+ assert8(
2835
3477
  gameModeRtp === paramRtp,
2836
3478
  `Sum of all RTP conditions (${paramRtp}) does not match the game mode RTP (${gameModeRtp}) in game mode "${k}".`
2837
3479
  );
@@ -2840,6 +3482,9 @@ var Optimizer = class {
2840
3482
  getGameConfig() {
2841
3483
  return this.gameConfig;
2842
3484
  }
3485
+ getGameMeta() {
3486
+ return this.gameMeta;
3487
+ }
2843
3488
  getOptimizerGameModes() {
2844
3489
  return this.gameModes;
2845
3490
  }
@@ -2848,7 +3493,7 @@ async function rustProgram(...args) {
2848
3493
  console.log("Starting Rust optimizer. This may take a while...");
2849
3494
  return new Promise((resolve, reject) => {
2850
3495
  const task = spawn("cargo", ["run", "-q", "--release", ...args], {
2851
- cwd: path5.join(__dirname, "./optimizer-rust"),
3496
+ cwd: path6.join(__dirname, "./optimizer-rust"),
2852
3497
  stdio: "pipe"
2853
3498
  });
2854
3499
  task.on("error", (error) => {
@@ -2875,8 +3520,8 @@ async function rustProgram(...args) {
2875
3520
  }
2876
3521
 
2877
3522
  // src/slot-game/index.ts
2878
- import { isMainThread as isMainThread4 } from "worker_threads";
2879
- var SlotGame = class {
3523
+ import { isMainThread as isMainThread5, workerData as workerData2 } from "worker_threads";
3524
+ var SlotGame = class _SlotGame {
2880
3525
  configOpts;
2881
3526
  simulation;
2882
3527
  optimizer;
@@ -2926,38 +3571,56 @@ var SlotGame = class {
2926
3571
  /**
2927
3572
  * Runs the analysis based on the configured settings.
2928
3573
  */
2929
- async runAnalysis(opts) {
2930
- if (!this.optimizer) {
2931
- throw new Error(
2932
- "Optimization must be configured to run analysis. Do so by calling configureOptimization() first."
2933
- );
2934
- }
2935
- this.analyzer = new Analysis(this.optimizer);
2936
- await this.analyzer.runAnalysis(opts.gameModes);
3574
+ runAnalysis(opts) {
3575
+ this.analyzer = new Analysis(this);
3576
+ this.analyzer.runAnalysis(opts.gameModes);
2937
3577
  }
2938
3578
  /**
2939
3579
  * Runs the configured tasks: simulation, optimization, and/or analysis.
2940
3580
  */
2941
3581
  async runTasks(opts = {}) {
3582
+ if (isMainThread5 && !opts._internal_ignore_args) {
3583
+ const [{ default: yargs }, { hideBin }] = await Promise.all([
3584
+ import("yargs"),
3585
+ import("yargs/helpers")
3586
+ ]);
3587
+ const argvParser = yargs(hideBin(process.argv)).options({
3588
+ [CLI_ARGS.RUN]: { type: "boolean", default: false }
3589
+ });
3590
+ const argv = await argvParser.parse();
3591
+ if (!argv[CLI_ARGS.RUN]) return;
3592
+ }
3593
+ if (!isMainThread5 && workerData2 && typeof workerData2 === "object" && "simStart" in workerData2) {
3594
+ opts.doSimulation = true;
3595
+ }
2942
3596
  if (!opts.doSimulation && !opts.doOptimization && !opts.doAnalysis) {
2943
3597
  console.log("No tasks to run. Enable either simulation, optimization or analysis.");
2944
3598
  }
2945
3599
  if (opts.doSimulation) {
2946
3600
  await this.runSimulation(opts.simulationOpts || {});
2947
3601
  }
3602
+ if (opts.doAnalysis) {
3603
+ this.runAnalysis(opts.analysisOpts || { gameModes: [] });
3604
+ }
2948
3605
  if (opts.doOptimization) {
2949
3606
  await this.runOptimization(opts.optimizationOpts || { gameModes: [] });
3607
+ if (opts.doAnalysis) {
3608
+ this.runAnalysis(opts.analysisOpts || { gameModes: [] });
3609
+ }
2950
3610
  }
2951
- if (opts.doAnalysis) {
2952
- await this.runAnalysis(opts.analysisOpts || { gameModes: [] });
2953
- }
2954
- if (isMainThread4) console.log("Finishing up...");
3611
+ if (isMainThread5) console.log("Done!");
2955
3612
  }
2956
3613
  /**
2957
3614
  * Gets the game configuration.
2958
3615
  */
2959
3616
  getConfig() {
2960
- return createGameConfig(this.configOpts);
3617
+ return createGameConfig(this.configOpts).config;
3618
+ }
3619
+ getMetadata() {
3620
+ return createGameConfig(this.configOpts).metadata;
3621
+ }
3622
+ clone() {
3623
+ return new _SlotGame(this.configOpts);
2961
3624
  }
2962
3625
  };
2963
3626
 
@@ -2970,7 +3633,7 @@ var defineSymbols = (symbols) => symbols;
2970
3633
  var defineGameModes = (gameModes) => gameModes;
2971
3634
 
2972
3635
  // src/game-mode/index.ts
2973
- import assert11 from "assert";
3636
+ import assert9 from "assert";
2974
3637
  var GameMode = class {
2975
3638
  name;
2976
3639
  _reelsAmount;
@@ -2993,12 +3656,12 @@ var GameMode = class {
2993
3656
  this.reelSets = opts.reelSets;
2994
3657
  this.resultSets = opts.resultSets;
2995
3658
  this.isBonusBuy = opts.isBonusBuy;
2996
- assert11(this.rtp >= 0.9 && this.rtp <= 0.99, "RTP must be between 0.9 and 0.99");
2997
- assert11(
3659
+ assert9(this.rtp >= 0.9 && this.rtp <= 0.99, "RTP must be between 0.9 and 0.99");
3660
+ assert9(
2998
3661
  this.symbolsPerReel.length === this.reelsAmount,
2999
3662
  "symbolsPerReel length must match reelsAmount."
3000
3663
  );
3001
- assert11(this.reelSets.length > 0, "GameMode must have at least one ReelSet defined.");
3664
+ assert9(this.reelSets.length > 0, "GameMode must have at least one ReelSet defined.");
3002
3665
  }
3003
3666
  /**
3004
3667
  * Intended for internal use only.
@@ -3011,7 +3674,7 @@ var GameMode = class {
3011
3674
  * Intended for internal use only.
3012
3675
  */
3013
3676
  _setSymbolsPerReel(symbolsPerReel) {
3014
- assert11(
3677
+ assert9(
3015
3678
  symbolsPerReel.length === this._reelsAmount,
3016
3679
  "symbolsPerReel length must match reelsAmount."
3017
3680
  );
@@ -3077,7 +3740,7 @@ var WinType = class {
3077
3740
  };
3078
3741
 
3079
3742
  // src/win-types/LinesWinType.ts
3080
- import assert12 from "assert";
3743
+ import assert10 from "assert";
3081
3744
  var LinesWinType = class extends WinType {
3082
3745
  lines;
3083
3746
  constructor(opts) {
@@ -3131,8 +3794,8 @@ var LinesWinType = class extends WinType {
3131
3794
  if (!baseSymbol) {
3132
3795
  baseSymbol = thisSymbol;
3133
3796
  }
3134
- assert12(baseSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3135
- assert12(thisSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3797
+ assert10(baseSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3798
+ assert10(thisSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3136
3799
  if (potentialWinLine.length == 0) {
3137
3800
  if (this.isWild(thisSymbol)) {
3138
3801
  potentialWildLine.push({ reel: ridx, row: sidx, symbol: thisSymbol });
@@ -3447,13 +4110,13 @@ var ManywaysWinType = class extends WinType {
3447
4110
  };
3448
4111
 
3449
4112
  // src/reel-set/GeneratedReelSet.ts
3450
- import fs5 from "fs";
3451
- import path7 from "path";
3452
- import { isMainThread as isMainThread5 } from "worker_threads";
4113
+ import fs6 from "fs";
4114
+ import path8 from "path";
4115
+ import { isMainThread as isMainThread6 } from "worker_threads";
3453
4116
 
3454
4117
  // src/reel-set/index.ts
3455
- import fs4 from "fs";
3456
- import path6 from "path";
4118
+ import fs5 from "fs";
4119
+ import path7 from "path";
3457
4120
  var ReelSet = class {
3458
4121
  id;
3459
4122
  associatedGameModeName;
@@ -3473,11 +4136,11 @@ var ReelSet = class {
3473
4136
  * Reads a reelset CSV file and returns the reels as arrays of GameSymbols.
3474
4137
  */
3475
4138
  parseReelsetCSV(reelSetPath, config) {
3476
- if (!fs4.existsSync(reelSetPath)) {
4139
+ if (!fs5.existsSync(reelSetPath)) {
3477
4140
  throw new Error(`Reelset CSV file not found at path: ${reelSetPath}`);
3478
4141
  }
3479
4142
  const allowedExtensions = [".csv"];
3480
- const ext = path6.extname(reelSetPath).toLowerCase();
4143
+ const ext = path7.extname(reelSetPath).toLowerCase();
3481
4144
  if (!allowedExtensions.includes(ext)) {
3482
4145
  throw new Error(
3483
4146
  `Invalid file extension for reelset CSV: ${ext}. Allowed extensions are: ${allowedExtensions.join(
@@ -3485,7 +4148,7 @@ var ReelSet = class {
3485
4148
  )}`
3486
4149
  );
3487
4150
  }
3488
- const csvData = fs4.readFileSync(reelSetPath, "utf8");
4151
+ const csvData = fs5.readFileSync(reelSetPath, "utf8");
3489
4152
  const rows = csvData.split("\n").filter((line) => line.trim() !== "");
3490
4153
  const reels = Array.from(
3491
4154
  { length: config.gameModes[this.associatedGameModeName].reelsAmount },
@@ -3493,6 +4156,7 @@ var ReelSet = class {
3493
4156
  );
3494
4157
  rows.forEach((row) => {
3495
4158
  const symsInRow = row.split(",").map((symbolId) => {
4159
+ if (!symbolId.trim()) return null;
3496
4160
  const symbol = config.symbols.get(symbolId.trim());
3497
4161
  if (!symbol) {
3498
4162
  throw new Error(`Symbol with id "${symbolId}" not found in game config.`);
@@ -3505,18 +4169,9 @@ var ReelSet = class {
3505
4169
  `Row in reelset CSV has more symbols than expected reels amount (${reels.length})`
3506
4170
  );
3507
4171
  }
3508
- reels[ridx].push(symbol);
4172
+ if (symbol) reels[ridx].push(symbol);
3509
4173
  });
3510
4174
  });
3511
- const reelLengths = reels.map((r) => r.length);
3512
- const uniqueLengths = new Set(reelLengths);
3513
- if (uniqueLengths.size > 1) {
3514
- throw new Error(
3515
- `Inconsistent reel lengths in reelset CSV at ${reelSetPath}: ${[
3516
- ...uniqueLengths
3517
- ].join(", ")}`
3518
- );
3519
- }
3520
4175
  return reels;
3521
4176
  }
3522
4177
  };
@@ -3646,12 +4301,12 @@ var GeneratedReelSet = class extends ReelSet {
3646
4301
  `Error generating reels for game mode "${this.associatedGameModeName}". It's not defined in the game config.`
3647
4302
  );
3648
4303
  }
3649
- const outputDir = config.rootDir.endsWith(config.outputDir) ? config.rootDir : path7.join(config.rootDir, config.outputDir);
3650
- const filePath = path7.join(
4304
+ const outputDir = config.rootDir.endsWith(config.outputDir) ? config.rootDir : path8.join(config.rootDir, config.outputDir);
4305
+ const filePath = path8.join(
3651
4306
  outputDir,
3652
4307
  `reels_${this.associatedGameModeName}-${this.id}.csv`
3653
4308
  );
3654
- const exists = fs5.existsSync(filePath);
4309
+ const exists = fs6.existsSync(filePath);
3655
4310
  if (exists && !this.overrideExisting) {
3656
4311
  this.reels = this.parseReelsetCSV(filePath, config);
3657
4312
  return this;
@@ -3788,9 +4443,9 @@ var GeneratedReelSet = class extends ReelSet {
3788
4443
  }
3789
4444
  }
3790
4445
  const csvString = csvRows.map((row) => row.join(",")).join("\n");
3791
- if (isMainThread5) {
4446
+ if (isMainThread6) {
3792
4447
  createDirIfNotExists(outputDir);
3793
- fs5.writeFileSync(filePath, csvString);
4448
+ fs6.writeFileSync(filePath, csvString);
3794
4449
  console.log(
3795
4450
  `Generated reelset ${this.id} for game mode ${this.associatedGameModeName}`
3796
4451
  );
@@ -3801,7 +4456,7 @@ var GeneratedReelSet = class extends ReelSet {
3801
4456
  };
3802
4457
 
3803
4458
  // src/reel-set/StaticReelSet.ts
3804
- import assert13 from "assert";
4459
+ import assert11 from "assert";
3805
4460
  var StaticReelSet = class extends ReelSet {
3806
4461
  reels;
3807
4462
  csvPath;
@@ -3811,7 +4466,7 @@ var StaticReelSet = class extends ReelSet {
3811
4466
  this.reels = [];
3812
4467
  this._strReels = opts.reels || [];
3813
4468
  this.csvPath = opts.csvPath || "";
3814
- assert13(
4469
+ assert11(
3815
4470
  opts.reels || opts.csvPath,
3816
4471
  `Either 'reels' or 'csvPath' must be provided for StaticReelSet ${this.id}`
3817
4472
  );
@@ -4064,6 +4719,7 @@ export {
4064
4719
  OptimizationConditions,
4065
4720
  OptimizationParameters,
4066
4721
  OptimizationScaling,
4722
+ RandomNumberGenerator,
4067
4723
  ResultSet,
4068
4724
  SPIN_TYPE,
4069
4725
  StandaloneBoard,
@@ -4071,6 +4727,8 @@ export {
4071
4727
  createSlotGame,
4072
4728
  defineGameModes,
4073
4729
  defineSymbols,
4074
- defineUserState
4730
+ defineUserState,
4731
+ parseLookupTable,
4732
+ parseLookupTableSegmented
4075
4733
  };
4076
4734
  //# sourceMappingURL=index.mjs.map