@slot-engine/core 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -84,7 +84,7 @@ function createGameConfig(opts) {
84
84
  // src/simulation/index.ts
85
85
  var import_fs2 = __toESM(require("fs"));
86
86
  var import_path = __toESM(require("path"));
87
- var import_assert6 = __toESM(require("assert"));
87
+ var import_assert7 = __toESM(require("assert"));
88
88
  var import_zlib = __toESM(require("zlib"));
89
89
  var import_readline2 = __toESM(require("readline"));
90
90
  var import_esbuild = require("esbuild");
@@ -310,6 +310,9 @@ var JSONL = class {
310
310
  });
311
311
  }
312
312
  };
313
+ function round(value, decimals) {
314
+ return Number(Math.round(Number(value + "e" + decimals)) + "e-" + decimals);
315
+ }
313
316
 
314
317
  // src/result-set/index.ts
315
318
  var ResultSet = class {
@@ -331,7 +334,7 @@ var ResultSet = class {
331
334
  this.forceFreespins = opts.forceFreespins;
332
335
  this.evaluate = opts.evaluate;
333
336
  }
334
- static assignCriteriaToSimulations(ctx, gameModeName) {
337
+ static getNumberOfSimsForCriteria(ctx, gameModeName) {
335
338
  const rng = new RandomNumberGenerator();
336
339
  rng.setSeed(0);
337
340
  (0, import_assert2.default)(ctx.simRunsAmount, "Simulation configuration is not set.");
@@ -358,7 +361,7 @@ var ResultSet = class {
358
361
  const criteriaToWeights = Object.fromEntries(
359
362
  resultSets.map((rs) => [rs.criteria, rs.quota])
360
363
  );
361
- while (totalSims != simNums) {
364
+ while (totalSims !== simNums) {
362
365
  const rs = rng.weightedRandom(criteriaToWeights);
363
366
  if (reduceSims && numberOfSimsForCriteria[rs] > 1) {
364
367
  numberOfSimsForCriteria[rs] -= 1;
@@ -371,18 +374,7 @@ var ResultSet = class {
371
374
  );
372
375
  reduceSims = totalSims > simNums;
373
376
  }
374
- let allCriteria = [];
375
- const simNumsToCriteria = {};
376
- Object.entries(numberOfSimsForCriteria).forEach(([criteria, num]) => {
377
- for (let i = 0; i <= num; i++) {
378
- allCriteria.push(criteria);
379
- }
380
- });
381
- allCriteria = rng.shuffle(allCriteria);
382
- for (let i = 1; i <= Math.min(simNums, allCriteria.length); i++) {
383
- simNumsToCriteria[i] = allCriteria[i];
384
- }
385
- return simNumsToCriteria;
377
+ return numberOfSimsForCriteria;
386
378
  }
387
379
  /**
388
380
  * Checks if core criteria is met, e.g. target multiplier or max win.
@@ -1339,7 +1331,7 @@ function createGameContext(opts) {
1339
1331
  }
1340
1332
 
1341
1333
  // src/book/index.ts
1342
- var Book = class _Book {
1334
+ var Book = class {
1343
1335
  id;
1344
1336
  criteria = "N/A";
1345
1337
  events = [];
@@ -1361,7 +1353,11 @@ var Book = class _Book {
1361
1353
  */
1362
1354
  addEvent(event) {
1363
1355
  const index = this.events.length + 1;
1364
- this.events.push({ index, ...event });
1356
+ this.events.push({
1357
+ index,
1358
+ type: event.type,
1359
+ data: copy(event.data)
1360
+ });
1365
1361
  }
1366
1362
  /**
1367
1363
  * Intended for internal use only.
@@ -1376,17 +1372,6 @@ var Book = class _Book {
1376
1372
  freespinsWins: this.freespinsWins
1377
1373
  };
1378
1374
  }
1379
- /**
1380
- * Intended for internal use only.
1381
- */
1382
- static fromSerialized(data) {
1383
- const book = new _Book({ id: data.id, criteria: data.criteria });
1384
- book.events = data.events;
1385
- book.payout = data.payout;
1386
- book.basegameWins = data.basegameWins;
1387
- book.freespinsWins = data.freespinsWins;
1388
- return book;
1389
- }
1390
1375
  };
1391
1376
 
1392
1377
  // src/wallet/index.ts
@@ -1612,6 +1597,107 @@ var Wallet = class {
1612
1597
 
1613
1598
  // src/simulation/index.ts
1614
1599
  var import_promises = require("stream/promises");
1600
+
1601
+ // src/simulation/utils.ts
1602
+ var import_assert6 = __toESM(require("assert"));
1603
+ function hashStringToInt(input) {
1604
+ let h = 2166136261;
1605
+ for (let i = 0; i < input.length; i++) {
1606
+ h ^= input.charCodeAt(i);
1607
+ h = Math.imul(h, 16777619);
1608
+ }
1609
+ return h >>> 0;
1610
+ }
1611
+ function splitCountsAcrossChunks(totalCounts, chunkSizes) {
1612
+ const total = chunkSizes.reduce((a, b) => a + b, 0);
1613
+ const allCriteria = Object.keys(totalCounts);
1614
+ const totalCountsSum = allCriteria.reduce((s, c) => s + (totalCounts[c] ?? 0), 0);
1615
+ (0, import_assert6.default)(
1616
+ totalCountsSum === total,
1617
+ `Counts (${totalCountsSum}) must match chunk total (${total}).`
1618
+ );
1619
+ const perChunk = chunkSizes.map(() => ({}));
1620
+ for (const criteria of allCriteria) {
1621
+ const count = totalCounts[criteria] ?? 0;
1622
+ if (count <= 0) {
1623
+ for (let i = 0; i < chunkSizes.length; i++) perChunk[i][criteria] = 0;
1624
+ continue;
1625
+ }
1626
+ let chunks = chunkSizes.map((size) => count * size / total);
1627
+ chunks = chunks.map((x) => Math.floor(x));
1628
+ let assigned = chunks.reduce((a, b) => a + b, 0);
1629
+ let remaining = count - assigned;
1630
+ const remainders = chunks.map((x, i) => ({ i, r: x - Math.floor(x) })).sort((a, b) => b.r - a.r);
1631
+ for (let i = 0; i < chunkSizes.length; i++) {
1632
+ perChunk[i][criteria] = chunks[i];
1633
+ }
1634
+ let idx = 0;
1635
+ while (remaining > 0) {
1636
+ perChunk[remainders[idx].i][criteria] += 1;
1637
+ remaining--;
1638
+ idx = (idx + 1) % remainders.length;
1639
+ }
1640
+ }
1641
+ const chunkTotals = () => perChunk.map((m) => Object.values(m).reduce((s, v) => s + v, 0));
1642
+ let totals = chunkTotals();
1643
+ const getDeficits = () => totals.map((t, i) => chunkSizes[i] - t);
1644
+ let deficits = getDeficits();
1645
+ for (let target = 0; target < chunkSizes.length; target++) {
1646
+ while (deficits[target] > 0) {
1647
+ const src = deficits.findIndex((d) => d < 0);
1648
+ (0, import_assert6.default)(src !== -1, "No surplus chunk found, but deficits remain.");
1649
+ const crit = allCriteria.find((c) => (perChunk[src][c] ?? 0) > 0);
1650
+ (0, import_assert6.default)(crit, `No movable criteria found from surplus chunk ${src}.`);
1651
+ perChunk[src][crit] -= 1;
1652
+ perChunk[target][crit] = (perChunk[target][crit] ?? 0) + 1;
1653
+ totals[src] -= 1;
1654
+ totals[target] += 1;
1655
+ deficits[src] += 1;
1656
+ deficits[target] -= 1;
1657
+ }
1658
+ }
1659
+ totals = chunkTotals();
1660
+ for (let i = 0; i < chunkSizes.length; i++) {
1661
+ (0, import_assert6.default)(
1662
+ totals[i] === chunkSizes[i],
1663
+ `Chunk ${i} size mismatch. Expected ${chunkSizes[i]}, got ${totals[i]}`
1664
+ );
1665
+ }
1666
+ for (const c of allCriteria) {
1667
+ const sum = perChunk.reduce((s, m) => s + (m[c] ?? 0), 0);
1668
+ (0, import_assert6.default)(sum === (totalCounts[c] ?? 0), `Chunk split mismatch for criteria "${c}"`);
1669
+ }
1670
+ return perChunk;
1671
+ }
1672
+ function createCriteriaSampler(counts, seed) {
1673
+ const rng = new RandomNumberGenerator();
1674
+ rng.setSeed(seed);
1675
+ const keys = Object.keys(counts).filter((k) => (counts[k] ?? 0) > 0);
1676
+ const remaining = Object.fromEntries(keys.map((k) => [k, counts[k] ?? 0]));
1677
+ let remainingTotal = Object.values(remaining).reduce((a, b) => a + b, 0);
1678
+ return () => {
1679
+ if (remainingTotal <= 0) return "N/A";
1680
+ const roll = Math.min(
1681
+ remainingTotal - Number.EPSILON,
1682
+ rng.randomFloat(0, remainingTotal)
1683
+ );
1684
+ let acc = 0;
1685
+ for (const k of keys) {
1686
+ const w = remaining[k] ?? 0;
1687
+ if (w <= 0) continue;
1688
+ acc += w;
1689
+ if (roll < acc) {
1690
+ remaining[k] = w - 1;
1691
+ remainingTotal--;
1692
+ return k;
1693
+ }
1694
+ }
1695
+ remainingTotal--;
1696
+ return keys.find((k) => (remaining[k] ?? 0) > 0) ?? "N/A";
1697
+ };
1698
+ }
1699
+
1700
+ // src/simulation/index.ts
1615
1701
  var completedSimulations = 0;
1616
1702
  var TEMP_FILENAME = "__temp_compiled_src_IGNORE.js";
1617
1703
  var TEMP_FOLDER = "temp_files";
@@ -1622,25 +1708,46 @@ var Simulation = class {
1622
1708
  concurrency;
1623
1709
  debug = false;
1624
1710
  actualSims = 0;
1625
- library;
1626
1711
  wallet;
1627
1712
  recordsWriteStream;
1628
1713
  hasWrittenRecord = false;
1714
+ maxPendingSims;
1715
+ maxHighWaterMark;
1716
+ PATHS = {};
1717
+ // Worker related
1718
+ credits = 0;
1719
+ creditWaiters = [];
1720
+ creditListenerInit = false;
1629
1721
  constructor(opts, gameConfigOpts) {
1630
1722
  this.gameConfig = createGameConfig(gameConfigOpts);
1631
1723
  this.gameConfigOpts = gameConfigOpts;
1632
1724
  this.simRunsAmount = opts.simRunsAmount || {};
1633
1725
  this.concurrency = (opts.concurrency || 6) >= 2 ? opts.concurrency || 6 : 2;
1634
- this.library = /* @__PURE__ */ new Map();
1635
1726
  this.wallet = new Wallet();
1727
+ this.maxPendingSims = Math.max(10, opts.maxPendingSims ?? 250);
1728
+ this.maxHighWaterMark = (opts.maxDiskBuffer ?? 50) * 1024 * 1024;
1636
1729
  const gameModeKeys = Object.keys(this.gameConfig.gameModes);
1637
- (0, import_assert6.default)(
1730
+ (0, import_assert7.default)(
1638
1731
  Object.values(this.gameConfig.gameModes).map((m) => gameModeKeys.includes(m.name)).every((v) => v === true),
1639
1732
  "Game mode name must match its key in the gameModes object."
1640
1733
  );
1641
- if (import_worker_threads.isMainThread) {
1642
- this.preprocessFiles();
1643
- }
1734
+ this.PATHS.base = import_path.default.join(this.gameConfig.rootDir, this.gameConfig.outputDir);
1735
+ this.PATHS = {
1736
+ ...this.PATHS,
1737
+ books: (mode) => import_path.default.join(this.PATHS.base, `books_${mode}.jsonl`),
1738
+ booksCompressed: (mode) => import_path.default.join(this.PATHS.base, "publish_files", `books_${mode}.jsonl.zst`),
1739
+ tempBooks: (mode, i) => import_path.default.join(this.PATHS.base, TEMP_FOLDER, `temp_books_${mode}_${i}.jsonl`),
1740
+ lookupTable: (mode) => import_path.default.join(this.PATHS.base, `lookUpTable_${mode}.csv`),
1741
+ tempLookupTable: (mode, i) => import_path.default.join(this.PATHS.base, TEMP_FOLDER, `temp_lookup_${mode}_${i}.csv`),
1742
+ lookupTableSegmented: (mode) => import_path.default.join(this.PATHS.base, `lookUpTableSegmented_${mode}.csv`),
1743
+ tempLookupTableSegmented: (mode, i) => import_path.default.join(this.PATHS.base, TEMP_FOLDER, `temp_lookup_segmented_${mode}_${i}.csv`),
1744
+ lookupTablePublish: (mode) => import_path.default.join(this.PATHS.base, "publish_files", `lookUpTable_${mode}_0.csv`),
1745
+ tempRecords: (mode) => import_path.default.join(this.PATHS.base, TEMP_FOLDER, `temp_records_${mode}.jsonl`),
1746
+ forceRecords: (mode) => import_path.default.join(this.PATHS.base, `force_record_${mode}.json`),
1747
+ indexJson: import_path.default.join(this.PATHS.base, "publish_files", "index.json"),
1748
+ optimizationFiles: import_path.default.join(this.PATHS.base, "optimization_files"),
1749
+ publishFiles: import_path.default.join(this.PATHS.base, "publish_files")
1750
+ };
1644
1751
  }
1645
1752
  async runSimulation(opts) {
1646
1753
  const debug = opts.debug || false;
@@ -1652,11 +1759,11 @@ var Simulation = class {
1652
1759
  }
1653
1760
  this.generateReelsetFiles();
1654
1761
  if (import_worker_threads.isMainThread) {
1762
+ this.preprocessFiles();
1655
1763
  const debugDetails = {};
1656
1764
  for (const mode of gameModesToSimulate) {
1657
1765
  completedSimulations = 0;
1658
1766
  this.wallet = new Wallet();
1659
- this.library = /* @__PURE__ */ new Map();
1660
1767
  this.hasWrittenRecord = false;
1661
1768
  debugDetails[mode] = {};
1662
1769
  console.log(`
@@ -1669,37 +1776,37 @@ Simulating game mode: ${mode}`);
1669
1776
  `Tried to simulate game mode "${mode}", but it's not configured in the game config.`
1670
1777
  );
1671
1778
  }
1672
- const booksPath = import_path.default.join(
1673
- this.gameConfig.rootDir,
1674
- this.gameConfig.outputDir,
1675
- `books_${mode}.jsonl`
1676
- );
1677
- const tempRecordsPath = import_path.default.join(
1678
- this.gameConfig.rootDir,
1679
- this.gameConfig.outputDir,
1680
- TEMP_FOLDER,
1681
- `temp_records_${mode}.jsonl`
1682
- );
1683
- createDirIfNotExists(
1684
- import_path.default.join(this.gameConfig.rootDir, this.gameConfig.outputDir)
1779
+ const booksPath = this.PATHS.books(mode);
1780
+ const tempRecordsPath = this.PATHS.tempRecords(mode);
1781
+ createDirIfNotExists(this.PATHS.base);
1782
+ createDirIfNotExists(import_path.default.join(this.PATHS.base, TEMP_FOLDER));
1783
+ this.recordsWriteStream = import_fs2.default.createWriteStream(tempRecordsPath, {
1784
+ highWaterMark: this.maxHighWaterMark
1785
+ }).setMaxListeners(30);
1786
+ const criteriaCounts = ResultSet.getNumberOfSimsForCriteria(this, mode);
1787
+ const totalSims = Object.values(criteriaCounts).reduce((a, b) => a + b, 0);
1788
+ (0, import_assert7.default)(
1789
+ totalSims === runs,
1790
+ `Criteria mismatch for mode "${mode}". Expected ${runs}, got ${totalSims}`
1685
1791
  );
1686
- createDirIfNotExists(
1687
- import_path.default.join(this.gameConfig.rootDir, this.gameConfig.outputDir, TEMP_FOLDER)
1792
+ const chunks = this.getSimRangesForChunks(totalSims, this.concurrency);
1793
+ const chunkSizes = chunks.map(([s, e]) => Math.max(0, e - s + 1));
1794
+ const chunkCriteriaCounts = splitCountsAcrossChunks(criteriaCounts, chunkSizes);
1795
+ await this.spawnWorkersForGameMode({
1796
+ mode,
1797
+ chunks,
1798
+ chunkCriteriaCounts,
1799
+ totalSims
1800
+ });
1801
+ createDirIfNotExists(this.PATHS.optimizationFiles);
1802
+ createDirIfNotExists(this.PATHS.publishFiles);
1803
+ console.log(
1804
+ `Writing final files for game mode "${mode}". This may take a while...`
1688
1805
  );
1689
- this.recordsWriteStream = import_fs2.default.createWriteStream(tempRecordsPath);
1690
- const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1691
- await this.spawnWorkersForGameMode({ mode, simNumsToCriteria });
1692
1806
  const finalBookStream = import_fs2.default.createWriteStream(booksPath);
1693
- const numSims = Object.keys(simNumsToCriteria).length;
1694
- const chunks = this.getSimRangesForChunks(numSims, this.concurrency);
1695
1807
  let isFirstChunk = true;
1696
1808
  for (let i = 0; i < chunks.length; i++) {
1697
- const tempBookPath = import_path.default.join(
1698
- this.gameConfig.rootDir,
1699
- this.gameConfig.outputDir,
1700
- TEMP_FOLDER,
1701
- `temp_books_${mode}_${i}.jsonl`
1702
- );
1809
+ const tempBookPath = this.PATHS.tempBooks(mode, i);
1703
1810
  if (import_fs2.default.existsSync(tempBookPath)) {
1704
1811
  if (!isFirstChunk) {
1705
1812
  finalBookStream.write("\n");
@@ -1714,6 +1821,16 @@ Simulating game mode: ${mode}`);
1714
1821
  }
1715
1822
  finalBookStream.end();
1716
1823
  await new Promise((resolve) => finalBookStream.on("finish", resolve));
1824
+ const lutPath = this.PATHS.lookupTable(mode);
1825
+ const lutPathPublish = this.PATHS.lookupTablePublish(mode);
1826
+ const lutSegmentedPath = this.PATHS.lookupTableSegmented(mode);
1827
+ await this.mergeCsv(chunks, lutPath, (i) => `temp_lookup_${mode}_${i}.csv`);
1828
+ import_fs2.default.copyFileSync(lutPath, lutPathPublish);
1829
+ await this.mergeCsv(
1830
+ chunks,
1831
+ lutSegmentedPath,
1832
+ (i) => `temp_lookup_segmented_${mode}_${i}.csv`
1833
+ );
1717
1834
  if (this.recordsWriteStream) {
1718
1835
  await new Promise((resolve) => {
1719
1836
  this.recordsWriteStream.end(() => {
@@ -1722,26 +1839,21 @@ Simulating game mode: ${mode}`);
1722
1839
  });
1723
1840
  this.recordsWriteStream = void 0;
1724
1841
  }
1725
- createDirIfNotExists(
1726
- import_path.default.join(
1727
- this.gameConfig.rootDir,
1728
- this.gameConfig.outputDir,
1729
- "optimization_files"
1730
- )
1731
- );
1732
- createDirIfNotExists(
1733
- import_path.default.join(this.gameConfig.rootDir, this.gameConfig.outputDir, "publish_files")
1734
- );
1735
- console.log(`Writing final files for game mode: ${mode} ...`);
1736
- this.writeLookupTableCSV(mode);
1737
- this.writeLookupTableSegmentedCSV(mode);
1738
- this.writeRecords(mode);
1842
+ await this.writeRecords(mode);
1739
1843
  await this.writeBooksJson(mode);
1740
1844
  this.writeIndexJson();
1741
1845
  console.log(`Mode ${mode} done!`);
1742
- debugDetails[mode].rtp = this.wallet.getCumulativeWins() / (runs * this.gameConfig.gameModes[mode].cost);
1743
- debugDetails[mode].wins = this.wallet.getCumulativeWins();
1744
- debugDetails[mode].winsPerSpinType = this.wallet.getCumulativeWinsPerSpinType();
1846
+ debugDetails[mode].rtp = round(
1847
+ this.wallet.getCumulativeWins() / (runs * this.gameConfig.gameModes[mode].cost),
1848
+ 3
1849
+ );
1850
+ debugDetails[mode].wins = round(this.wallet.getCumulativeWins(), 3);
1851
+ debugDetails[mode].winsPerSpinType = Object.fromEntries(
1852
+ Object.entries(this.wallet.getCumulativeWinsPerSpinType()).map(([k, v]) => [
1853
+ k,
1854
+ round(v, 3)
1855
+ ])
1856
+ );
1745
1857
  console.timeEnd(mode);
1746
1858
  }
1747
1859
  console.log("\n=== SIMULATION SUMMARY ===");
@@ -1751,14 +1863,14 @@ Simulating game mode: ${mode}`);
1751
1863
  let actualSims = 0;
1752
1864
  const criteriaToRetries = {};
1753
1865
  if (!import_worker_threads.isMainThread) {
1754
- const { mode, simStart, simEnd, index } = import_worker_threads.workerData;
1755
- const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1866
+ const { mode, simStart, simEnd, index, criteriaCounts } = import_worker_threads.workerData;
1867
+ const seed = hashStringToInt(mode) + index >>> 0;
1868
+ const nextCriteria = createCriteriaSampler(criteriaCounts, seed);
1756
1869
  for (let simId = simStart; simId <= simEnd; simId++) {
1757
1870
  if (this.debug) desiredSims++;
1758
- const criteria = simNumsToCriteria[simId] || "N/A";
1759
- if (!criteriaToRetries[criteria]) {
1760
- criteriaToRetries[criteria] = 0;
1761
- }
1871
+ const criteria = nextCriteria();
1872
+ if (!criteriaToRetries[criteria]) criteriaToRetries[criteria] = 0;
1873
+ await this.acquireCredit();
1762
1874
  this.runSingleSimulation({ simId, mode, criteria, index });
1763
1875
  if (this.debug) {
1764
1876
  criteriaToRetries[criteria] += this.actualSims - 1;
@@ -1773,30 +1885,31 @@ Simulating game mode: ${mode}`);
1773
1885
  type: "done",
1774
1886
  workerNum: index
1775
1887
  });
1888
+ import_worker_threads.parentPort?.removeAllListeners();
1889
+ import_worker_threads.parentPort?.close();
1776
1890
  }
1777
1891
  }
1778
1892
  /**
1779
1893
  * Runs all simulations for a specific game mode.
1780
1894
  */
1781
1895
  async spawnWorkersForGameMode(opts) {
1782
- const { mode, simNumsToCriteria } = opts;
1783
- const numSims = Object.keys(simNumsToCriteria).length;
1784
- const simRangesPerChunk = this.getSimRangesForChunks(numSims, this.concurrency);
1896
+ const { mode, chunks, chunkCriteriaCounts, totalSims } = opts;
1785
1897
  await Promise.all(
1786
- simRangesPerChunk.map(([simStart, simEnd], index) => {
1898
+ chunks.map(([simStart, simEnd], index) => {
1787
1899
  return this.callWorker({
1788
- basePath: import_path.default.join(this.gameConfig.rootDir, this.gameConfig.outputDir),
1900
+ basePath: this.PATHS.base,
1789
1901
  mode,
1790
1902
  simStart,
1791
1903
  simEnd,
1792
1904
  index,
1793
- totalSims: numSims
1905
+ totalSims,
1906
+ criteriaCounts: chunkCriteriaCounts[index]
1794
1907
  });
1795
1908
  })
1796
1909
  );
1797
1910
  }
1798
1911
  async callWorker(opts) {
1799
- const { mode, simEnd, simStart, basePath, index, totalSims } = opts;
1912
+ const { mode, simEnd, simStart, basePath, index, totalSims, criteriaCounts } = opts;
1800
1913
  function logArrowProgress(current, total) {
1801
1914
  const percentage = current / total * 100;
1802
1915
  const progressBarLength = 50;
@@ -1807,6 +1920,11 @@ Simulating game mode: ${mode}`);
1807
1920
  process.stdout.write("\n");
1808
1921
  }
1809
1922
  }
1923
+ const write = async (stream, chunk) => {
1924
+ if (!stream.write(chunk)) {
1925
+ await new Promise((resolve) => stream.once("drain", resolve));
1926
+ }
1927
+ };
1810
1928
  return new Promise((resolve, reject) => {
1811
1929
  const scriptPath = import_path.default.join(basePath, TEMP_FILENAME);
1812
1930
  const worker = new import_worker_threads.Worker(scriptPath, {
@@ -1814,42 +1932,77 @@ Simulating game mode: ${mode}`);
1814
1932
  mode,
1815
1933
  simStart,
1816
1934
  simEnd,
1817
- index
1935
+ index,
1936
+ criteriaCounts
1818
1937
  }
1819
1938
  });
1820
- const tempBookPath = import_path.default.join(
1821
- basePath,
1822
- TEMP_FOLDER,
1823
- `temp_books_${mode}_${index}.jsonl`
1824
- );
1825
- const bookStream = import_fs2.default.createWriteStream(tempBookPath);
1939
+ worker.postMessage({ type: "credit", amount: this.maxPendingSims });
1940
+ const tempBookPath = this.PATHS.tempBooks(mode, index);
1941
+ const bookStream = import_fs2.default.createWriteStream(tempBookPath, {
1942
+ highWaterMark: this.maxHighWaterMark
1943
+ });
1944
+ const tempLookupPath = this.PATHS.tempLookupTable(mode, index);
1945
+ const lookupStream = import_fs2.default.createWriteStream(tempLookupPath, {
1946
+ highWaterMark: this.maxHighWaterMark
1947
+ });
1948
+ const tempLookupSegPath = this.PATHS.tempLookupTableSegmented(mode, index);
1949
+ const lookupSegmentedStream = import_fs2.default.createWriteStream(tempLookupSegPath, {
1950
+ highWaterMark: this.maxHighWaterMark
1951
+ });
1952
+ let writeChain = Promise.resolve();
1826
1953
  worker.on("message", (msg) => {
1827
1954
  if (msg.type === "log") {
1828
- } else if (msg.type === "complete") {
1829
- completedSimulations++;
1830
- if (completedSimulations % 250 === 0) {
1831
- logArrowProgress(completedSimulations, totalSims);
1832
- }
1833
- const book = Book.fromSerialized(msg.book);
1834
- const bookData = {
1835
- id: book.id,
1836
- payoutMultiplier: book.payout,
1837
- events: book.events
1838
- };
1839
- const prefix = book.id === simStart ? "" : "\n";
1840
- bookStream.write(prefix + JSONL.stringify([bookData]));
1841
- book.events = [];
1842
- this.library.set(book.id, book);
1843
- if (this.recordsWriteStream) {
1844
- for (const record of msg.records) {
1845
- const recordPrefix = this.hasWrittenRecord ? "\n" : "";
1846
- this.recordsWriteStream.write(recordPrefix + JSONL.stringify([record]));
1847
- this.hasWrittenRecord = true;
1955
+ return;
1956
+ }
1957
+ if (msg.type === "complete") {
1958
+ writeChain = writeChain.then(async () => {
1959
+ completedSimulations++;
1960
+ if (completedSimulations % 250 === 0) {
1961
+ logArrowProgress(completedSimulations, totalSims);
1848
1962
  }
1849
- }
1850
- this.wallet.mergeSerialized(msg.wallet);
1851
- } else if (msg.type === "done") {
1852
- resolve(true);
1963
+ const book = msg.book;
1964
+ const bookData = {
1965
+ id: book.id,
1966
+ payoutMultiplier: book.payout,
1967
+ events: book.events
1968
+ };
1969
+ const prefix = book.id === simStart ? "" : "\n";
1970
+ await write(bookStream, prefix + JSONL.stringify([bookData]));
1971
+ await write(lookupStream, `${book.id},1,${Math.round(book.payout)}
1972
+ `);
1973
+ await write(
1974
+ lookupSegmentedStream,
1975
+ `${book.id},${book.criteria},${book.basegameWins},${book.freespinsWins}
1976
+ `
1977
+ );
1978
+ if (this.recordsWriteStream) {
1979
+ for (const record of msg.records) {
1980
+ const recordPrefix = this.hasWrittenRecord ? "\n" : "";
1981
+ await write(
1982
+ this.recordsWriteStream,
1983
+ recordPrefix + JSONL.stringify([record])
1984
+ );
1985
+ this.hasWrittenRecord = true;
1986
+ }
1987
+ }
1988
+ this.wallet.mergeSerialized(msg.wallet);
1989
+ worker.postMessage({ type: "credit", amount: 1 });
1990
+ }).catch(reject);
1991
+ return;
1992
+ }
1993
+ if (msg.type === "done") {
1994
+ writeChain.then(async () => {
1995
+ bookStream.end();
1996
+ lookupStream.end();
1997
+ lookupSegmentedStream.end();
1998
+ await Promise.all([
1999
+ new Promise((r) => bookStream.on("finish", () => r())),
2000
+ new Promise((r) => lookupStream.on("finish", () => r())),
2001
+ new Promise((r) => lookupSegmentedStream.on("finish", () => r()))
2002
+ ]);
2003
+ resolve(true);
2004
+ }).catch(reject);
2005
+ return;
1853
2006
  }
1854
2007
  });
1855
2008
  worker.on("error", (error) => {
@@ -1910,6 +2063,31 @@ ${error.stack}
1910
2063
  records: ctx.services.data._getRecords()
1911
2064
  });
1912
2065
  }
2066
+ initCreditListener() {
2067
+ if (this.creditListenerInit) return;
2068
+ this.creditListenerInit = true;
2069
+ import_worker_threads.parentPort?.on("message", (msg) => {
2070
+ if (msg?.type !== "credit") return;
2071
+ const amount = Number(msg?.amount ?? 0);
2072
+ if (!Number.isFinite(amount) || amount <= 0) return;
2073
+ this.credits += amount;
2074
+ while (this.credits > 0 && this.creditWaiters.length > 0) {
2075
+ this.credits -= 1;
2076
+ const resolve = this.creditWaiters.shift();
2077
+ resolve();
2078
+ }
2079
+ });
2080
+ }
2081
+ acquireCredit() {
2082
+ this.initCreditListener();
2083
+ if (this.credits > 0) {
2084
+ this.credits -= 1;
2085
+ return Promise.resolve();
2086
+ }
2087
+ return new Promise((resolve) => {
2088
+ this.creditWaiters.push(resolve);
2089
+ });
2090
+ }
1913
2091
  /**
1914
2092
  * If a simulation does not meet the required criteria, reset the state to run it again.
1915
2093
  *
@@ -1937,7 +2115,7 @@ ${error.stack}
1937
2115
  ctx.state.totalFreespinAmount = 0;
1938
2116
  ctx.state.triggeredMaxWin = false;
1939
2117
  ctx.state.triggeredFreespins = false;
1940
- ctx.state.userData = ctx.config.userState || {};
2118
+ ctx.state.userData = copy(ctx.config.userState);
1941
2119
  }
1942
2120
  /**
1943
2121
  * Contains and executes the entire game logic:
@@ -1952,64 +2130,9 @@ ${error.stack}
1952
2130
  handleGameFlow(ctx) {
1953
2131
  this.gameConfig.hooks.onHandleGameFlow(ctx);
1954
2132
  }
1955
- /**
1956
- * Creates a CSV file in the format "simulationId,weight,payout".
1957
- *
1958
- * `weight` defaults to 1.
1959
- */
1960
- writeLookupTableCSV(gameMode) {
1961
- const rows = [];
1962
- for (const [bookId, book] of this.library.entries()) {
1963
- rows.push(`${book.id},1,${Math.round(book.payout)}`);
1964
- }
1965
- rows.sort((a, b) => Number(a.split(",")[0]) - Number(b.split(",")[0]));
1966
- let outputFileName = `lookUpTable_${gameMode}.csv`;
1967
- let outputFilePath = import_path.default.join(
1968
- this.gameConfig.rootDir,
1969
- this.gameConfig.outputDir,
1970
- outputFileName
1971
- );
1972
- writeFile(outputFilePath, rows.join("\n"));
1973
- outputFileName = `lookUpTable_${gameMode}_0.csv`;
1974
- outputFilePath = import_path.default.join(
1975
- this.gameConfig.rootDir,
1976
- this.gameConfig.outputDir,
1977
- "publish_files",
1978
- outputFileName
1979
- );
1980
- writeFile(outputFilePath, rows.join("\n"));
1981
- return outputFilePath;
1982
- }
1983
- /**
1984
- * Creates a CSV file in the format "simulationId,criteria,payoutBase,payoutFreespins".
1985
- */
1986
- writeLookupTableSegmentedCSV(gameMode) {
1987
- const rows = [];
1988
- for (const [bookId, book] of this.library.entries()) {
1989
- rows.push(`${book.id},${book.criteria},${book.basegameWins},${book.freespinsWins}`);
1990
- }
1991
- rows.sort((a, b) => Number(a.split(",")[0]) - Number(b.split(",")[0]));
1992
- const outputFileName = `lookUpTableSegmented_${gameMode}.csv`;
1993
- const outputFilePath = import_path.default.join(
1994
- this.gameConfig.rootDir,
1995
- this.gameConfig.outputDir,
1996
- outputFileName
1997
- );
1998
- writeFile(outputFilePath, rows.join("\n"));
1999
- return outputFilePath;
2000
- }
2001
2133
  async writeRecords(mode) {
2002
- const tempRecordsPath = import_path.default.join(
2003
- this.gameConfig.rootDir,
2004
- this.gameConfig.outputDir,
2005
- TEMP_FOLDER,
2006
- `temp_records_${mode}.jsonl`
2007
- );
2008
- const forceRecordsPath = import_path.default.join(
2009
- this.gameConfig.rootDir,
2010
- this.gameConfig.outputDir,
2011
- `force_record_${mode}.json`
2012
- );
2134
+ const tempRecordsPath = this.PATHS.tempRecords(mode);
2135
+ const forceRecordsPath = this.PATHS.forceRecords(mode);
2013
2136
  const aggregatedRecords = /* @__PURE__ */ new Map();
2014
2137
  if (import_fs2.default.existsSync(tempRecordsPath)) {
2015
2138
  const fileStream = import_fs2.default.createReadStream(tempRecordsPath);
@@ -2055,15 +2178,10 @@ ${error.stack}
2055
2178
  import_fs2.default.rmSync(tempRecordsPath, { force: true });
2056
2179
  }
2057
2180
  writeIndexJson() {
2058
- const outputFilePath = import_path.default.join(
2059
- this.gameConfig.rootDir,
2060
- this.gameConfig.outputDir,
2061
- "publish_files",
2062
- "index.json"
2063
- );
2181
+ const outputFilePath = this.PATHS.indexJson;
2064
2182
  const modes = Object.keys(this.simRunsAmount).map((id) => {
2065
2183
  const mode = this.gameConfig.gameModes[id];
2066
- (0, import_assert6.default)(mode, `Game mode "${id}" not found in game config.`);
2184
+ (0, import_assert7.default)(mode, `Game mode "${id}" not found in game config.`);
2067
2185
  return {
2068
2186
  name: mode.name,
2069
2187
  cost: mode.cost,
@@ -2074,17 +2192,8 @@ ${error.stack}
2074
2192
  writeFile(outputFilePath, JSON.stringify({ modes }, null, 2));
2075
2193
  }
2076
2194
  async writeBooksJson(gameMode) {
2077
- const outputFilePath = import_path.default.join(
2078
- this.gameConfig.rootDir,
2079
- this.gameConfig.outputDir,
2080
- `books_${gameMode}.jsonl`
2081
- );
2082
- const compressedFilePath = import_path.default.join(
2083
- this.gameConfig.rootDir,
2084
- this.gameConfig.outputDir,
2085
- "publish_files",
2086
- `books_${gameMode}.jsonl.zst`
2087
- );
2195
+ const outputFilePath = this.PATHS.books(gameMode);
2196
+ const compressedFilePath = this.PATHS.booksCompressed(gameMode);
2088
2197
  import_fs2.default.rmSync(compressedFilePath, { force: true });
2089
2198
  if (import_fs2.default.existsSync(outputFilePath)) {
2090
2199
  await (0, import_promises.pipeline)(
@@ -2117,11 +2226,12 @@ ${error.stack}
2117
2226
  });
2118
2227
  }
2119
2228
  getSimRangesForChunks(total, chunks) {
2120
- const base = Math.floor(total / chunks);
2121
- const remainder = total % chunks;
2229
+ const realChunks = Math.min(chunks, Math.max(total, 1));
2230
+ const base = Math.floor(total / realChunks);
2231
+ const remainder = total % realChunks;
2122
2232
  const result = [];
2123
2233
  let current = 1;
2124
- for (let i = 0; i < chunks; i++) {
2234
+ for (let i = 0; i < realChunks; i++) {
2125
2235
  const size = base + (i < remainder ? 1 : 0);
2126
2236
  const start = current;
2127
2237
  const end = current + size - 1;
@@ -2147,6 +2257,22 @@ ${error.stack}
2147
2257
  }
2148
2258
  }
2149
2259
  }
2260
+ async mergeCsv(chunks, outPath, tempName) {
2261
+ import_fs2.default.rmSync(outPath, { force: true });
2262
+ const out = import_fs2.default.createWriteStream(outPath);
2263
+ let wroteAny = false;
2264
+ for (let i = 0; i < chunks.length; i++) {
2265
+ const p = import_path.default.join(this.PATHS.base, TEMP_FOLDER, tempName(i));
2266
+ if (!import_fs2.default.existsSync(p)) continue;
2267
+ if (wroteAny) out.write("");
2268
+ const rs = import_fs2.default.createReadStream(p);
2269
+ for await (const buf of rs) out.write(buf);
2270
+ import_fs2.default.rmSync(p);
2271
+ wroteAny = true;
2272
+ }
2273
+ out.end();
2274
+ await new Promise((resolve) => out.on("finish", resolve));
2275
+ }
2150
2276
  /**
2151
2277
  * Confirms all pending records and adds them to the main records list.
2152
2278
  */
@@ -2182,7 +2308,7 @@ ${error.stack}
2182
2308
  // src/analysis/index.ts
2183
2309
  var import_fs3 = __toESM(require("fs"));
2184
2310
  var import_path2 = __toESM(require("path"));
2185
- var import_assert7 = __toESM(require("assert"));
2311
+ var import_assert8 = __toESM(require("assert"));
2186
2312
 
2187
2313
  // src/analysis/utils.ts
2188
2314
  function parseLookupTable(content) {
@@ -2359,7 +2485,7 @@ var Analysis = class {
2359
2485
  booksJsonlCompressed
2360
2486
  };
2361
2487
  for (const p of Object.values(paths[modeStr])) {
2362
- (0, import_assert7.default)(
2488
+ (0, import_assert8.default)(
2363
2489
  import_fs3.default.existsSync(p),
2364
2490
  `File "${p}" does not exist. Run optimization to auto-create it.`
2365
2491
  );
@@ -2481,14 +2607,14 @@ var Analysis = class {
2481
2607
  }
2482
2608
  getGameModeConfig(mode) {
2483
2609
  const config = this.gameConfig.gameModes[mode];
2484
- (0, import_assert7.default)(config, `Game mode "${mode}" not found in game config`);
2610
+ (0, import_assert8.default)(config, `Game mode "${mode}" not found in game config`);
2485
2611
  return config;
2486
2612
  }
2487
2613
  };
2488
2614
 
2489
2615
  // src/optimizer/index.ts
2490
2616
  var import_path5 = __toESM(require("path"));
2491
- var import_assert9 = __toESM(require("assert"));
2617
+ var import_assert10 = __toESM(require("assert"));
2492
2618
  var import_child_process = require("child_process");
2493
2619
  var import_worker_threads3 = require("worker_threads");
2494
2620
 
@@ -2589,7 +2715,7 @@ function makeSetupFile(optimizer, gameMode) {
2589
2715
  }
2590
2716
 
2591
2717
  // src/optimizer/OptimizationConditions.ts
2592
- var import_assert8 = __toESM(require("assert"));
2718
+ var import_assert9 = __toESM(require("assert"));
2593
2719
  var OptimizationConditions = class {
2594
2720
  rtp;
2595
2721
  avgWin;
@@ -2600,14 +2726,14 @@ var OptimizationConditions = class {
2600
2726
  constructor(opts) {
2601
2727
  let { rtp, avgWin, hitRate, searchConditions, priority } = opts;
2602
2728
  if (rtp == void 0 || rtp === "x") {
2603
- (0, import_assert8.default)(avgWin !== void 0 && hitRate !== void 0, "If RTP is not specified, hit-rate (hr) and average win amount (av_win) must be given.");
2729
+ (0, import_assert9.default)(avgWin !== void 0 && hitRate !== void 0, "If RTP is not specified, hit-rate (hr) and average win amount (av_win) must be given.");
2604
2730
  rtp = Math.round(avgWin / Number(hitRate) * 1e5) / 1e5;
2605
2731
  }
2606
2732
  let noneCount = 0;
2607
2733
  for (const val of [rtp, avgWin, hitRate]) {
2608
2734
  if (val === void 0) noneCount++;
2609
2735
  }
2610
- (0, import_assert8.default)(noneCount <= 1, "Invalid combination of optimization conditions.");
2736
+ (0, import_assert9.default)(noneCount <= 1, "Invalid combination of optimization conditions.");
2611
2737
  this.searchRange = [-1, -1];
2612
2738
  this.forceSearch = {};
2613
2739
  if (typeof searchConditions === "number") {
@@ -2735,7 +2861,7 @@ var Optimizer = class {
2735
2861
  }
2736
2862
  gameModeRtp = Math.round(gameModeRtp * 1e3) / 1e3;
2737
2863
  paramRtp = Math.round(paramRtp * 1e3) / 1e3;
2738
- (0, import_assert9.default)(
2864
+ (0, import_assert10.default)(
2739
2865
  gameModeRtp === paramRtp,
2740
2866
  `Sum of all RTP conditions (${paramRtp}) does not match the game mode RTP (${gameModeRtp}) in game mode "${k}".`
2741
2867
  );
@@ -2874,7 +3000,7 @@ var defineSymbols = (symbols) => symbols;
2874
3000
  var defineGameModes = (gameModes) => gameModes;
2875
3001
 
2876
3002
  // src/game-mode/index.ts
2877
- var import_assert10 = __toESM(require("assert"));
3003
+ var import_assert11 = __toESM(require("assert"));
2878
3004
  var GameMode = class {
2879
3005
  name;
2880
3006
  _reelsAmount;
@@ -2897,12 +3023,12 @@ var GameMode = class {
2897
3023
  this.reelSets = opts.reelSets;
2898
3024
  this.resultSets = opts.resultSets;
2899
3025
  this.isBonusBuy = opts.isBonusBuy;
2900
- (0, import_assert10.default)(this.rtp >= 0.9 && this.rtp <= 0.99, "RTP must be between 0.9 and 0.99");
2901
- (0, import_assert10.default)(
3026
+ (0, import_assert11.default)(this.rtp >= 0.9 && this.rtp <= 0.99, "RTP must be between 0.9 and 0.99");
3027
+ (0, import_assert11.default)(
2902
3028
  this.symbolsPerReel.length === this.reelsAmount,
2903
3029
  "symbolsPerReel length must match reelsAmount."
2904
3030
  );
2905
- (0, import_assert10.default)(this.reelSets.length > 0, "GameMode must have at least one ReelSet defined.");
3031
+ (0, import_assert11.default)(this.reelSets.length > 0, "GameMode must have at least one ReelSet defined.");
2906
3032
  }
2907
3033
  /**
2908
3034
  * Intended for internal use only.
@@ -2915,7 +3041,7 @@ var GameMode = class {
2915
3041
  * Intended for internal use only.
2916
3042
  */
2917
3043
  _setSymbolsPerReel(symbolsPerReel) {
2918
- (0, import_assert10.default)(
3044
+ (0, import_assert11.default)(
2919
3045
  symbolsPerReel.length === this._reelsAmount,
2920
3046
  "symbolsPerReel length must match reelsAmount."
2921
3047
  );
@@ -2981,7 +3107,7 @@ var WinType = class {
2981
3107
  };
2982
3108
 
2983
3109
  // src/win-types/LinesWinType.ts
2984
- var import_assert11 = __toESM(require("assert"));
3110
+ var import_assert12 = __toESM(require("assert"));
2985
3111
  var LinesWinType = class extends WinType {
2986
3112
  lines;
2987
3113
  constructor(opts) {
@@ -3035,8 +3161,8 @@ var LinesWinType = class extends WinType {
3035
3161
  if (!baseSymbol) {
3036
3162
  baseSymbol = thisSymbol;
3037
3163
  }
3038
- (0, import_assert11.default)(baseSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3039
- (0, import_assert11.default)(thisSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3164
+ (0, import_assert12.default)(baseSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3165
+ (0, import_assert12.default)(thisSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3040
3166
  if (potentialWinLine.length == 0) {
3041
3167
  if (this.isWild(thisSymbol)) {
3042
3168
  potentialWildLine.push({ reel: ridx, row: sidx, symbol: thisSymbol });
@@ -3705,7 +3831,7 @@ var GeneratedReelSet = class extends ReelSet {
3705
3831
  };
3706
3832
 
3707
3833
  // src/reel-set/StaticReelSet.ts
3708
- var import_assert12 = __toESM(require("assert"));
3834
+ var import_assert13 = __toESM(require("assert"));
3709
3835
  var StaticReelSet = class extends ReelSet {
3710
3836
  reels;
3711
3837
  csvPath;
@@ -3715,7 +3841,7 @@ var StaticReelSet = class extends ReelSet {
3715
3841
  this.reels = [];
3716
3842
  this._strReels = opts.reels || [];
3717
3843
  this.csvPath = opts.csvPath || "";
3718
- (0, import_assert12.default)(
3844
+ (0, import_assert13.default)(
3719
3845
  opts.reels || opts.csvPath,
3720
3846
  `Either 'reels' or 'csvPath' must be provided for StaticReelSet ${this.id}`
3721
3847
  );