@slot-engine/core 0.1.14-test.0 → 0.2.0

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