@slot-engine/core 0.1.14 → 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,18 +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, {
2592
+ if (fs3.existsSync(tempRecordsPath)) {
2593
+ const fileStream = fs3.createReadStream(tempRecordsPath, {
2098
2594
  highWaterMark: this.streamHighWaterMark
2099
2595
  });
2100
- const rl = readline2.createInterface({
2596
+ const rl = readline.createInterface({
2101
2597
  input: fileStream,
2102
2598
  crlfDelay: Infinity
2103
2599
  });
2104
2600
  for await (const line of rl) {
2105
2601
  if (line.trim() === "") continue;
2106
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
+ }
2107
2609
  const key = JSON.stringify(record.search);
2108
2610
  let existing = aggregatedRecords.get(key);
2109
2611
  if (!existing) {
@@ -2120,8 +2622,9 @@ ${error.stack}
2120
2622
  }
2121
2623
  }
2122
2624
  }
2123
- fs2.rmSync(forceRecordsPath, { force: true });
2124
- 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" });
2125
2628
  writeStream.write("[\n");
2126
2629
  let isFirst = true;
2127
2630
  for (const record of aggregatedRecords.values()) {
@@ -2136,13 +2639,17 @@ ${error.stack}
2136
2639
  await new Promise((resolve) => {
2137
2640
  writeStream.on("finish", () => resolve());
2138
2641
  });
2139
- 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 });
2140
2647
  }
2141
2648
  writeIndexJson() {
2142
2649
  const outputFilePath = this.PATHS.indexJson;
2143
2650
  const modes = Object.keys(this.simRunsAmount).map((id) => {
2144
2651
  const mode = this.gameConfig.gameModes[id];
2145
- assert7(mode, `Game mode "${id}" not found in game config.`);
2652
+ assert5(mode, `Game mode "${id}" not found in game config.`);
2146
2653
  return {
2147
2654
  name: mode.name,
2148
2655
  cost: mode.cost,
@@ -2152,38 +2659,26 @@ ${error.stack}
2152
2659
  });
2153
2660
  writeFile(outputFilePath, JSON.stringify({ modes }, null, 2));
2154
2661
  }
2155
- async writeBooksJson(gameMode) {
2156
- const outputFilePath = this.PATHS.books(gameMode);
2157
- const compressedFilePath = this.PATHS.booksCompressed(gameMode);
2158
- fs2.rmSync(compressedFilePath, { force: true });
2159
- if (fs2.existsSync(outputFilePath)) {
2160
- await pipeline(
2161
- fs2.createReadStream(outputFilePath),
2162
- zlib.createZstdCompress(),
2163
- fs2.createWriteStream(compressedFilePath)
2164
- );
2165
- }
2166
- }
2167
2662
  /**
2168
2663
  * Compiles user configured game to JS for use in different Node processes
2169
2664
  */
2170
2665
  preprocessFiles() {
2171
- const builtFilePath = path.join(
2666
+ const builtFilePath = path3.join(
2172
2667
  this.gameConfig.rootDir,
2173
2668
  this.gameConfig.outputDir,
2174
2669
  TEMP_FILENAME
2175
2670
  );
2176
- fs2.rmSync(builtFilePath, { force: true });
2671
+ fs3.rmSync(builtFilePath, { force: true });
2177
2672
  buildSync({
2178
2673
  entryPoints: [this.gameConfig.rootDir],
2179
2674
  bundle: true,
2180
2675
  platform: "node",
2181
- outfile: path.join(
2676
+ outfile: path3.join(
2182
2677
  this.gameConfig.rootDir,
2183
2678
  this.gameConfig.outputDir,
2184
2679
  TEMP_FILENAME
2185
2680
  ),
2186
- external: ["esbuild"]
2681
+ external: ["esbuild", "yargs"]
2187
2682
  });
2188
2683
  }
2189
2684
  getSimRangesForChunks(total, chunks) {
@@ -2218,27 +2713,62 @@ ${error.stack}
2218
2713
  }
2219
2714
  }
2220
2715
  }
2221
- 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) {
2222
2724
  try {
2223
- fs2.rmSync(outPath, { force: true });
2224
- const out = fs2.createWriteStream(outPath, {
2725
+ fs3.rmSync(outPath, { force: true });
2726
+ const lutStream = fs3.createWriteStream(outPath, {
2225
2727
  highWaterMark: this.streamHighWaterMark
2226
2728
  });
2729
+ const lutIndexStream = lutIndexPath ? fs3.createWriteStream(lutIndexPath, {
2730
+ highWaterMark: this.streamHighWaterMark
2731
+ }) : void 0;
2732
+ let offset = 0n;
2227
2733
  for (let i = 0; i < chunks.length; i++) {
2228
- const p = path.join(this.PATHS.base, TEMP_FOLDER, tempName(i));
2229
- if (!fs2.existsSync(p)) continue;
2230
- const rs = fs2.createReadStream(p, {
2231
- highWaterMark: this.streamHighWaterMark
2232
- });
2233
- for await (const buf of rs) {
2234
- if (!out.write(buf)) {
2235
- await new Promise((resolve) => out.once("drain", resolve));
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
+ }
2236
2762
  }
2237
2763
  }
2238
- fs2.rmSync(p);
2764
+ fs3.rmSync(tempLutChunk);
2239
2765
  }
2240
- out.end();
2241
- 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
+ ]);
2242
2772
  } catch (error) {
2243
2773
  throw new Error(`Error merging CSV files: ${error.message}`);
2244
2774
  }
@@ -2249,21 +2779,16 @@ ${error.stack}
2249
2779
  confirmRecords(ctx) {
2250
2780
  const recorder = ctx.services.data._getRecorder();
2251
2781
  for (const pendingRecord of recorder.pendingRecords) {
2252
- const search = Object.entries(pendingRecord.properties).map(([name, value]) => ({ name, value })).sort((a, b) => a.name.localeCompare(b.name));
2253
- let record = recorder.records.find((r) => {
2254
- if (r.search.length !== search.length) return false;
2255
- for (let i = 0; i < r.search.length; i++) {
2256
- if (r.search[i].name !== search[i].name) return false;
2257
- if (r.search[i].value !== search[i].value) return false;
2258
- }
2259
- return true;
2260
- });
2782
+ const key = Object.keys(pendingRecord.properties).sort().map((k) => `${k}:${pendingRecord.properties[k]}`).join("|");
2783
+ let record = recorder.recordsMap.get(key);
2261
2784
  if (!record) {
2785
+ const search = Object.entries(pendingRecord.properties).map(([name, value]) => ({ name, value })).sort((a, b) => a.name.localeCompare(b.name));
2262
2786
  record = {
2263
2787
  search,
2264
2788
  timesTriggered: 0,
2265
2789
  bookIds: []
2266
2790
  };
2791
+ recorder.recordsMap.set(key, record);
2267
2792
  recorder.records.push(record);
2268
2793
  }
2269
2794
  record.timesTriggered++;
@@ -2273,18 +2798,71 @@ ${error.stack}
2273
2798
  }
2274
2799
  recorder.pendingRecords = [];
2275
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
+ }
2276
2854
  };
2277
2855
 
2278
2856
  // src/analysis/index.ts
2279
- import fs3 from "fs";
2280
- import path2 from "path";
2281
- import assert8 from "assert";
2857
+ import fs4 from "fs";
2858
+ import assert6 from "assert";
2282
2859
 
2283
2860
  // src/analysis/utils.ts
2284
2861
  function parseLookupTable(content) {
2285
2862
  const lines = content.trim().split("\n");
2286
2863
  const lut = [];
2287
2864
  for (const line of lines) {
2865
+ if (!line.trim()) continue;
2288
2866
  const [indexStr, weightStr, payoutStr] = line.split(",");
2289
2867
  const index = parseInt(indexStr.trim());
2290
2868
  const weight = parseInt(weightStr.trim());
@@ -2297,11 +2875,12 @@ function parseLookupTableSegmented(content) {
2297
2875
  const lines = content.trim().split("\n");
2298
2876
  const lut = [];
2299
2877
  for (const line of lines) {
2300
- const [indexStr, criteria, weightStr, payoutStr] = line.split(",");
2878
+ if (!line.trim()) continue;
2879
+ const [indexStr, criteria, bsWinsStr, fsWinsStr] = line.split(",");
2301
2880
  const index = parseInt(indexStr.trim());
2302
- const weight = parseInt(weightStr.trim());
2303
- const payout = parseFloat(payoutStr.trim());
2304
- 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]);
2305
2884
  }
2306
2885
  return lut;
2307
2886
  }
@@ -2335,19 +2914,19 @@ function getPayoutWeights(lut, opts = {}) {
2335
2914
  function getNonZeroHitrate(payoutWeights) {
2336
2915
  const totalWeight = getTotalWeight(payoutWeights);
2337
2916
  if (Math.min(...Object.keys(payoutWeights).map(Number)) == 0) {
2338
- return totalWeight / (totalWeight - (payoutWeights[0] ?? 0) / totalWeight);
2917
+ return round(totalWeight / (totalWeight - (payoutWeights[0] ?? 0) / totalWeight), 4);
2339
2918
  } else {
2340
2919
  return 1;
2341
2920
  }
2342
2921
  }
2343
2922
  function getNullHitrate(payoutWeights) {
2344
- return payoutWeights[0] ?? 0;
2923
+ return round(payoutWeights[0] ?? 0, 4);
2345
2924
  }
2346
2925
  function getMaxwinHitrate(payoutWeights) {
2347
2926
  const totalWeight = getTotalWeight(payoutWeights);
2348
2927
  const maxWin = Math.max(...Object.keys(payoutWeights).map(Number));
2349
2928
  const hitRate = (payoutWeights[maxWin] || 0) / totalWeight;
2350
- return 1 / hitRate;
2929
+ return round(1 / hitRate, 4);
2351
2930
  }
2352
2931
  function getUniquePayouts(payoutWeights) {
2353
2932
  return Object.keys(payoutWeights).length;
@@ -2366,15 +2945,15 @@ function getAvgWin(payoutWeights) {
2366
2945
  const payout = parseFloat(payoutStr);
2367
2946
  avgWin += payout * weight;
2368
2947
  }
2369
- return avgWin;
2948
+ return round(avgWin, 4);
2370
2949
  }
2371
2950
  function getRtp(payoutWeights, cost) {
2372
2951
  const avgWin = getAvgWin(payoutWeights);
2373
- return avgWin / cost;
2952
+ return round(avgWin / cost, 4);
2374
2953
  }
2375
2954
  function getStandardDeviation(payoutWeights) {
2376
2955
  const variance = getVariance(payoutWeights);
2377
- return Math.sqrt(variance);
2956
+ return round(Math.sqrt(variance), 4);
2378
2957
  }
2379
2958
  function getVariance(payoutWeights) {
2380
2959
  const totalWeight = getTotalWeight(payoutWeights);
@@ -2384,7 +2963,7 @@ function getVariance(payoutWeights) {
2384
2963
  const payout = parseFloat(payoutStr);
2385
2964
  variance += Math.pow(payout - avgWin, 2) * (weight / totalWeight);
2386
2965
  }
2387
- return variance;
2966
+ return round(variance, 4);
2388
2967
  }
2389
2968
  function getLessBetHitrate(payoutWeights, cost) {
2390
2969
  let lessBetWeight = 0;
@@ -2395,80 +2974,29 @@ function getLessBetHitrate(payoutWeights, cost) {
2395
2974
  lessBetWeight += weight;
2396
2975
  }
2397
2976
  }
2398
- return lessBetWeight / totalWeight;
2977
+ return round(lessBetWeight / totalWeight, 4);
2399
2978
  }
2400
2979
 
2401
2980
  // src/analysis/index.ts
2402
- import { isMainThread as isMainThread2 } from "worker_threads";
2981
+ import { isMainThread as isMainThread3 } from "worker_threads";
2403
2982
  var Analysis = class {
2404
- gameConfig;
2405
- optimizerConfig;
2406
- filePaths;
2407
- constructor(optimizer) {
2408
- this.gameConfig = optimizer.getGameConfig();
2409
- this.optimizerConfig = optimizer.getOptimizerGameModes();
2410
- this.filePaths = {};
2983
+ game;
2984
+ constructor(game) {
2985
+ this.game = game;
2411
2986
  }
2412
2987
  async runAnalysis(gameModes) {
2413
- if (!isMainThread2) return;
2414
- this.filePaths = this.getPathsForModes(gameModes);
2988
+ if (!isMainThread3) return;
2415
2989
  this.getNumberStats(gameModes);
2416
2990
  this.getWinRanges(gameModes);
2417
2991
  console.log("Analysis complete. Files written to build directory.");
2418
2992
  }
2419
- getPathsForModes(gameModes) {
2420
- const rootPath = this.gameConfig.rootDir;
2421
- const paths = {};
2422
- for (const modeStr of gameModes) {
2423
- const lut = path2.join(
2424
- rootPath,
2425
- this.gameConfig.outputDir,
2426
- `lookUpTable_${modeStr}.csv`
2427
- );
2428
- const lutSegmented = path2.join(
2429
- rootPath,
2430
- this.gameConfig.outputDir,
2431
- `lookUpTableSegmented_${modeStr}.csv`
2432
- );
2433
- const lutOptimized = path2.join(
2434
- rootPath,
2435
- this.gameConfig.outputDir,
2436
- "publish_files",
2437
- `lookUpTable_${modeStr}_0.csv`
2438
- );
2439
- const booksJsonl = path2.join(
2440
- rootPath,
2441
- this.gameConfig.outputDir,
2442
- `books_${modeStr}.jsonl`
2443
- );
2444
- const booksJsonlCompressed = path2.join(
2445
- rootPath,
2446
- this.gameConfig.outputDir,
2447
- "publish_files",
2448
- `books_${modeStr}.jsonl.zst`
2449
- );
2450
- paths[modeStr] = {
2451
- lut,
2452
- lutSegmented,
2453
- lutOptimized,
2454
- booksJsonl,
2455
- booksJsonlCompressed
2456
- };
2457
- for (const p of Object.values(paths[modeStr])) {
2458
- assert8(
2459
- fs3.existsSync(p),
2460
- `File "${p}" does not exist. Run optimization to auto-create it.`
2461
- );
2462
- }
2463
- }
2464
- return paths;
2465
- }
2466
2993
  getNumberStats(gameModes) {
2994
+ const meta = this.game.getMetadata();
2467
2995
  const stats = [];
2468
2996
  for (const modeStr of gameModes) {
2469
2997
  const mode = this.getGameModeConfig(modeStr);
2470
2998
  const lutOptimized = parseLookupTable(
2471
- fs3.readFileSync(this.filePaths[modeStr].lutOptimized, "utf-8")
2999
+ fs4.readFileSync(meta.paths.lookupTablePublish(modeStr), "utf-8")
2472
3000
  );
2473
3001
  const totalWeight = getTotalLutWeight(lutOptimized);
2474
3002
  const payoutWeights = getPayoutWeights(lutOptimized);
@@ -2488,10 +3016,7 @@ var Analysis = class {
2488
3016
  uniquePayouts: getUniquePayouts(payoutWeights)
2489
3017
  });
2490
3018
  }
2491
- writeJsonFile(
2492
- path2.join(this.gameConfig.rootDir, this.gameConfig.outputDir, "stats_summary.json"),
2493
- stats
2494
- );
3019
+ writeJsonFile(meta.paths.statsSummary, stats);
2495
3020
  }
2496
3021
  getWinRanges(gameModes) {
2497
3022
  const winRanges = [
@@ -2514,14 +3039,31 @@ var Analysis = class {
2514
3039
  [7500, 9999.99],
2515
3040
  [1e4, 14999.99],
2516
3041
  [15e3, 19999.99],
2517
- [2e4, 24999.99]
3042
+ [2e4, 24999.99],
3043
+ [25e3, 49999.99],
3044
+ [5e4, 74999.99],
3045
+ [75e3, 99999.99],
3046
+ [1e5, Infinity]
2518
3047
  ];
2519
- const payoutRanges = {};
3048
+ const payoutRanges = [];
3049
+ const meta = this.game.getMetadata();
2520
3050
  for (const modeStr of gameModes) {
2521
- payoutRanges[modeStr] = { overall: {}, criteria: {} };
2522
3051
  const lutSegmented = parseLookupTableSegmented(
2523
- fs3.readFileSync(this.filePaths[modeStr].lutSegmented, "utf-8")
3052
+ fs4.readFileSync(meta.paths.lookupTableSegmented(modeStr), "utf-8")
2524
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();
2525
3067
  lutSegmented.forEach(([, criteria, bp, fsp]) => {
2526
3068
  const basePayout = bp;
2527
3069
  const freeSpinPayout = fsp;
@@ -2529,32 +3071,54 @@ var Analysis = class {
2529
3071
  for (const [min, max] of winRanges) {
2530
3072
  if (payout >= min && payout <= max) {
2531
3073
  const rangeKey = `${min}-${max}`;
2532
- if (!payoutRanges[modeStr].overall[rangeKey]) {
2533
- 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;
2534
3083
  }
2535
- payoutRanges[modeStr].overall[rangeKey] += 1;
2536
- if (!payoutRanges[modeStr].criteria[criteria]) {
2537
- payoutRanges[modeStr].criteria[criteria] = {};
3084
+ range.allPayouts.criteria[criteria][rangeKey] += 1;
3085
+ if (!uniquePayoutsOverall.has(rangeKey)) {
3086
+ uniquePayoutsOverall.set(rangeKey, /* @__PURE__ */ new Set());
2538
3087
  }
2539
- if (!payoutRanges[modeStr].criteria[criteria][rangeKey]) {
2540
- payoutRanges[modeStr].criteria[criteria][rangeKey] = 0;
3088
+ uniquePayoutsOverall.get(rangeKey).add(payout);
3089
+ if (!uniquePayoutsCriteria.has(criteria)) {
3090
+ uniquePayoutsCriteria.set(criteria, /* @__PURE__ */ new Map());
2541
3091
  }
2542
- 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);
2543
3096
  break;
2544
3097
  }
2545
3098
  }
2546
3099
  });
2547
- const orderedOverall = {};
2548
- 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) => {
2549
3113
  const [aMin] = a.split("-").map(Number);
2550
3114
  const [bMin] = b.split("-").map(Number);
2551
3115
  return aMin - bMin;
2552
3116
  }).forEach((key) => {
2553
- orderedOverall[key] = payoutRanges[modeStr].overall[key];
3117
+ orderedAllOverall[key] = range.allPayouts.overall[key];
2554
3118
  });
2555
- const orderedCriteria = {};
2556
- Object.keys(payoutRanges[modeStr].criteria).forEach((crit) => {
2557
- const critMap = payoutRanges[modeStr].criteria[crit];
3119
+ const orderedAllCriteria = {};
3120
+ Object.keys(range.allPayouts.criteria).forEach((crit) => {
3121
+ const critMap = range.allPayouts.criteria[crit];
2558
3122
  const orderedCritMap = {};
2559
3123
  Object.keys(critMap).sort((a, b) => {
2560
3124
  const [aMin] = a.split("-").map(Number);
@@ -2563,35 +3127,61 @@ var Analysis = class {
2563
3127
  }).forEach((key) => {
2564
3128
  orderedCritMap[key] = critMap[key];
2565
3129
  });
2566
- 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
+ }
2567
3163
  });
2568
- payoutRanges[modeStr] = {
2569
- overall: orderedOverall,
2570
- criteria: {}
2571
- };
2572
3164
  }
2573
- writeJsonFile(
2574
- path2.join(this.gameConfig.rootDir, this.gameConfig.outputDir, "stats_payouts.json"),
2575
- payoutRanges
2576
- );
3165
+ writeJsonFile(meta.paths.statsPayouts, payoutRanges);
2577
3166
  }
2578
3167
  getGameModeConfig(mode) {
2579
- const config = this.gameConfig.gameModes[mode];
2580
- 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`);
2581
3170
  return config;
2582
3171
  }
2583
3172
  };
2584
3173
 
2585
3174
  // src/optimizer/index.ts
2586
- import path5 from "path";
2587
- import assert10 from "assert";
3175
+ import path6 from "path";
3176
+ import assert8 from "assert";
2588
3177
  import { spawn } from "child_process";
2589
- import { isMainThread as isMainThread3 } from "worker_threads";
3178
+ import { isMainThread as isMainThread4 } from "worker_threads";
2590
3179
 
2591
3180
  // src/utils/math-config.ts
2592
- import path3 from "path";
3181
+ import path4 from "path";
2593
3182
  function makeMathConfig(optimizer, opts = {}) {
2594
3183
  const game = optimizer.getGameConfig();
3184
+ const meta = optimizer.getGameMeta();
2595
3185
  const gameModesCfg = optimizer.getOptimizerGameModes();
2596
3186
  const { writeToFile } = opts;
2597
3187
  const isDefined = (v) => v !== void 0;
@@ -2633,16 +3223,17 @@ function makeMathConfig(optimizer, opts = {}) {
2633
3223
  }))
2634
3224
  };
2635
3225
  if (writeToFile) {
2636
- const outPath = path3.join(game.rootDir, game.outputDir, "math_config.json");
3226
+ const outPath = path4.join(meta.rootDir, meta.outputDir, "math_config.json");
2637
3227
  writeJsonFile(outPath, config);
2638
3228
  }
2639
3229
  return config;
2640
3230
  }
2641
3231
 
2642
3232
  // src/utils/setup-file.ts
2643
- import path4 from "path";
3233
+ import path5 from "path";
2644
3234
  function makeSetupFile(optimizer, gameMode) {
2645
3235
  const gameConfig = optimizer.getGameConfig();
3236
+ const gameMeta = optimizer.getGameMeta();
2646
3237
  const optimizerGameModes = optimizer.getOptimizerGameModes();
2647
3238
  const modeConfig = optimizerGameModes[gameMode];
2648
3239
  if (!modeConfig) {
@@ -2676,16 +3267,16 @@ function makeSetupFile(optimizer, gameMode) {
2676
3267
  `;
2677
3268
  content += `simulation_trials;${params.simulationTrials}
2678
3269
  `;
2679
- content += `user_game_build_path;${path4.join(gameConfig.rootDir, gameConfig.outputDir)}
3270
+ content += `user_game_build_path;${path5.join(gameMeta.rootDir, gameMeta.outputDir)}
2680
3271
  `;
2681
3272
  content += `pmb_rtp;${params.pmbRtp}
2682
3273
  `;
2683
- const outPath = path4.join(__dirname, "./optimizer-rust/src", "setup.txt");
3274
+ const outPath = path5.join(__dirname, "./optimizer-rust/src", "setup.txt");
2684
3275
  writeFile(outPath, content);
2685
3276
  }
2686
3277
 
2687
3278
  // src/optimizer/OptimizationConditions.ts
2688
- import assert9 from "assert";
3279
+ import assert7 from "assert";
2689
3280
  var OptimizationConditions = class {
2690
3281
  rtp;
2691
3282
  avgWin;
@@ -2696,14 +3287,14 @@ var OptimizationConditions = class {
2696
3287
  constructor(opts) {
2697
3288
  let { rtp, avgWin, hitRate, searchConditions, priority } = opts;
2698
3289
  if (rtp == void 0 || rtp === "x") {
2699
- assert9(avgWin !== void 0 && hitRate !== void 0, "If RTP is not specified, hit-rate (hr) and average win amount (av_win) must be given.");
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.");
2700
3291
  rtp = Math.round(avgWin / Number(hitRate) * 1e5) / 1e5;
2701
3292
  }
2702
3293
  let noneCount = 0;
2703
3294
  for (const val of [rtp, avgWin, hitRate]) {
2704
3295
  if (val === void 0) noneCount++;
2705
3296
  }
2706
- assert9(noneCount <= 1, "Invalid combination of optimization conditions.");
3297
+ assert7(noneCount <= 1, "Invalid combination of optimization conditions.");
2707
3298
  this.searchRange = [-1, -1];
2708
3299
  this.forceSearch = {};
2709
3300
  if (typeof searchConditions === "number") {
@@ -2784,9 +3375,11 @@ var OptimizationParameters = class _OptimizationParameters {
2784
3375
  // src/optimizer/index.ts
2785
3376
  var Optimizer = class {
2786
3377
  gameConfig;
3378
+ gameMeta;
2787
3379
  gameModes;
2788
3380
  constructor(opts) {
2789
3381
  this.gameConfig = opts.game.getConfig();
3382
+ this.gameMeta = opts.game.getMetadata();
2790
3383
  this.gameModes = opts.gameModes;
2791
3384
  this.verifyConfig();
2792
3385
  }
@@ -2794,11 +3387,15 @@ var Optimizer = class {
2794
3387
  * Runs the optimization process, and runs analysis after.
2795
3388
  */
2796
3389
  async runOptimization({ gameModes }) {
2797
- if (!isMainThread3) return;
3390
+ if (!isMainThread4) return;
2798
3391
  const mathConfig = makeMathConfig(this, { writeToFile: true });
2799
3392
  for (const mode of gameModes) {
2800
3393
  const setupFile = makeSetupFile(this, mode);
2801
3394
  await this.runSingleOptimization();
3395
+ await makeLutIndexFromPublishLut(
3396
+ this.gameMeta.paths.lookupTablePublish(mode),
3397
+ this.gameMeta.paths.lookupTableIndex(mode)
3398
+ );
2802
3399
  }
2803
3400
  console.log("Optimization complete. Files written to build directory.");
2804
3401
  }
@@ -2831,7 +3428,7 @@ var Optimizer = class {
2831
3428
  }
2832
3429
  gameModeRtp = Math.round(gameModeRtp * 1e3) / 1e3;
2833
3430
  paramRtp = Math.round(paramRtp * 1e3) / 1e3;
2834
- assert10(
3431
+ assert8(
2835
3432
  gameModeRtp === paramRtp,
2836
3433
  `Sum of all RTP conditions (${paramRtp}) does not match the game mode RTP (${gameModeRtp}) in game mode "${k}".`
2837
3434
  );
@@ -2840,6 +3437,9 @@ var Optimizer = class {
2840
3437
  getGameConfig() {
2841
3438
  return this.gameConfig;
2842
3439
  }
3440
+ getGameMeta() {
3441
+ return this.gameMeta;
3442
+ }
2843
3443
  getOptimizerGameModes() {
2844
3444
  return this.gameModes;
2845
3445
  }
@@ -2848,7 +3448,7 @@ async function rustProgram(...args) {
2848
3448
  console.log("Starting Rust optimizer. This may take a while...");
2849
3449
  return new Promise((resolve, reject) => {
2850
3450
  const task = spawn("cargo", ["run", "-q", "--release", ...args], {
2851
- cwd: path5.join(__dirname, "./optimizer-rust"),
3451
+ cwd: path6.join(__dirname, "./optimizer-rust"),
2852
3452
  stdio: "pipe"
2853
3453
  });
2854
3454
  task.on("error", (error) => {
@@ -2875,8 +3475,8 @@ async function rustProgram(...args) {
2875
3475
  }
2876
3476
 
2877
3477
  // src/slot-game/index.ts
2878
- import { isMainThread as isMainThread4 } from "worker_threads";
2879
- var SlotGame = class {
3478
+ import { isMainThread as isMainThread5, workerData as workerData2 } from "worker_threads";
3479
+ var SlotGame = class _SlotGame {
2880
3480
  configOpts;
2881
3481
  simulation;
2882
3482
  optimizer;
@@ -2926,38 +3526,56 @@ var SlotGame = class {
2926
3526
  /**
2927
3527
  * Runs the analysis based on the configured settings.
2928
3528
  */
2929
- async runAnalysis(opts) {
2930
- if (!this.optimizer) {
2931
- throw new Error(
2932
- "Optimization must be configured to run analysis. Do so by calling configureOptimization() first."
2933
- );
2934
- }
2935
- this.analyzer = new Analysis(this.optimizer);
2936
- await this.analyzer.runAnalysis(opts.gameModes);
3529
+ runAnalysis(opts) {
3530
+ this.analyzer = new Analysis(this);
3531
+ this.analyzer.runAnalysis(opts.gameModes);
2937
3532
  }
2938
3533
  /**
2939
3534
  * Runs the configured tasks: simulation, optimization, and/or analysis.
2940
3535
  */
2941
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
+ }
2942
3551
  if (!opts.doSimulation && !opts.doOptimization && !opts.doAnalysis) {
2943
3552
  console.log("No tasks to run. Enable either simulation, optimization or analysis.");
2944
3553
  }
2945
3554
  if (opts.doSimulation) {
2946
3555
  await this.runSimulation(opts.simulationOpts || {});
2947
3556
  }
3557
+ if (opts.doAnalysis) {
3558
+ this.runAnalysis(opts.analysisOpts || { gameModes: [] });
3559
+ }
2948
3560
  if (opts.doOptimization) {
2949
3561
  await this.runOptimization(opts.optimizationOpts || { gameModes: [] });
3562
+ if (opts.doAnalysis) {
3563
+ this.runAnalysis(opts.analysisOpts || { gameModes: [] });
3564
+ }
2950
3565
  }
2951
- if (opts.doAnalysis) {
2952
- await this.runAnalysis(opts.analysisOpts || { gameModes: [] });
2953
- }
2954
- if (isMainThread4) console.log("Finishing up...");
3566
+ if (isMainThread5) console.log("Done!");
2955
3567
  }
2956
3568
  /**
2957
3569
  * Gets the game configuration.
2958
3570
  */
2959
3571
  getConfig() {
2960
- 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);
2961
3579
  }
2962
3580
  };
2963
3581
 
@@ -2970,7 +3588,7 @@ var defineSymbols = (symbols) => symbols;
2970
3588
  var defineGameModes = (gameModes) => gameModes;
2971
3589
 
2972
3590
  // src/game-mode/index.ts
2973
- import assert11 from "assert";
3591
+ import assert9 from "assert";
2974
3592
  var GameMode = class {
2975
3593
  name;
2976
3594
  _reelsAmount;
@@ -2993,12 +3611,12 @@ var GameMode = class {
2993
3611
  this.reelSets = opts.reelSets;
2994
3612
  this.resultSets = opts.resultSets;
2995
3613
  this.isBonusBuy = opts.isBonusBuy;
2996
- assert11(this.rtp >= 0.9 && this.rtp <= 0.99, "RTP must be between 0.9 and 0.99");
2997
- assert11(
3614
+ assert9(this.rtp >= 0.9 && this.rtp <= 0.99, "RTP must be between 0.9 and 0.99");
3615
+ assert9(
2998
3616
  this.symbolsPerReel.length === this.reelsAmount,
2999
3617
  "symbolsPerReel length must match reelsAmount."
3000
3618
  );
3001
- 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.");
3002
3620
  }
3003
3621
  /**
3004
3622
  * Intended for internal use only.
@@ -3011,7 +3629,7 @@ var GameMode = class {
3011
3629
  * Intended for internal use only.
3012
3630
  */
3013
3631
  _setSymbolsPerReel(symbolsPerReel) {
3014
- assert11(
3632
+ assert9(
3015
3633
  symbolsPerReel.length === this._reelsAmount,
3016
3634
  "symbolsPerReel length must match reelsAmount."
3017
3635
  );
@@ -3077,7 +3695,7 @@ var WinType = class {
3077
3695
  };
3078
3696
 
3079
3697
  // src/win-types/LinesWinType.ts
3080
- import assert12 from "assert";
3698
+ import assert10 from "assert";
3081
3699
  var LinesWinType = class extends WinType {
3082
3700
  lines;
3083
3701
  constructor(opts) {
@@ -3131,8 +3749,8 @@ var LinesWinType = class extends WinType {
3131
3749
  if (!baseSymbol) {
3132
3750
  baseSymbol = thisSymbol;
3133
3751
  }
3134
- assert12(baseSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3135
- 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}`);
3136
3754
  if (potentialWinLine.length == 0) {
3137
3755
  if (this.isWild(thisSymbol)) {
3138
3756
  potentialWildLine.push({ reel: ridx, row: sidx, symbol: thisSymbol });
@@ -3447,13 +4065,13 @@ var ManywaysWinType = class extends WinType {
3447
4065
  };
3448
4066
 
3449
4067
  // src/reel-set/GeneratedReelSet.ts
3450
- import fs5 from "fs";
3451
- import path7 from "path";
3452
- 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";
3453
4071
 
3454
4072
  // src/reel-set/index.ts
3455
- import fs4 from "fs";
3456
- import path6 from "path";
4073
+ import fs5 from "fs";
4074
+ import path7 from "path";
3457
4075
  var ReelSet = class {
3458
4076
  id;
3459
4077
  associatedGameModeName;
@@ -3473,11 +4091,11 @@ var ReelSet = class {
3473
4091
  * Reads a reelset CSV file and returns the reels as arrays of GameSymbols.
3474
4092
  */
3475
4093
  parseReelsetCSV(reelSetPath, config) {
3476
- if (!fs4.existsSync(reelSetPath)) {
4094
+ if (!fs5.existsSync(reelSetPath)) {
3477
4095
  throw new Error(`Reelset CSV file not found at path: ${reelSetPath}`);
3478
4096
  }
3479
4097
  const allowedExtensions = [".csv"];
3480
- const ext = path6.extname(reelSetPath).toLowerCase();
4098
+ const ext = path7.extname(reelSetPath).toLowerCase();
3481
4099
  if (!allowedExtensions.includes(ext)) {
3482
4100
  throw new Error(
3483
4101
  `Invalid file extension for reelset CSV: ${ext}. Allowed extensions are: ${allowedExtensions.join(
@@ -3485,7 +4103,7 @@ var ReelSet = class {
3485
4103
  )}`
3486
4104
  );
3487
4105
  }
3488
- const csvData = fs4.readFileSync(reelSetPath, "utf8");
4106
+ const csvData = fs5.readFileSync(reelSetPath, "utf8");
3489
4107
  const rows = csvData.split("\n").filter((line) => line.trim() !== "");
3490
4108
  const reels = Array.from(
3491
4109
  { length: config.gameModes[this.associatedGameModeName].reelsAmount },
@@ -3493,6 +4111,7 @@ var ReelSet = class {
3493
4111
  );
3494
4112
  rows.forEach((row) => {
3495
4113
  const symsInRow = row.split(",").map((symbolId) => {
4114
+ if (!symbolId.trim()) return null;
3496
4115
  const symbol = config.symbols.get(symbolId.trim());
3497
4116
  if (!symbol) {
3498
4117
  throw new Error(`Symbol with id "${symbolId}" not found in game config.`);
@@ -3505,18 +4124,9 @@ var ReelSet = class {
3505
4124
  `Row in reelset CSV has more symbols than expected reels amount (${reels.length})`
3506
4125
  );
3507
4126
  }
3508
- reels[ridx].push(symbol);
4127
+ if (symbol) reels[ridx].push(symbol);
3509
4128
  });
3510
4129
  });
3511
- const reelLengths = reels.map((r) => r.length);
3512
- const uniqueLengths = new Set(reelLengths);
3513
- if (uniqueLengths.size > 1) {
3514
- throw new Error(
3515
- `Inconsistent reel lengths in reelset CSV at ${reelSetPath}: ${[
3516
- ...uniqueLengths
3517
- ].join(", ")}`
3518
- );
3519
- }
3520
4130
  return reels;
3521
4131
  }
3522
4132
  };
@@ -3646,12 +4256,12 @@ var GeneratedReelSet = class extends ReelSet {
3646
4256
  `Error generating reels for game mode "${this.associatedGameModeName}". It's not defined in the game config.`
3647
4257
  );
3648
4258
  }
3649
- const outputDir = config.rootDir.endsWith(config.outputDir) ? config.rootDir : path7.join(config.rootDir, config.outputDir);
3650
- 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(
3651
4261
  outputDir,
3652
4262
  `reels_${this.associatedGameModeName}-${this.id}.csv`
3653
4263
  );
3654
- const exists = fs5.existsSync(filePath);
4264
+ const exists = fs6.existsSync(filePath);
3655
4265
  if (exists && !this.overrideExisting) {
3656
4266
  this.reels = this.parseReelsetCSV(filePath, config);
3657
4267
  return this;
@@ -3788,9 +4398,9 @@ var GeneratedReelSet = class extends ReelSet {
3788
4398
  }
3789
4399
  }
3790
4400
  const csvString = csvRows.map((row) => row.join(",")).join("\n");
3791
- if (isMainThread5) {
4401
+ if (isMainThread6) {
3792
4402
  createDirIfNotExists(outputDir);
3793
- fs5.writeFileSync(filePath, csvString);
4403
+ fs6.writeFileSync(filePath, csvString);
3794
4404
  console.log(
3795
4405
  `Generated reelset ${this.id} for game mode ${this.associatedGameModeName}`
3796
4406
  );
@@ -3801,7 +4411,7 @@ var GeneratedReelSet = class extends ReelSet {
3801
4411
  };
3802
4412
 
3803
4413
  // src/reel-set/StaticReelSet.ts
3804
- import assert13 from "assert";
4414
+ import assert11 from "assert";
3805
4415
  var StaticReelSet = class extends ReelSet {
3806
4416
  reels;
3807
4417
  csvPath;
@@ -3811,7 +4421,7 @@ var StaticReelSet = class extends ReelSet {
3811
4421
  this.reels = [];
3812
4422
  this._strReels = opts.reels || [];
3813
4423
  this.csvPath = opts.csvPath || "";
3814
- assert13(
4424
+ assert11(
3815
4425
  opts.reels || opts.csvPath,
3816
4426
  `Either 'reels' or 'csvPath' must be provided for StaticReelSet ${this.id}`
3817
4427
  );
@@ -4064,6 +4674,7 @@ export {
4064
4674
  OptimizationConditions,
4065
4675
  OptimizationParameters,
4066
4676
  OptimizationScaling,
4677
+ RandomNumberGenerator,
4067
4678
  ResultSet,
4068
4679
  SPIN_TYPE,
4069
4680
  StandaloneBoard,
@@ -4071,6 +4682,8 @@ export {
4071
4682
  createSlotGame,
4072
4683
  defineGameModes,
4073
4684
  defineSymbols,
4074
- defineUserState
4685
+ defineUserState,
4686
+ parseLookupTable,
4687
+ parseLookupTableSegmented
4075
4688
  };
4076
4689
  //# sourceMappingURL=index.mjs.map