@slot-engine/core 0.1.10 → 0.1.11

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 = [];
@@ -1376,17 +1368,6 @@ var Book = class _Book {
1376
1368
  freespinsWins: this.freespinsWins
1377
1369
  };
1378
1370
  }
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
1371
  };
1391
1372
 
1392
1373
  // src/wallet/index.ts
@@ -1612,6 +1593,107 @@ var Wallet = class {
1612
1593
 
1613
1594
  // src/simulation/index.ts
1614
1595
  var import_promises = require("stream/promises");
1596
+
1597
+ // src/simulation/utils.ts
1598
+ var import_assert6 = __toESM(require("assert"));
1599
+ function hashStringToInt(input) {
1600
+ let h = 2166136261;
1601
+ for (let i = 0; i < input.length; i++) {
1602
+ h ^= input.charCodeAt(i);
1603
+ h = Math.imul(h, 16777619);
1604
+ }
1605
+ return h >>> 0;
1606
+ }
1607
+ function splitCountsAcrossChunks(totalCounts, chunkSizes) {
1608
+ const total = chunkSizes.reduce((a, b) => a + b, 0);
1609
+ const allCriteria = Object.keys(totalCounts);
1610
+ const totalCountsSum = allCriteria.reduce((s, c) => s + (totalCounts[c] ?? 0), 0);
1611
+ (0, import_assert6.default)(
1612
+ totalCountsSum === total,
1613
+ `Counts (${totalCountsSum}) must match chunk total (${total}).`
1614
+ );
1615
+ const perChunk = chunkSizes.map(() => ({}));
1616
+ for (const criteria of allCriteria) {
1617
+ const count = totalCounts[criteria] ?? 0;
1618
+ if (count <= 0) {
1619
+ for (let i = 0; i < chunkSizes.length; i++) perChunk[i][criteria] = 0;
1620
+ continue;
1621
+ }
1622
+ let chunks = chunkSizes.map((size) => count * size / total);
1623
+ chunks = chunks.map((x) => Math.floor(x));
1624
+ let assigned = chunks.reduce((a, b) => a + b, 0);
1625
+ let remaining = count - assigned;
1626
+ const remainders = chunks.map((x, i) => ({ i, r: x - Math.floor(x) })).sort((a, b) => b.r - a.r);
1627
+ for (let i = 0; i < chunkSizes.length; i++) {
1628
+ perChunk[i][criteria] = chunks[i];
1629
+ }
1630
+ let idx = 0;
1631
+ while (remaining > 0) {
1632
+ perChunk[remainders[idx].i][criteria] += 1;
1633
+ remaining--;
1634
+ idx = (idx + 1) % remainders.length;
1635
+ }
1636
+ }
1637
+ const chunkTotals = () => perChunk.map((m) => Object.values(m).reduce((s, v) => s + v, 0));
1638
+ let totals = chunkTotals();
1639
+ const getDeficits = () => totals.map((t, i) => chunkSizes[i] - t);
1640
+ let deficits = getDeficits();
1641
+ for (let target = 0; target < chunkSizes.length; target++) {
1642
+ while (deficits[target] > 0) {
1643
+ const src = deficits.findIndex((d) => d < 0);
1644
+ (0, import_assert6.default)(src !== -1, "No surplus chunk found, but deficits remain.");
1645
+ const crit = allCriteria.find((c) => (perChunk[src][c] ?? 0) > 0);
1646
+ (0, import_assert6.default)(crit, `No movable criteria found from surplus chunk ${src}.`);
1647
+ perChunk[src][crit] -= 1;
1648
+ perChunk[target][crit] = (perChunk[target][crit] ?? 0) + 1;
1649
+ totals[src] -= 1;
1650
+ totals[target] += 1;
1651
+ deficits[src] += 1;
1652
+ deficits[target] -= 1;
1653
+ }
1654
+ }
1655
+ totals = chunkTotals();
1656
+ for (let i = 0; i < chunkSizes.length; i++) {
1657
+ (0, import_assert6.default)(
1658
+ totals[i] === chunkSizes[i],
1659
+ `Chunk ${i} size mismatch. Expected ${chunkSizes[i]}, got ${totals[i]}`
1660
+ );
1661
+ }
1662
+ for (const c of allCriteria) {
1663
+ const sum = perChunk.reduce((s, m) => s + (m[c] ?? 0), 0);
1664
+ (0, import_assert6.default)(sum === (totalCounts[c] ?? 0), `Chunk split mismatch for criteria "${c}"`);
1665
+ }
1666
+ return perChunk;
1667
+ }
1668
+ function createCriteriaSampler(counts, seed) {
1669
+ const rng = new RandomNumberGenerator();
1670
+ rng.setSeed(seed);
1671
+ const keys = Object.keys(counts).filter((k) => (counts[k] ?? 0) > 0);
1672
+ const remaining = Object.fromEntries(keys.map((k) => [k, counts[k] ?? 0]));
1673
+ let remainingTotal = Object.values(remaining).reduce((a, b) => a + b, 0);
1674
+ return () => {
1675
+ if (remainingTotal <= 0) return "N/A";
1676
+ const roll = Math.min(
1677
+ remainingTotal - Number.EPSILON,
1678
+ rng.randomFloat(0, remainingTotal)
1679
+ );
1680
+ let acc = 0;
1681
+ for (const k of keys) {
1682
+ const w = remaining[k] ?? 0;
1683
+ if (w <= 0) continue;
1684
+ acc += w;
1685
+ if (roll < acc) {
1686
+ remaining[k] = w - 1;
1687
+ remainingTotal--;
1688
+ return k;
1689
+ }
1690
+ }
1691
+ remainingTotal--;
1692
+ return keys.find((k) => (remaining[k] ?? 0) > 0) ?? "N/A";
1693
+ };
1694
+ }
1695
+
1696
+ // src/simulation/index.ts
1615
1697
  var completedSimulations = 0;
1616
1698
  var TEMP_FILENAME = "__temp_compiled_src_IGNORE.js";
1617
1699
  var TEMP_FOLDER = "temp_files";
@@ -1622,25 +1704,46 @@ var Simulation = class {
1622
1704
  concurrency;
1623
1705
  debug = false;
1624
1706
  actualSims = 0;
1625
- library;
1626
1707
  wallet;
1627
1708
  recordsWriteStream;
1628
1709
  hasWrittenRecord = false;
1710
+ maxPendingSims;
1711
+ maxHighWaterMark;
1712
+ PATHS = {};
1713
+ // Worker related
1714
+ credits = 0;
1715
+ creditWaiters = [];
1716
+ creditListenerInit = false;
1629
1717
  constructor(opts, gameConfigOpts) {
1630
1718
  this.gameConfig = createGameConfig(gameConfigOpts);
1631
1719
  this.gameConfigOpts = gameConfigOpts;
1632
1720
  this.simRunsAmount = opts.simRunsAmount || {};
1633
1721
  this.concurrency = (opts.concurrency || 6) >= 2 ? opts.concurrency || 6 : 2;
1634
- this.library = /* @__PURE__ */ new Map();
1635
1722
  this.wallet = new Wallet();
1723
+ this.maxPendingSims = Math.max(10, opts.maxPendingSims ?? 250);
1724
+ this.maxHighWaterMark = (opts.maxDiskBuffer ?? 50) * 1024 * 1024;
1636
1725
  const gameModeKeys = Object.keys(this.gameConfig.gameModes);
1637
- (0, import_assert6.default)(
1726
+ (0, import_assert7.default)(
1638
1727
  Object.values(this.gameConfig.gameModes).map((m) => gameModeKeys.includes(m.name)).every((v) => v === true),
1639
1728
  "Game mode name must match its key in the gameModes object."
1640
1729
  );
1641
- if (import_worker_threads.isMainThread) {
1642
- this.preprocessFiles();
1643
- }
1730
+ this.PATHS.base = import_path.default.join(this.gameConfig.rootDir, this.gameConfig.outputDir);
1731
+ this.PATHS = {
1732
+ ...this.PATHS,
1733
+ books: (mode) => import_path.default.join(this.PATHS.base, `books_${mode}.jsonl`),
1734
+ booksCompressed: (mode) => import_path.default.join(this.PATHS.base, "publish_files", `books_${mode}.jsonl.zst`),
1735
+ tempBooks: (mode, i) => import_path.default.join(this.PATHS.base, TEMP_FOLDER, `temp_books_${mode}_${i}.jsonl`),
1736
+ lookupTable: (mode) => import_path.default.join(this.PATHS.base, `lookUpTable_${mode}.csv`),
1737
+ tempLookupTable: (mode, i) => import_path.default.join(this.PATHS.base, TEMP_FOLDER, `temp_lookup_${mode}_${i}.csv`),
1738
+ lookupTableSegmented: (mode) => import_path.default.join(this.PATHS.base, `lookUpTableSegmented_${mode}.csv`),
1739
+ tempLookupTableSegmented: (mode, i) => import_path.default.join(this.PATHS.base, TEMP_FOLDER, `temp_lookup_segmented_${mode}_${i}.csv`),
1740
+ lookupTablePublish: (mode) => import_path.default.join(this.PATHS.base, "publish_files", `lookUpTable_${mode}_0.csv`),
1741
+ tempRecords: (mode) => import_path.default.join(this.PATHS.base, TEMP_FOLDER, `temp_records_${mode}.jsonl`),
1742
+ forceRecords: (mode) => import_path.default.join(this.PATHS.base, `force_record_${mode}.json`),
1743
+ indexJson: import_path.default.join(this.PATHS.base, "publish_files", "index.json"),
1744
+ optimizationFiles: import_path.default.join(this.PATHS.base, "optimization_files"),
1745
+ publishFiles: import_path.default.join(this.PATHS.base, "publish_files")
1746
+ };
1644
1747
  }
1645
1748
  async runSimulation(opts) {
1646
1749
  const debug = opts.debug || false;
@@ -1652,11 +1755,11 @@ var Simulation = class {
1652
1755
  }
1653
1756
  this.generateReelsetFiles();
1654
1757
  if (import_worker_threads.isMainThread) {
1758
+ this.preprocessFiles();
1655
1759
  const debugDetails = {};
1656
1760
  for (const mode of gameModesToSimulate) {
1657
1761
  completedSimulations = 0;
1658
1762
  this.wallet = new Wallet();
1659
- this.library = /* @__PURE__ */ new Map();
1660
1763
  this.hasWrittenRecord = false;
1661
1764
  debugDetails[mode] = {};
1662
1765
  console.log(`
@@ -1669,37 +1772,37 @@ Simulating game mode: ${mode}`);
1669
1772
  `Tried to simulate game mode "${mode}", but it's not configured in the game config.`
1670
1773
  );
1671
1774
  }
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)
1775
+ const booksPath = this.PATHS.books(mode);
1776
+ const tempRecordsPath = this.PATHS.tempRecords(mode);
1777
+ createDirIfNotExists(this.PATHS.base);
1778
+ createDirIfNotExists(import_path.default.join(this.PATHS.base, TEMP_FOLDER));
1779
+ this.recordsWriteStream = import_fs2.default.createWriteStream(tempRecordsPath, {
1780
+ highWaterMark: this.maxHighWaterMark
1781
+ });
1782
+ const criteriaCounts = ResultSet.getNumberOfSimsForCriteria(this, mode);
1783
+ const totalSims = Object.values(criteriaCounts).reduce((a, b) => a + b, 0);
1784
+ (0, import_assert7.default)(
1785
+ totalSims === runs,
1786
+ `Criteria mismatch for mode "${mode}". Expected ${runs}, got ${totalSims}`
1685
1787
  );
1686
- createDirIfNotExists(
1687
- import_path.default.join(this.gameConfig.rootDir, this.gameConfig.outputDir, TEMP_FOLDER)
1788
+ const chunks = this.getSimRangesForChunks(totalSims, this.concurrency);
1789
+ const chunkSizes = chunks.map(([s, e]) => Math.max(0, e - s + 1));
1790
+ const chunkCriteriaCounts = splitCountsAcrossChunks(criteriaCounts, chunkSizes);
1791
+ await this.spawnWorkersForGameMode({
1792
+ mode,
1793
+ chunks,
1794
+ chunkCriteriaCounts,
1795
+ totalSims
1796
+ });
1797
+ createDirIfNotExists(this.PATHS.optimizationFiles);
1798
+ createDirIfNotExists(this.PATHS.publishFiles);
1799
+ console.log(
1800
+ `Writing final files for game mode "${mode}". This may take a while...`
1688
1801
  );
1689
- this.recordsWriteStream = import_fs2.default.createWriteStream(tempRecordsPath);
1690
- const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1691
- await this.spawnWorkersForGameMode({ mode, simNumsToCriteria });
1692
1802
  const finalBookStream = import_fs2.default.createWriteStream(booksPath);
1693
- const numSims = Object.keys(simNumsToCriteria).length;
1694
- const chunks = this.getSimRangesForChunks(numSims, this.concurrency);
1695
1803
  let isFirstChunk = true;
1696
1804
  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
- );
1805
+ const tempBookPath = this.PATHS.tempBooks(mode, i);
1703
1806
  if (import_fs2.default.existsSync(tempBookPath)) {
1704
1807
  if (!isFirstChunk) {
1705
1808
  finalBookStream.write("\n");
@@ -1714,6 +1817,16 @@ Simulating game mode: ${mode}`);
1714
1817
  }
1715
1818
  finalBookStream.end();
1716
1819
  await new Promise((resolve) => finalBookStream.on("finish", resolve));
1820
+ const lutPath = this.PATHS.lookupTable(mode);
1821
+ const lutPathPublish = this.PATHS.lookupTablePublish(mode);
1822
+ const lutSegmentedPath = this.PATHS.lookupTableSegmented(mode);
1823
+ await this.mergeCsv(chunks, lutPath, (i) => `temp_lookup_${mode}_${i}.csv`);
1824
+ import_fs2.default.copyFileSync(lutPath, lutPathPublish);
1825
+ await this.mergeCsv(
1826
+ chunks,
1827
+ lutSegmentedPath,
1828
+ (i) => `temp_lookup_segmented_${mode}_${i}.csv`
1829
+ );
1717
1830
  if (this.recordsWriteStream) {
1718
1831
  await new Promise((resolve) => {
1719
1832
  this.recordsWriteStream.end(() => {
@@ -1722,26 +1835,21 @@ Simulating game mode: ${mode}`);
1722
1835
  });
1723
1836
  this.recordsWriteStream = void 0;
1724
1837
  }
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);
1838
+ await this.writeRecords(mode);
1739
1839
  await this.writeBooksJson(mode);
1740
1840
  this.writeIndexJson();
1741
1841
  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();
1842
+ debugDetails[mode].rtp = round(
1843
+ this.wallet.getCumulativeWins() / (runs * this.gameConfig.gameModes[mode].cost),
1844
+ 3
1845
+ );
1846
+ debugDetails[mode].wins = round(this.wallet.getCumulativeWins(), 3);
1847
+ debugDetails[mode].winsPerSpinType = Object.fromEntries(
1848
+ Object.entries(this.wallet.getCumulativeWinsPerSpinType()).map(([k, v]) => [
1849
+ k,
1850
+ round(v, 3)
1851
+ ])
1852
+ );
1745
1853
  console.timeEnd(mode);
1746
1854
  }
1747
1855
  console.log("\n=== SIMULATION SUMMARY ===");
@@ -1751,14 +1859,14 @@ Simulating game mode: ${mode}`);
1751
1859
  let actualSims = 0;
1752
1860
  const criteriaToRetries = {};
1753
1861
  if (!import_worker_threads.isMainThread) {
1754
- const { mode, simStart, simEnd, index } = import_worker_threads.workerData;
1755
- const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1862
+ const { mode, simStart, simEnd, index, criteriaCounts } = import_worker_threads.workerData;
1863
+ const seed = hashStringToInt(mode) + index >>> 0;
1864
+ const nextCriteria = createCriteriaSampler(criteriaCounts, seed);
1756
1865
  for (let simId = simStart; simId <= simEnd; simId++) {
1757
1866
  if (this.debug) desiredSims++;
1758
- const criteria = simNumsToCriteria[simId] || "N/A";
1759
- if (!criteriaToRetries[criteria]) {
1760
- criteriaToRetries[criteria] = 0;
1761
- }
1867
+ const criteria = nextCriteria();
1868
+ if (!criteriaToRetries[criteria]) criteriaToRetries[criteria] = 0;
1869
+ await this.acquireCredit();
1762
1870
  this.runSingleSimulation({ simId, mode, criteria, index });
1763
1871
  if (this.debug) {
1764
1872
  criteriaToRetries[criteria] += this.actualSims - 1;
@@ -1773,30 +1881,30 @@ Simulating game mode: ${mode}`);
1773
1881
  type: "done",
1774
1882
  workerNum: index
1775
1883
  });
1884
+ import_worker_threads.parentPort?.close();
1776
1885
  }
1777
1886
  }
1778
1887
  /**
1779
1888
  * Runs all simulations for a specific game mode.
1780
1889
  */
1781
1890
  async spawnWorkersForGameMode(opts) {
1782
- const { mode, simNumsToCriteria } = opts;
1783
- const numSims = Object.keys(simNumsToCriteria).length;
1784
- const simRangesPerChunk = this.getSimRangesForChunks(numSims, this.concurrency);
1891
+ const { mode, chunks, chunkCriteriaCounts, totalSims } = opts;
1785
1892
  await Promise.all(
1786
- simRangesPerChunk.map(([simStart, simEnd], index) => {
1893
+ chunks.map(([simStart, simEnd], index) => {
1787
1894
  return this.callWorker({
1788
- basePath: import_path.default.join(this.gameConfig.rootDir, this.gameConfig.outputDir),
1895
+ basePath: this.PATHS.base,
1789
1896
  mode,
1790
1897
  simStart,
1791
1898
  simEnd,
1792
1899
  index,
1793
- totalSims: numSims
1900
+ totalSims,
1901
+ criteriaCounts: chunkCriteriaCounts[index]
1794
1902
  });
1795
1903
  })
1796
1904
  );
1797
1905
  }
1798
1906
  async callWorker(opts) {
1799
- const { mode, simEnd, simStart, basePath, index, totalSims } = opts;
1907
+ const { mode, simEnd, simStart, basePath, index, totalSims, criteriaCounts } = opts;
1800
1908
  function logArrowProgress(current, total) {
1801
1909
  const percentage = current / total * 100;
1802
1910
  const progressBarLength = 50;
@@ -1807,6 +1915,11 @@ Simulating game mode: ${mode}`);
1807
1915
  process.stdout.write("\n");
1808
1916
  }
1809
1917
  }
1918
+ const write = async (stream, chunk) => {
1919
+ if (!stream.write(chunk)) {
1920
+ await new Promise((resolve) => stream.once("drain", resolve));
1921
+ }
1922
+ };
1810
1923
  return new Promise((resolve, reject) => {
1811
1924
  const scriptPath = import_path.default.join(basePath, TEMP_FILENAME);
1812
1925
  const worker = new import_worker_threads.Worker(scriptPath, {
@@ -1814,42 +1927,77 @@ Simulating game mode: ${mode}`);
1814
1927
  mode,
1815
1928
  simStart,
1816
1929
  simEnd,
1817
- index
1930
+ index,
1931
+ criteriaCounts
1818
1932
  }
1819
1933
  });
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);
1934
+ worker.postMessage({ type: "credit", amount: this.maxPendingSims });
1935
+ const tempBookPath = this.PATHS.tempBooks(mode, index);
1936
+ const bookStream = import_fs2.default.createWriteStream(tempBookPath, {
1937
+ highWaterMark: this.maxHighWaterMark
1938
+ });
1939
+ const tempLookupPath = this.PATHS.tempLookupTable(mode, index);
1940
+ const lookupStream = import_fs2.default.createWriteStream(tempLookupPath, {
1941
+ highWaterMark: this.maxHighWaterMark
1942
+ });
1943
+ const tempLookupSegPath = this.PATHS.tempLookupTableSegmented(mode, index);
1944
+ const lookupSegmentedStream = import_fs2.default.createWriteStream(tempLookupSegPath, {
1945
+ highWaterMark: this.maxHighWaterMark
1946
+ });
1947
+ let writeChain = Promise.resolve();
1826
1948
  worker.on("message", (msg) => {
1827
1949
  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;
1950
+ return;
1951
+ }
1952
+ if (msg.type === "complete") {
1953
+ writeChain = writeChain.then(async () => {
1954
+ completedSimulations++;
1955
+ if (completedSimulations % 250 === 0) {
1956
+ logArrowProgress(completedSimulations, totalSims);
1848
1957
  }
1849
- }
1850
- this.wallet.mergeSerialized(msg.wallet);
1851
- } else if (msg.type === "done") {
1852
- resolve(true);
1958
+ const book = msg.book;
1959
+ const bookData = {
1960
+ id: book.id,
1961
+ payoutMultiplier: book.payout,
1962
+ events: book.events
1963
+ };
1964
+ const prefix = book.id === simStart ? "" : "\n";
1965
+ await write(bookStream, prefix + JSONL.stringify([bookData]));
1966
+ await write(lookupStream, `${book.id},1,${Math.round(book.payout)}
1967
+ `);
1968
+ await write(
1969
+ lookupSegmentedStream,
1970
+ `${book.id},${book.criteria},${book.basegameWins},${book.freespinsWins}
1971
+ `
1972
+ );
1973
+ if (this.recordsWriteStream) {
1974
+ for (const record of msg.records) {
1975
+ const recordPrefix = this.hasWrittenRecord ? "\n" : "";
1976
+ await write(
1977
+ this.recordsWriteStream,
1978
+ recordPrefix + JSONL.stringify([record])
1979
+ );
1980
+ this.hasWrittenRecord = true;
1981
+ }
1982
+ }
1983
+ this.wallet.mergeSerialized(msg.wallet);
1984
+ worker.postMessage({ type: "credit", amount: 1 });
1985
+ }).catch(reject);
1986
+ return;
1987
+ }
1988
+ if (msg.type === "done") {
1989
+ writeChain.then(async () => {
1990
+ bookStream.end();
1991
+ lookupStream.end();
1992
+ lookupSegmentedStream.end();
1993
+ await Promise.all([
1994
+ new Promise((r) => bookStream.on("finish", () => r())),
1995
+ new Promise((r) => lookupStream.on("finish", () => r())),
1996
+ new Promise((r) => lookupSegmentedStream.on("finish", () => r()))
1997
+ ]);
1998
+ resolve(true);
1999
+ }).catch(reject);
2000
+ return;
1853
2001
  }
1854
2002
  });
1855
2003
  worker.on("error", (error) => {
@@ -1910,6 +2058,31 @@ ${error.stack}
1910
2058
  records: ctx.services.data._getRecords()
1911
2059
  });
1912
2060
  }
2061
+ initCreditListener() {
2062
+ if (this.creditListenerInit) return;
2063
+ this.creditListenerInit = true;
2064
+ import_worker_threads.parentPort?.on("message", (msg) => {
2065
+ if (msg?.type !== "credit") return;
2066
+ const amount = Number(msg?.amount ?? 0);
2067
+ if (!Number.isFinite(amount) || amount <= 0) return;
2068
+ this.credits += amount;
2069
+ while (this.credits > 0 && this.creditWaiters.length > 0) {
2070
+ this.credits -= 1;
2071
+ const resolve = this.creditWaiters.shift();
2072
+ resolve();
2073
+ }
2074
+ });
2075
+ }
2076
+ acquireCredit() {
2077
+ this.initCreditListener();
2078
+ if (this.credits > 0) {
2079
+ this.credits -= 1;
2080
+ return Promise.resolve();
2081
+ }
2082
+ return new Promise((resolve) => {
2083
+ this.creditWaiters.push(resolve);
2084
+ });
2085
+ }
1913
2086
  /**
1914
2087
  * If a simulation does not meet the required criteria, reset the state to run it again.
1915
2088
  *
@@ -1952,64 +2125,9 @@ ${error.stack}
1952
2125
  handleGameFlow(ctx) {
1953
2126
  this.gameConfig.hooks.onHandleGameFlow(ctx);
1954
2127
  }
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
2128
  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
- );
2129
+ const tempRecordsPath = this.PATHS.tempRecords(mode);
2130
+ const forceRecordsPath = this.PATHS.forceRecords(mode);
2013
2131
  const aggregatedRecords = /* @__PURE__ */ new Map();
2014
2132
  if (import_fs2.default.existsSync(tempRecordsPath)) {
2015
2133
  const fileStream = import_fs2.default.createReadStream(tempRecordsPath);
@@ -2055,15 +2173,10 @@ ${error.stack}
2055
2173
  import_fs2.default.rmSync(tempRecordsPath, { force: true });
2056
2174
  }
2057
2175
  writeIndexJson() {
2058
- const outputFilePath = import_path.default.join(
2059
- this.gameConfig.rootDir,
2060
- this.gameConfig.outputDir,
2061
- "publish_files",
2062
- "index.json"
2063
- );
2176
+ const outputFilePath = this.PATHS.indexJson;
2064
2177
  const modes = Object.keys(this.simRunsAmount).map((id) => {
2065
2178
  const mode = this.gameConfig.gameModes[id];
2066
- (0, import_assert6.default)(mode, `Game mode "${id}" not found in game config.`);
2179
+ (0, import_assert7.default)(mode, `Game mode "${id}" not found in game config.`);
2067
2180
  return {
2068
2181
  name: mode.name,
2069
2182
  cost: mode.cost,
@@ -2074,17 +2187,8 @@ ${error.stack}
2074
2187
  writeFile(outputFilePath, JSON.stringify({ modes }, null, 2));
2075
2188
  }
2076
2189
  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
- );
2190
+ const outputFilePath = this.PATHS.books(gameMode);
2191
+ const compressedFilePath = this.PATHS.booksCompressed(gameMode);
2088
2192
  import_fs2.default.rmSync(compressedFilePath, { force: true });
2089
2193
  if (import_fs2.default.existsSync(outputFilePath)) {
2090
2194
  await (0, import_promises.pipeline)(
@@ -2117,11 +2221,12 @@ ${error.stack}
2117
2221
  });
2118
2222
  }
2119
2223
  getSimRangesForChunks(total, chunks) {
2120
- const base = Math.floor(total / chunks);
2121
- const remainder = total % chunks;
2224
+ const realChunks = Math.min(chunks, Math.max(total, 1));
2225
+ const base = Math.floor(total / realChunks);
2226
+ const remainder = total % realChunks;
2122
2227
  const result = [];
2123
2228
  let current = 1;
2124
- for (let i = 0; i < chunks; i++) {
2229
+ for (let i = 0; i < realChunks; i++) {
2125
2230
  const size = base + (i < remainder ? 1 : 0);
2126
2231
  const start = current;
2127
2232
  const end = current + size - 1;
@@ -2147,6 +2252,22 @@ ${error.stack}
2147
2252
  }
2148
2253
  }
2149
2254
  }
2255
+ async mergeCsv(chunks, outPath, tempName) {
2256
+ import_fs2.default.rmSync(outPath, { force: true });
2257
+ const out = import_fs2.default.createWriteStream(outPath);
2258
+ let wroteAny = false;
2259
+ for (let i = 0; i < chunks.length; i++) {
2260
+ const p = import_path.default.join(this.PATHS.base, TEMP_FOLDER, tempName(i));
2261
+ if (!import_fs2.default.existsSync(p)) continue;
2262
+ if (wroteAny) out.write("");
2263
+ const rs = import_fs2.default.createReadStream(p);
2264
+ for await (const buf of rs) out.write(buf);
2265
+ import_fs2.default.rmSync(p);
2266
+ wroteAny = true;
2267
+ }
2268
+ out.end();
2269
+ await new Promise((resolve) => out.on("finish", resolve));
2270
+ }
2150
2271
  /**
2151
2272
  * Confirms all pending records and adds them to the main records list.
2152
2273
  */
@@ -2182,7 +2303,7 @@ ${error.stack}
2182
2303
  // src/analysis/index.ts
2183
2304
  var import_fs3 = __toESM(require("fs"));
2184
2305
  var import_path2 = __toESM(require("path"));
2185
- var import_assert7 = __toESM(require("assert"));
2306
+ var import_assert8 = __toESM(require("assert"));
2186
2307
 
2187
2308
  // src/analysis/utils.ts
2188
2309
  function parseLookupTable(content) {
@@ -2359,7 +2480,7 @@ var Analysis = class {
2359
2480
  booksJsonlCompressed
2360
2481
  };
2361
2482
  for (const p of Object.values(paths[modeStr])) {
2362
- (0, import_assert7.default)(
2483
+ (0, import_assert8.default)(
2363
2484
  import_fs3.default.existsSync(p),
2364
2485
  `File "${p}" does not exist. Run optimization to auto-create it.`
2365
2486
  );
@@ -2481,14 +2602,14 @@ var Analysis = class {
2481
2602
  }
2482
2603
  getGameModeConfig(mode) {
2483
2604
  const config = this.gameConfig.gameModes[mode];
2484
- (0, import_assert7.default)(config, `Game mode "${mode}" not found in game config`);
2605
+ (0, import_assert8.default)(config, `Game mode "${mode}" not found in game config`);
2485
2606
  return config;
2486
2607
  }
2487
2608
  };
2488
2609
 
2489
2610
  // src/optimizer/index.ts
2490
2611
  var import_path5 = __toESM(require("path"));
2491
- var import_assert9 = __toESM(require("assert"));
2612
+ var import_assert10 = __toESM(require("assert"));
2492
2613
  var import_child_process = require("child_process");
2493
2614
  var import_worker_threads3 = require("worker_threads");
2494
2615
 
@@ -2589,7 +2710,7 @@ function makeSetupFile(optimizer, gameMode) {
2589
2710
  }
2590
2711
 
2591
2712
  // src/optimizer/OptimizationConditions.ts
2592
- var import_assert8 = __toESM(require("assert"));
2713
+ var import_assert9 = __toESM(require("assert"));
2593
2714
  var OptimizationConditions = class {
2594
2715
  rtp;
2595
2716
  avgWin;
@@ -2600,14 +2721,14 @@ var OptimizationConditions = class {
2600
2721
  constructor(opts) {
2601
2722
  let { rtp, avgWin, hitRate, searchConditions, priority } = opts;
2602
2723
  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.");
2724
+ (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
2725
  rtp = Math.round(avgWin / Number(hitRate) * 1e5) / 1e5;
2605
2726
  }
2606
2727
  let noneCount = 0;
2607
2728
  for (const val of [rtp, avgWin, hitRate]) {
2608
2729
  if (val === void 0) noneCount++;
2609
2730
  }
2610
- (0, import_assert8.default)(noneCount <= 1, "Invalid combination of optimization conditions.");
2731
+ (0, import_assert9.default)(noneCount <= 1, "Invalid combination of optimization conditions.");
2611
2732
  this.searchRange = [-1, -1];
2612
2733
  this.forceSearch = {};
2613
2734
  if (typeof searchConditions === "number") {
@@ -2735,7 +2856,7 @@ var Optimizer = class {
2735
2856
  }
2736
2857
  gameModeRtp = Math.round(gameModeRtp * 1e3) / 1e3;
2737
2858
  paramRtp = Math.round(paramRtp * 1e3) / 1e3;
2738
- (0, import_assert9.default)(
2859
+ (0, import_assert10.default)(
2739
2860
  gameModeRtp === paramRtp,
2740
2861
  `Sum of all RTP conditions (${paramRtp}) does not match the game mode RTP (${gameModeRtp}) in game mode "${k}".`
2741
2862
  );
@@ -2874,7 +2995,7 @@ var defineSymbols = (symbols) => symbols;
2874
2995
  var defineGameModes = (gameModes) => gameModes;
2875
2996
 
2876
2997
  // src/game-mode/index.ts
2877
- var import_assert10 = __toESM(require("assert"));
2998
+ var import_assert11 = __toESM(require("assert"));
2878
2999
  var GameMode = class {
2879
3000
  name;
2880
3001
  _reelsAmount;
@@ -2897,12 +3018,12 @@ var GameMode = class {
2897
3018
  this.reelSets = opts.reelSets;
2898
3019
  this.resultSets = opts.resultSets;
2899
3020
  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)(
3021
+ (0, import_assert11.default)(this.rtp >= 0.9 && this.rtp <= 0.99, "RTP must be between 0.9 and 0.99");
3022
+ (0, import_assert11.default)(
2902
3023
  this.symbolsPerReel.length === this.reelsAmount,
2903
3024
  "symbolsPerReel length must match reelsAmount."
2904
3025
  );
2905
- (0, import_assert10.default)(this.reelSets.length > 0, "GameMode must have at least one ReelSet defined.");
3026
+ (0, import_assert11.default)(this.reelSets.length > 0, "GameMode must have at least one ReelSet defined.");
2906
3027
  }
2907
3028
  /**
2908
3029
  * Intended for internal use only.
@@ -2915,7 +3036,7 @@ var GameMode = class {
2915
3036
  * Intended for internal use only.
2916
3037
  */
2917
3038
  _setSymbolsPerReel(symbolsPerReel) {
2918
- (0, import_assert10.default)(
3039
+ (0, import_assert11.default)(
2919
3040
  symbolsPerReel.length === this._reelsAmount,
2920
3041
  "symbolsPerReel length must match reelsAmount."
2921
3042
  );
@@ -2981,7 +3102,7 @@ var WinType = class {
2981
3102
  };
2982
3103
 
2983
3104
  // src/win-types/LinesWinType.ts
2984
- var import_assert11 = __toESM(require("assert"));
3105
+ var import_assert12 = __toESM(require("assert"));
2985
3106
  var LinesWinType = class extends WinType {
2986
3107
  lines;
2987
3108
  constructor(opts) {
@@ -3035,8 +3156,8 @@ var LinesWinType = class extends WinType {
3035
3156
  if (!baseSymbol) {
3036
3157
  baseSymbol = thisSymbol;
3037
3158
  }
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}`);
3159
+ (0, import_assert12.default)(baseSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3160
+ (0, import_assert12.default)(thisSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3040
3161
  if (potentialWinLine.length == 0) {
3041
3162
  if (this.isWild(thisSymbol)) {
3042
3163
  potentialWildLine.push({ reel: ridx, row: sidx, symbol: thisSymbol });
@@ -3705,7 +3826,7 @@ var GeneratedReelSet = class extends ReelSet {
3705
3826
  };
3706
3827
 
3707
3828
  // src/reel-set/StaticReelSet.ts
3708
- var import_assert12 = __toESM(require("assert"));
3829
+ var import_assert13 = __toESM(require("assert"));
3709
3830
  var StaticReelSet = class extends ReelSet {
3710
3831
  reels;
3711
3832
  csvPath;
@@ -3715,7 +3836,7 @@ var StaticReelSet = class extends ReelSet {
3715
3836
  this.reels = [];
3716
3837
  this._strReels = opts.reels || [];
3717
3838
  this.csvPath = opts.csvPath || "";
3718
- (0, import_assert12.default)(
3839
+ (0, import_assert13.default)(
3719
3840
  opts.reels || opts.csvPath,
3720
3841
  `Either 'reels' or 'csvPath' must be provided for StaticReelSet ${this.id}`
3721
3842
  );