@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.mjs CHANGED
@@ -32,7 +32,7 @@ function createGameConfig(opts) {
32
32
  // src/simulation/index.ts
33
33
  import fs2 from "fs";
34
34
  import path from "path";
35
- import assert6 from "assert";
35
+ import assert7 from "assert";
36
36
  import zlib from "zlib";
37
37
  import readline2 from "readline";
38
38
  import { buildSync } from "esbuild";
@@ -258,6 +258,9 @@ var JSONL = class {
258
258
  });
259
259
  }
260
260
  };
261
+ function round(value, decimals) {
262
+ return Number(Math.round(Number(value + "e" + decimals)) + "e-" + decimals);
263
+ }
261
264
 
262
265
  // src/result-set/index.ts
263
266
  var ResultSet = class {
@@ -279,7 +282,7 @@ var ResultSet = class {
279
282
  this.forceFreespins = opts.forceFreespins;
280
283
  this.evaluate = opts.evaluate;
281
284
  }
282
- static assignCriteriaToSimulations(ctx, gameModeName) {
285
+ static getNumberOfSimsForCriteria(ctx, gameModeName) {
283
286
  const rng = new RandomNumberGenerator();
284
287
  rng.setSeed(0);
285
288
  assert2(ctx.simRunsAmount, "Simulation configuration is not set.");
@@ -306,7 +309,7 @@ var ResultSet = class {
306
309
  const criteriaToWeights = Object.fromEntries(
307
310
  resultSets.map((rs) => [rs.criteria, rs.quota])
308
311
  );
309
- while (totalSims != simNums) {
312
+ while (totalSims !== simNums) {
310
313
  const rs = rng.weightedRandom(criteriaToWeights);
311
314
  if (reduceSims && numberOfSimsForCriteria[rs] > 1) {
312
315
  numberOfSimsForCriteria[rs] -= 1;
@@ -319,18 +322,7 @@ var ResultSet = class {
319
322
  );
320
323
  reduceSims = totalSims > simNums;
321
324
  }
322
- let allCriteria = [];
323
- const simNumsToCriteria = {};
324
- Object.entries(numberOfSimsForCriteria).forEach(([criteria, num]) => {
325
- for (let i = 0; i <= num; i++) {
326
- allCriteria.push(criteria);
327
- }
328
- });
329
- allCriteria = rng.shuffle(allCriteria);
330
- for (let i = 1; i <= Math.min(simNums, allCriteria.length); i++) {
331
- simNumsToCriteria[i] = allCriteria[i];
332
- }
333
- return simNumsToCriteria;
325
+ return numberOfSimsForCriteria;
334
326
  }
335
327
  /**
336
328
  * Checks if core criteria is met, e.g. target multiplier or max win.
@@ -1287,7 +1279,7 @@ function createGameContext(opts) {
1287
1279
  }
1288
1280
 
1289
1281
  // src/book/index.ts
1290
- var Book = class _Book {
1282
+ var Book = class {
1291
1283
  id;
1292
1284
  criteria = "N/A";
1293
1285
  events = [];
@@ -1324,17 +1316,6 @@ var Book = class _Book {
1324
1316
  freespinsWins: this.freespinsWins
1325
1317
  };
1326
1318
  }
1327
- /**
1328
- * Intended for internal use only.
1329
- */
1330
- static fromSerialized(data) {
1331
- const book = new _Book({ id: data.id, criteria: data.criteria });
1332
- book.events = data.events;
1333
- book.payout = data.payout;
1334
- book.basegameWins = data.basegameWins;
1335
- book.freespinsWins = data.freespinsWins;
1336
- return book;
1337
- }
1338
1319
  };
1339
1320
 
1340
1321
  // src/wallet/index.ts
@@ -1560,6 +1541,107 @@ var Wallet = class {
1560
1541
 
1561
1542
  // src/simulation/index.ts
1562
1543
  import { pipeline } from "stream/promises";
1544
+
1545
+ // src/simulation/utils.ts
1546
+ import assert6 from "assert";
1547
+ function hashStringToInt(input) {
1548
+ let h = 2166136261;
1549
+ for (let i = 0; i < input.length; i++) {
1550
+ h ^= input.charCodeAt(i);
1551
+ h = Math.imul(h, 16777619);
1552
+ }
1553
+ return h >>> 0;
1554
+ }
1555
+ function splitCountsAcrossChunks(totalCounts, chunkSizes) {
1556
+ const total = chunkSizes.reduce((a, b) => a + b, 0);
1557
+ const allCriteria = Object.keys(totalCounts);
1558
+ const totalCountsSum = allCriteria.reduce((s, c) => s + (totalCounts[c] ?? 0), 0);
1559
+ assert6(
1560
+ totalCountsSum === total,
1561
+ `Counts (${totalCountsSum}) must match chunk total (${total}).`
1562
+ );
1563
+ const perChunk = chunkSizes.map(() => ({}));
1564
+ for (const criteria of allCriteria) {
1565
+ const count = totalCounts[criteria] ?? 0;
1566
+ if (count <= 0) {
1567
+ for (let i = 0; i < chunkSizes.length; i++) perChunk[i][criteria] = 0;
1568
+ continue;
1569
+ }
1570
+ let chunks = chunkSizes.map((size) => count * size / total);
1571
+ chunks = chunks.map((x) => Math.floor(x));
1572
+ let assigned = chunks.reduce((a, b) => a + b, 0);
1573
+ let remaining = count - assigned;
1574
+ const remainders = chunks.map((x, i) => ({ i, r: x - Math.floor(x) })).sort((a, b) => b.r - a.r);
1575
+ for (let i = 0; i < chunkSizes.length; i++) {
1576
+ perChunk[i][criteria] = chunks[i];
1577
+ }
1578
+ let idx = 0;
1579
+ while (remaining > 0) {
1580
+ perChunk[remainders[idx].i][criteria] += 1;
1581
+ remaining--;
1582
+ idx = (idx + 1) % remainders.length;
1583
+ }
1584
+ }
1585
+ const chunkTotals = () => perChunk.map((m) => Object.values(m).reduce((s, v) => s + v, 0));
1586
+ let totals = chunkTotals();
1587
+ const getDeficits = () => totals.map((t, i) => chunkSizes[i] - t);
1588
+ let deficits = getDeficits();
1589
+ for (let target = 0; target < chunkSizes.length; target++) {
1590
+ while (deficits[target] > 0) {
1591
+ const src = deficits.findIndex((d) => d < 0);
1592
+ assert6(src !== -1, "No surplus chunk found, but deficits remain.");
1593
+ const crit = allCriteria.find((c) => (perChunk[src][c] ?? 0) > 0);
1594
+ assert6(crit, `No movable criteria found from surplus chunk ${src}.`);
1595
+ perChunk[src][crit] -= 1;
1596
+ perChunk[target][crit] = (perChunk[target][crit] ?? 0) + 1;
1597
+ totals[src] -= 1;
1598
+ totals[target] += 1;
1599
+ deficits[src] += 1;
1600
+ deficits[target] -= 1;
1601
+ }
1602
+ }
1603
+ totals = chunkTotals();
1604
+ for (let i = 0; i < chunkSizes.length; i++) {
1605
+ assert6(
1606
+ totals[i] === chunkSizes[i],
1607
+ `Chunk ${i} size mismatch. Expected ${chunkSizes[i]}, got ${totals[i]}`
1608
+ );
1609
+ }
1610
+ for (const c of allCriteria) {
1611
+ const sum = perChunk.reduce((s, m) => s + (m[c] ?? 0), 0);
1612
+ assert6(sum === (totalCounts[c] ?? 0), `Chunk split mismatch for criteria "${c}"`);
1613
+ }
1614
+ return perChunk;
1615
+ }
1616
+ function createCriteriaSampler(counts, seed) {
1617
+ const rng = new RandomNumberGenerator();
1618
+ rng.setSeed(seed);
1619
+ const keys = Object.keys(counts).filter((k) => (counts[k] ?? 0) > 0);
1620
+ const remaining = Object.fromEntries(keys.map((k) => [k, counts[k] ?? 0]));
1621
+ let remainingTotal = Object.values(remaining).reduce((a, b) => a + b, 0);
1622
+ return () => {
1623
+ if (remainingTotal <= 0) return "N/A";
1624
+ const roll = Math.min(
1625
+ remainingTotal - Number.EPSILON,
1626
+ rng.randomFloat(0, remainingTotal)
1627
+ );
1628
+ let acc = 0;
1629
+ for (const k of keys) {
1630
+ const w = remaining[k] ?? 0;
1631
+ if (w <= 0) continue;
1632
+ acc += w;
1633
+ if (roll < acc) {
1634
+ remaining[k] = w - 1;
1635
+ remainingTotal--;
1636
+ return k;
1637
+ }
1638
+ }
1639
+ remainingTotal--;
1640
+ return keys.find((k) => (remaining[k] ?? 0) > 0) ?? "N/A";
1641
+ };
1642
+ }
1643
+
1644
+ // src/simulation/index.ts
1563
1645
  var completedSimulations = 0;
1564
1646
  var TEMP_FILENAME = "__temp_compiled_src_IGNORE.js";
1565
1647
  var TEMP_FOLDER = "temp_files";
@@ -1570,25 +1652,46 @@ var Simulation = class {
1570
1652
  concurrency;
1571
1653
  debug = false;
1572
1654
  actualSims = 0;
1573
- library;
1574
1655
  wallet;
1575
1656
  recordsWriteStream;
1576
1657
  hasWrittenRecord = false;
1658
+ maxPendingSims;
1659
+ maxHighWaterMark;
1660
+ PATHS = {};
1661
+ // Worker related
1662
+ credits = 0;
1663
+ creditWaiters = [];
1664
+ creditListenerInit = false;
1577
1665
  constructor(opts, gameConfigOpts) {
1578
1666
  this.gameConfig = createGameConfig(gameConfigOpts);
1579
1667
  this.gameConfigOpts = gameConfigOpts;
1580
1668
  this.simRunsAmount = opts.simRunsAmount || {};
1581
1669
  this.concurrency = (opts.concurrency || 6) >= 2 ? opts.concurrency || 6 : 2;
1582
- this.library = /* @__PURE__ */ new Map();
1583
1670
  this.wallet = new Wallet();
1671
+ this.maxPendingSims = Math.max(10, opts.maxPendingSims ?? 250);
1672
+ this.maxHighWaterMark = (opts.maxDiskBuffer ?? 50) * 1024 * 1024;
1584
1673
  const gameModeKeys = Object.keys(this.gameConfig.gameModes);
1585
- assert6(
1674
+ assert7(
1586
1675
  Object.values(this.gameConfig.gameModes).map((m) => gameModeKeys.includes(m.name)).every((v) => v === true),
1587
1676
  "Game mode name must match its key in the gameModes object."
1588
1677
  );
1589
- if (isMainThread) {
1590
- this.preprocessFiles();
1591
- }
1678
+ this.PATHS.base = path.join(this.gameConfig.rootDir, this.gameConfig.outputDir);
1679
+ this.PATHS = {
1680
+ ...this.PATHS,
1681
+ books: (mode) => path.join(this.PATHS.base, `books_${mode}.jsonl`),
1682
+ booksCompressed: (mode) => path.join(this.PATHS.base, "publish_files", `books_${mode}.jsonl.zst`),
1683
+ tempBooks: (mode, i) => path.join(this.PATHS.base, TEMP_FOLDER, `temp_books_${mode}_${i}.jsonl`),
1684
+ lookupTable: (mode) => path.join(this.PATHS.base, `lookUpTable_${mode}.csv`),
1685
+ tempLookupTable: (mode, i) => path.join(this.PATHS.base, TEMP_FOLDER, `temp_lookup_${mode}_${i}.csv`),
1686
+ lookupTableSegmented: (mode) => path.join(this.PATHS.base, `lookUpTableSegmented_${mode}.csv`),
1687
+ tempLookupTableSegmented: (mode, i) => path.join(this.PATHS.base, TEMP_FOLDER, `temp_lookup_segmented_${mode}_${i}.csv`),
1688
+ lookupTablePublish: (mode) => path.join(this.PATHS.base, "publish_files", `lookUpTable_${mode}_0.csv`),
1689
+ tempRecords: (mode) => path.join(this.PATHS.base, TEMP_FOLDER, `temp_records_${mode}.jsonl`),
1690
+ forceRecords: (mode) => path.join(this.PATHS.base, `force_record_${mode}.json`),
1691
+ indexJson: path.join(this.PATHS.base, "publish_files", "index.json"),
1692
+ optimizationFiles: path.join(this.PATHS.base, "optimization_files"),
1693
+ publishFiles: path.join(this.PATHS.base, "publish_files")
1694
+ };
1592
1695
  }
1593
1696
  async runSimulation(opts) {
1594
1697
  const debug = opts.debug || false;
@@ -1600,11 +1703,11 @@ var Simulation = class {
1600
1703
  }
1601
1704
  this.generateReelsetFiles();
1602
1705
  if (isMainThread) {
1706
+ this.preprocessFiles();
1603
1707
  const debugDetails = {};
1604
1708
  for (const mode of gameModesToSimulate) {
1605
1709
  completedSimulations = 0;
1606
1710
  this.wallet = new Wallet();
1607
- this.library = /* @__PURE__ */ new Map();
1608
1711
  this.hasWrittenRecord = false;
1609
1712
  debugDetails[mode] = {};
1610
1713
  console.log(`
@@ -1617,37 +1720,37 @@ Simulating game mode: ${mode}`);
1617
1720
  `Tried to simulate game mode "${mode}", but it's not configured in the game config.`
1618
1721
  );
1619
1722
  }
1620
- const booksPath = path.join(
1621
- this.gameConfig.rootDir,
1622
- this.gameConfig.outputDir,
1623
- `books_${mode}.jsonl`
1624
- );
1625
- const tempRecordsPath = path.join(
1626
- this.gameConfig.rootDir,
1627
- this.gameConfig.outputDir,
1628
- TEMP_FOLDER,
1629
- `temp_records_${mode}.jsonl`
1630
- );
1631
- createDirIfNotExists(
1632
- path.join(this.gameConfig.rootDir, this.gameConfig.outputDir)
1723
+ const booksPath = this.PATHS.books(mode);
1724
+ const tempRecordsPath = this.PATHS.tempRecords(mode);
1725
+ createDirIfNotExists(this.PATHS.base);
1726
+ createDirIfNotExists(path.join(this.PATHS.base, TEMP_FOLDER));
1727
+ this.recordsWriteStream = fs2.createWriteStream(tempRecordsPath, {
1728
+ highWaterMark: this.maxHighWaterMark
1729
+ });
1730
+ const criteriaCounts = ResultSet.getNumberOfSimsForCriteria(this, mode);
1731
+ const totalSims = Object.values(criteriaCounts).reduce((a, b) => a + b, 0);
1732
+ assert7(
1733
+ totalSims === runs,
1734
+ `Criteria mismatch for mode "${mode}". Expected ${runs}, got ${totalSims}`
1633
1735
  );
1634
- createDirIfNotExists(
1635
- path.join(this.gameConfig.rootDir, this.gameConfig.outputDir, TEMP_FOLDER)
1736
+ const chunks = this.getSimRangesForChunks(totalSims, this.concurrency);
1737
+ const chunkSizes = chunks.map(([s, e]) => Math.max(0, e - s + 1));
1738
+ const chunkCriteriaCounts = splitCountsAcrossChunks(criteriaCounts, chunkSizes);
1739
+ await this.spawnWorkersForGameMode({
1740
+ mode,
1741
+ chunks,
1742
+ chunkCriteriaCounts,
1743
+ totalSims
1744
+ });
1745
+ createDirIfNotExists(this.PATHS.optimizationFiles);
1746
+ createDirIfNotExists(this.PATHS.publishFiles);
1747
+ console.log(
1748
+ `Writing final files for game mode "${mode}". This may take a while...`
1636
1749
  );
1637
- this.recordsWriteStream = fs2.createWriteStream(tempRecordsPath);
1638
- const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1639
- await this.spawnWorkersForGameMode({ mode, simNumsToCriteria });
1640
1750
  const finalBookStream = fs2.createWriteStream(booksPath);
1641
- const numSims = Object.keys(simNumsToCriteria).length;
1642
- const chunks = this.getSimRangesForChunks(numSims, this.concurrency);
1643
1751
  let isFirstChunk = true;
1644
1752
  for (let i = 0; i < chunks.length; i++) {
1645
- const tempBookPath = path.join(
1646
- this.gameConfig.rootDir,
1647
- this.gameConfig.outputDir,
1648
- TEMP_FOLDER,
1649
- `temp_books_${mode}_${i}.jsonl`
1650
- );
1753
+ const tempBookPath = this.PATHS.tempBooks(mode, i);
1651
1754
  if (fs2.existsSync(tempBookPath)) {
1652
1755
  if (!isFirstChunk) {
1653
1756
  finalBookStream.write("\n");
@@ -1662,6 +1765,16 @@ Simulating game mode: ${mode}`);
1662
1765
  }
1663
1766
  finalBookStream.end();
1664
1767
  await new Promise((resolve) => finalBookStream.on("finish", resolve));
1768
+ const lutPath = this.PATHS.lookupTable(mode);
1769
+ const lutPathPublish = this.PATHS.lookupTablePublish(mode);
1770
+ const lutSegmentedPath = this.PATHS.lookupTableSegmented(mode);
1771
+ await this.mergeCsv(chunks, lutPath, (i) => `temp_lookup_${mode}_${i}.csv`);
1772
+ fs2.copyFileSync(lutPath, lutPathPublish);
1773
+ await this.mergeCsv(
1774
+ chunks,
1775
+ lutSegmentedPath,
1776
+ (i) => `temp_lookup_segmented_${mode}_${i}.csv`
1777
+ );
1665
1778
  if (this.recordsWriteStream) {
1666
1779
  await new Promise((resolve) => {
1667
1780
  this.recordsWriteStream.end(() => {
@@ -1670,26 +1783,21 @@ Simulating game mode: ${mode}`);
1670
1783
  });
1671
1784
  this.recordsWriteStream = void 0;
1672
1785
  }
1673
- createDirIfNotExists(
1674
- path.join(
1675
- this.gameConfig.rootDir,
1676
- this.gameConfig.outputDir,
1677
- "optimization_files"
1678
- )
1679
- );
1680
- createDirIfNotExists(
1681
- path.join(this.gameConfig.rootDir, this.gameConfig.outputDir, "publish_files")
1682
- );
1683
- console.log(`Writing final files for game mode: ${mode} ...`);
1684
- this.writeLookupTableCSV(mode);
1685
- this.writeLookupTableSegmentedCSV(mode);
1686
- this.writeRecords(mode);
1786
+ await this.writeRecords(mode);
1687
1787
  await this.writeBooksJson(mode);
1688
1788
  this.writeIndexJson();
1689
1789
  console.log(`Mode ${mode} done!`);
1690
- debugDetails[mode].rtp = this.wallet.getCumulativeWins() / (runs * this.gameConfig.gameModes[mode].cost);
1691
- debugDetails[mode].wins = this.wallet.getCumulativeWins();
1692
- debugDetails[mode].winsPerSpinType = this.wallet.getCumulativeWinsPerSpinType();
1790
+ debugDetails[mode].rtp = round(
1791
+ this.wallet.getCumulativeWins() / (runs * this.gameConfig.gameModes[mode].cost),
1792
+ 3
1793
+ );
1794
+ debugDetails[mode].wins = round(this.wallet.getCumulativeWins(), 3);
1795
+ debugDetails[mode].winsPerSpinType = Object.fromEntries(
1796
+ Object.entries(this.wallet.getCumulativeWinsPerSpinType()).map(([k, v]) => [
1797
+ k,
1798
+ round(v, 3)
1799
+ ])
1800
+ );
1693
1801
  console.timeEnd(mode);
1694
1802
  }
1695
1803
  console.log("\n=== SIMULATION SUMMARY ===");
@@ -1699,14 +1807,14 @@ Simulating game mode: ${mode}`);
1699
1807
  let actualSims = 0;
1700
1808
  const criteriaToRetries = {};
1701
1809
  if (!isMainThread) {
1702
- const { mode, simStart, simEnd, index } = workerData;
1703
- const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1810
+ const { mode, simStart, simEnd, index, criteriaCounts } = workerData;
1811
+ const seed = hashStringToInt(mode) + index >>> 0;
1812
+ const nextCriteria = createCriteriaSampler(criteriaCounts, seed);
1704
1813
  for (let simId = simStart; simId <= simEnd; simId++) {
1705
1814
  if (this.debug) desiredSims++;
1706
- const criteria = simNumsToCriteria[simId] || "N/A";
1707
- if (!criteriaToRetries[criteria]) {
1708
- criteriaToRetries[criteria] = 0;
1709
- }
1815
+ const criteria = nextCriteria();
1816
+ if (!criteriaToRetries[criteria]) criteriaToRetries[criteria] = 0;
1817
+ await this.acquireCredit();
1710
1818
  this.runSingleSimulation({ simId, mode, criteria, index });
1711
1819
  if (this.debug) {
1712
1820
  criteriaToRetries[criteria] += this.actualSims - 1;
@@ -1721,30 +1829,30 @@ Simulating game mode: ${mode}`);
1721
1829
  type: "done",
1722
1830
  workerNum: index
1723
1831
  });
1832
+ parentPort?.close();
1724
1833
  }
1725
1834
  }
1726
1835
  /**
1727
1836
  * Runs all simulations for a specific game mode.
1728
1837
  */
1729
1838
  async spawnWorkersForGameMode(opts) {
1730
- const { mode, simNumsToCriteria } = opts;
1731
- const numSims = Object.keys(simNumsToCriteria).length;
1732
- const simRangesPerChunk = this.getSimRangesForChunks(numSims, this.concurrency);
1839
+ const { mode, chunks, chunkCriteriaCounts, totalSims } = opts;
1733
1840
  await Promise.all(
1734
- simRangesPerChunk.map(([simStart, simEnd], index) => {
1841
+ chunks.map(([simStart, simEnd], index) => {
1735
1842
  return this.callWorker({
1736
- basePath: path.join(this.gameConfig.rootDir, this.gameConfig.outputDir),
1843
+ basePath: this.PATHS.base,
1737
1844
  mode,
1738
1845
  simStart,
1739
1846
  simEnd,
1740
1847
  index,
1741
- totalSims: numSims
1848
+ totalSims,
1849
+ criteriaCounts: chunkCriteriaCounts[index]
1742
1850
  });
1743
1851
  })
1744
1852
  );
1745
1853
  }
1746
1854
  async callWorker(opts) {
1747
- const { mode, simEnd, simStart, basePath, index, totalSims } = opts;
1855
+ const { mode, simEnd, simStart, basePath, index, totalSims, criteriaCounts } = opts;
1748
1856
  function logArrowProgress(current, total) {
1749
1857
  const percentage = current / total * 100;
1750
1858
  const progressBarLength = 50;
@@ -1755,6 +1863,11 @@ Simulating game mode: ${mode}`);
1755
1863
  process.stdout.write("\n");
1756
1864
  }
1757
1865
  }
1866
+ const write = async (stream, chunk) => {
1867
+ if (!stream.write(chunk)) {
1868
+ await new Promise((resolve) => stream.once("drain", resolve));
1869
+ }
1870
+ };
1758
1871
  return new Promise((resolve, reject) => {
1759
1872
  const scriptPath = path.join(basePath, TEMP_FILENAME);
1760
1873
  const worker = new Worker(scriptPath, {
@@ -1762,42 +1875,77 @@ Simulating game mode: ${mode}`);
1762
1875
  mode,
1763
1876
  simStart,
1764
1877
  simEnd,
1765
- index
1878
+ index,
1879
+ criteriaCounts
1766
1880
  }
1767
1881
  });
1768
- const tempBookPath = path.join(
1769
- basePath,
1770
- TEMP_FOLDER,
1771
- `temp_books_${mode}_${index}.jsonl`
1772
- );
1773
- const bookStream = fs2.createWriteStream(tempBookPath);
1882
+ worker.postMessage({ type: "credit", amount: this.maxPendingSims });
1883
+ const tempBookPath = this.PATHS.tempBooks(mode, index);
1884
+ const bookStream = fs2.createWriteStream(tempBookPath, {
1885
+ highWaterMark: this.maxHighWaterMark
1886
+ });
1887
+ const tempLookupPath = this.PATHS.tempLookupTable(mode, index);
1888
+ const lookupStream = fs2.createWriteStream(tempLookupPath, {
1889
+ highWaterMark: this.maxHighWaterMark
1890
+ });
1891
+ const tempLookupSegPath = this.PATHS.tempLookupTableSegmented(mode, index);
1892
+ const lookupSegmentedStream = fs2.createWriteStream(tempLookupSegPath, {
1893
+ highWaterMark: this.maxHighWaterMark
1894
+ });
1895
+ let writeChain = Promise.resolve();
1774
1896
  worker.on("message", (msg) => {
1775
1897
  if (msg.type === "log") {
1776
- } else if (msg.type === "complete") {
1777
- completedSimulations++;
1778
- if (completedSimulations % 250 === 0) {
1779
- logArrowProgress(completedSimulations, totalSims);
1780
- }
1781
- const book = Book.fromSerialized(msg.book);
1782
- const bookData = {
1783
- id: book.id,
1784
- payoutMultiplier: book.payout,
1785
- events: book.events
1786
- };
1787
- const prefix = book.id === simStart ? "" : "\n";
1788
- bookStream.write(prefix + JSONL.stringify([bookData]));
1789
- book.events = [];
1790
- this.library.set(book.id, book);
1791
- if (this.recordsWriteStream) {
1792
- for (const record of msg.records) {
1793
- const recordPrefix = this.hasWrittenRecord ? "\n" : "";
1794
- this.recordsWriteStream.write(recordPrefix + JSONL.stringify([record]));
1795
- this.hasWrittenRecord = true;
1898
+ return;
1899
+ }
1900
+ if (msg.type === "complete") {
1901
+ writeChain = writeChain.then(async () => {
1902
+ completedSimulations++;
1903
+ if (completedSimulations % 250 === 0) {
1904
+ logArrowProgress(completedSimulations, totalSims);
1796
1905
  }
1797
- }
1798
- this.wallet.mergeSerialized(msg.wallet);
1799
- } else if (msg.type === "done") {
1800
- resolve(true);
1906
+ const book = msg.book;
1907
+ const bookData = {
1908
+ id: book.id,
1909
+ payoutMultiplier: book.payout,
1910
+ events: book.events
1911
+ };
1912
+ const prefix = book.id === simStart ? "" : "\n";
1913
+ await write(bookStream, prefix + JSONL.stringify([bookData]));
1914
+ await write(lookupStream, `${book.id},1,${Math.round(book.payout)}
1915
+ `);
1916
+ await write(
1917
+ lookupSegmentedStream,
1918
+ `${book.id},${book.criteria},${book.basegameWins},${book.freespinsWins}
1919
+ `
1920
+ );
1921
+ if (this.recordsWriteStream) {
1922
+ for (const record of msg.records) {
1923
+ const recordPrefix = this.hasWrittenRecord ? "\n" : "";
1924
+ await write(
1925
+ this.recordsWriteStream,
1926
+ recordPrefix + JSONL.stringify([record])
1927
+ );
1928
+ this.hasWrittenRecord = true;
1929
+ }
1930
+ }
1931
+ this.wallet.mergeSerialized(msg.wallet);
1932
+ worker.postMessage({ type: "credit", amount: 1 });
1933
+ }).catch(reject);
1934
+ return;
1935
+ }
1936
+ if (msg.type === "done") {
1937
+ writeChain.then(async () => {
1938
+ bookStream.end();
1939
+ lookupStream.end();
1940
+ lookupSegmentedStream.end();
1941
+ await Promise.all([
1942
+ new Promise((r) => bookStream.on("finish", () => r())),
1943
+ new Promise((r) => lookupStream.on("finish", () => r())),
1944
+ new Promise((r) => lookupSegmentedStream.on("finish", () => r()))
1945
+ ]);
1946
+ resolve(true);
1947
+ }).catch(reject);
1948
+ return;
1801
1949
  }
1802
1950
  });
1803
1951
  worker.on("error", (error) => {
@@ -1858,6 +2006,31 @@ ${error.stack}
1858
2006
  records: ctx.services.data._getRecords()
1859
2007
  });
1860
2008
  }
2009
+ initCreditListener() {
2010
+ if (this.creditListenerInit) return;
2011
+ this.creditListenerInit = true;
2012
+ parentPort?.on("message", (msg) => {
2013
+ if (msg?.type !== "credit") return;
2014
+ const amount = Number(msg?.amount ?? 0);
2015
+ if (!Number.isFinite(amount) || amount <= 0) return;
2016
+ this.credits += amount;
2017
+ while (this.credits > 0 && this.creditWaiters.length > 0) {
2018
+ this.credits -= 1;
2019
+ const resolve = this.creditWaiters.shift();
2020
+ resolve();
2021
+ }
2022
+ });
2023
+ }
2024
+ acquireCredit() {
2025
+ this.initCreditListener();
2026
+ if (this.credits > 0) {
2027
+ this.credits -= 1;
2028
+ return Promise.resolve();
2029
+ }
2030
+ return new Promise((resolve) => {
2031
+ this.creditWaiters.push(resolve);
2032
+ });
2033
+ }
1861
2034
  /**
1862
2035
  * If a simulation does not meet the required criteria, reset the state to run it again.
1863
2036
  *
@@ -1900,64 +2073,9 @@ ${error.stack}
1900
2073
  handleGameFlow(ctx) {
1901
2074
  this.gameConfig.hooks.onHandleGameFlow(ctx);
1902
2075
  }
1903
- /**
1904
- * Creates a CSV file in the format "simulationId,weight,payout".
1905
- *
1906
- * `weight` defaults to 1.
1907
- */
1908
- writeLookupTableCSV(gameMode) {
1909
- const rows = [];
1910
- for (const [bookId, book] of this.library.entries()) {
1911
- rows.push(`${book.id},1,${Math.round(book.payout)}`);
1912
- }
1913
- rows.sort((a, b) => Number(a.split(",")[0]) - Number(b.split(",")[0]));
1914
- let outputFileName = `lookUpTable_${gameMode}.csv`;
1915
- let outputFilePath = path.join(
1916
- this.gameConfig.rootDir,
1917
- this.gameConfig.outputDir,
1918
- outputFileName
1919
- );
1920
- writeFile(outputFilePath, rows.join("\n"));
1921
- outputFileName = `lookUpTable_${gameMode}_0.csv`;
1922
- outputFilePath = path.join(
1923
- this.gameConfig.rootDir,
1924
- this.gameConfig.outputDir,
1925
- "publish_files",
1926
- outputFileName
1927
- );
1928
- writeFile(outputFilePath, rows.join("\n"));
1929
- return outputFilePath;
1930
- }
1931
- /**
1932
- * Creates a CSV file in the format "simulationId,criteria,payoutBase,payoutFreespins".
1933
- */
1934
- writeLookupTableSegmentedCSV(gameMode) {
1935
- const rows = [];
1936
- for (const [bookId, book] of this.library.entries()) {
1937
- rows.push(`${book.id},${book.criteria},${book.basegameWins},${book.freespinsWins}`);
1938
- }
1939
- rows.sort((a, b) => Number(a.split(",")[0]) - Number(b.split(",")[0]));
1940
- const outputFileName = `lookUpTableSegmented_${gameMode}.csv`;
1941
- const outputFilePath = path.join(
1942
- this.gameConfig.rootDir,
1943
- this.gameConfig.outputDir,
1944
- outputFileName
1945
- );
1946
- writeFile(outputFilePath, rows.join("\n"));
1947
- return outputFilePath;
1948
- }
1949
2076
  async writeRecords(mode) {
1950
- const tempRecordsPath = path.join(
1951
- this.gameConfig.rootDir,
1952
- this.gameConfig.outputDir,
1953
- TEMP_FOLDER,
1954
- `temp_records_${mode}.jsonl`
1955
- );
1956
- const forceRecordsPath = path.join(
1957
- this.gameConfig.rootDir,
1958
- this.gameConfig.outputDir,
1959
- `force_record_${mode}.json`
1960
- );
2077
+ const tempRecordsPath = this.PATHS.tempRecords(mode);
2078
+ const forceRecordsPath = this.PATHS.forceRecords(mode);
1961
2079
  const aggregatedRecords = /* @__PURE__ */ new Map();
1962
2080
  if (fs2.existsSync(tempRecordsPath)) {
1963
2081
  const fileStream = fs2.createReadStream(tempRecordsPath);
@@ -2003,15 +2121,10 @@ ${error.stack}
2003
2121
  fs2.rmSync(tempRecordsPath, { force: true });
2004
2122
  }
2005
2123
  writeIndexJson() {
2006
- const outputFilePath = path.join(
2007
- this.gameConfig.rootDir,
2008
- this.gameConfig.outputDir,
2009
- "publish_files",
2010
- "index.json"
2011
- );
2124
+ const outputFilePath = this.PATHS.indexJson;
2012
2125
  const modes = Object.keys(this.simRunsAmount).map((id) => {
2013
2126
  const mode = this.gameConfig.gameModes[id];
2014
- assert6(mode, `Game mode "${id}" not found in game config.`);
2127
+ assert7(mode, `Game mode "${id}" not found in game config.`);
2015
2128
  return {
2016
2129
  name: mode.name,
2017
2130
  cost: mode.cost,
@@ -2022,17 +2135,8 @@ ${error.stack}
2022
2135
  writeFile(outputFilePath, JSON.stringify({ modes }, null, 2));
2023
2136
  }
2024
2137
  async writeBooksJson(gameMode) {
2025
- const outputFilePath = path.join(
2026
- this.gameConfig.rootDir,
2027
- this.gameConfig.outputDir,
2028
- `books_${gameMode}.jsonl`
2029
- );
2030
- const compressedFilePath = path.join(
2031
- this.gameConfig.rootDir,
2032
- this.gameConfig.outputDir,
2033
- "publish_files",
2034
- `books_${gameMode}.jsonl.zst`
2035
- );
2138
+ const outputFilePath = this.PATHS.books(gameMode);
2139
+ const compressedFilePath = this.PATHS.booksCompressed(gameMode);
2036
2140
  fs2.rmSync(compressedFilePath, { force: true });
2037
2141
  if (fs2.existsSync(outputFilePath)) {
2038
2142
  await pipeline(
@@ -2065,11 +2169,12 @@ ${error.stack}
2065
2169
  });
2066
2170
  }
2067
2171
  getSimRangesForChunks(total, chunks) {
2068
- const base = Math.floor(total / chunks);
2069
- const remainder = total % chunks;
2172
+ const realChunks = Math.min(chunks, Math.max(total, 1));
2173
+ const base = Math.floor(total / realChunks);
2174
+ const remainder = total % realChunks;
2070
2175
  const result = [];
2071
2176
  let current = 1;
2072
- for (let i = 0; i < chunks; i++) {
2177
+ for (let i = 0; i < realChunks; i++) {
2073
2178
  const size = base + (i < remainder ? 1 : 0);
2074
2179
  const start = current;
2075
2180
  const end = current + size - 1;
@@ -2095,6 +2200,22 @@ ${error.stack}
2095
2200
  }
2096
2201
  }
2097
2202
  }
2203
+ async mergeCsv(chunks, outPath, tempName) {
2204
+ fs2.rmSync(outPath, { force: true });
2205
+ const out = fs2.createWriteStream(outPath);
2206
+ let wroteAny = false;
2207
+ for (let i = 0; i < chunks.length; i++) {
2208
+ const p = path.join(this.PATHS.base, TEMP_FOLDER, tempName(i));
2209
+ if (!fs2.existsSync(p)) continue;
2210
+ if (wroteAny) out.write("");
2211
+ const rs = fs2.createReadStream(p);
2212
+ for await (const buf of rs) out.write(buf);
2213
+ fs2.rmSync(p);
2214
+ wroteAny = true;
2215
+ }
2216
+ out.end();
2217
+ await new Promise((resolve) => out.on("finish", resolve));
2218
+ }
2098
2219
  /**
2099
2220
  * Confirms all pending records and adds them to the main records list.
2100
2221
  */
@@ -2130,7 +2251,7 @@ ${error.stack}
2130
2251
  // src/analysis/index.ts
2131
2252
  import fs3 from "fs";
2132
2253
  import path2 from "path";
2133
- import assert7 from "assert";
2254
+ import assert8 from "assert";
2134
2255
 
2135
2256
  // src/analysis/utils.ts
2136
2257
  function parseLookupTable(content) {
@@ -2307,7 +2428,7 @@ var Analysis = class {
2307
2428
  booksJsonlCompressed
2308
2429
  };
2309
2430
  for (const p of Object.values(paths[modeStr])) {
2310
- assert7(
2431
+ assert8(
2311
2432
  fs3.existsSync(p),
2312
2433
  `File "${p}" does not exist. Run optimization to auto-create it.`
2313
2434
  );
@@ -2429,14 +2550,14 @@ var Analysis = class {
2429
2550
  }
2430
2551
  getGameModeConfig(mode) {
2431
2552
  const config = this.gameConfig.gameModes[mode];
2432
- assert7(config, `Game mode "${mode}" not found in game config`);
2553
+ assert8(config, `Game mode "${mode}" not found in game config`);
2433
2554
  return config;
2434
2555
  }
2435
2556
  };
2436
2557
 
2437
2558
  // src/optimizer/index.ts
2438
2559
  import path5 from "path";
2439
- import assert9 from "assert";
2560
+ import assert10 from "assert";
2440
2561
  import { spawn } from "child_process";
2441
2562
  import { isMainThread as isMainThread3 } from "worker_threads";
2442
2563
 
@@ -2537,7 +2658,7 @@ function makeSetupFile(optimizer, gameMode) {
2537
2658
  }
2538
2659
 
2539
2660
  // src/optimizer/OptimizationConditions.ts
2540
- import assert8 from "assert";
2661
+ import assert9 from "assert";
2541
2662
  var OptimizationConditions = class {
2542
2663
  rtp;
2543
2664
  avgWin;
@@ -2548,14 +2669,14 @@ var OptimizationConditions = class {
2548
2669
  constructor(opts) {
2549
2670
  let { rtp, avgWin, hitRate, searchConditions, priority } = opts;
2550
2671
  if (rtp == void 0 || rtp === "x") {
2551
- assert8(avgWin !== void 0 && hitRate !== void 0, "If RTP is not specified, hit-rate (hr) and average win amount (av_win) must be given.");
2672
+ assert9(avgWin !== void 0 && hitRate !== void 0, "If RTP is not specified, hit-rate (hr) and average win amount (av_win) must be given.");
2552
2673
  rtp = Math.round(avgWin / Number(hitRate) * 1e5) / 1e5;
2553
2674
  }
2554
2675
  let noneCount = 0;
2555
2676
  for (const val of [rtp, avgWin, hitRate]) {
2556
2677
  if (val === void 0) noneCount++;
2557
2678
  }
2558
- assert8(noneCount <= 1, "Invalid combination of optimization conditions.");
2679
+ assert9(noneCount <= 1, "Invalid combination of optimization conditions.");
2559
2680
  this.searchRange = [-1, -1];
2560
2681
  this.forceSearch = {};
2561
2682
  if (typeof searchConditions === "number") {
@@ -2683,7 +2804,7 @@ var Optimizer = class {
2683
2804
  }
2684
2805
  gameModeRtp = Math.round(gameModeRtp * 1e3) / 1e3;
2685
2806
  paramRtp = Math.round(paramRtp * 1e3) / 1e3;
2686
- assert9(
2807
+ assert10(
2687
2808
  gameModeRtp === paramRtp,
2688
2809
  `Sum of all RTP conditions (${paramRtp}) does not match the game mode RTP (${gameModeRtp}) in game mode "${k}".`
2689
2810
  );
@@ -2822,7 +2943,7 @@ var defineSymbols = (symbols) => symbols;
2822
2943
  var defineGameModes = (gameModes) => gameModes;
2823
2944
 
2824
2945
  // src/game-mode/index.ts
2825
- import assert10 from "assert";
2946
+ import assert11 from "assert";
2826
2947
  var GameMode = class {
2827
2948
  name;
2828
2949
  _reelsAmount;
@@ -2845,12 +2966,12 @@ var GameMode = class {
2845
2966
  this.reelSets = opts.reelSets;
2846
2967
  this.resultSets = opts.resultSets;
2847
2968
  this.isBonusBuy = opts.isBonusBuy;
2848
- assert10(this.rtp >= 0.9 && this.rtp <= 0.99, "RTP must be between 0.9 and 0.99");
2849
- assert10(
2969
+ assert11(this.rtp >= 0.9 && this.rtp <= 0.99, "RTP must be between 0.9 and 0.99");
2970
+ assert11(
2850
2971
  this.symbolsPerReel.length === this.reelsAmount,
2851
2972
  "symbolsPerReel length must match reelsAmount."
2852
2973
  );
2853
- assert10(this.reelSets.length > 0, "GameMode must have at least one ReelSet defined.");
2974
+ assert11(this.reelSets.length > 0, "GameMode must have at least one ReelSet defined.");
2854
2975
  }
2855
2976
  /**
2856
2977
  * Intended for internal use only.
@@ -2863,7 +2984,7 @@ var GameMode = class {
2863
2984
  * Intended for internal use only.
2864
2985
  */
2865
2986
  _setSymbolsPerReel(symbolsPerReel) {
2866
- assert10(
2987
+ assert11(
2867
2988
  symbolsPerReel.length === this._reelsAmount,
2868
2989
  "symbolsPerReel length must match reelsAmount."
2869
2990
  );
@@ -2929,7 +3050,7 @@ var WinType = class {
2929
3050
  };
2930
3051
 
2931
3052
  // src/win-types/LinesWinType.ts
2932
- import assert11 from "assert";
3053
+ import assert12 from "assert";
2933
3054
  var LinesWinType = class extends WinType {
2934
3055
  lines;
2935
3056
  constructor(opts) {
@@ -2983,8 +3104,8 @@ var LinesWinType = class extends WinType {
2983
3104
  if (!baseSymbol) {
2984
3105
  baseSymbol = thisSymbol;
2985
3106
  }
2986
- assert11(baseSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
2987
- assert11(thisSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3107
+ assert12(baseSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3108
+ assert12(thisSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
2988
3109
  if (potentialWinLine.length == 0) {
2989
3110
  if (this.isWild(thisSymbol)) {
2990
3111
  potentialWildLine.push({ reel: ridx, row: sidx, symbol: thisSymbol });
@@ -3653,7 +3774,7 @@ var GeneratedReelSet = class extends ReelSet {
3653
3774
  };
3654
3775
 
3655
3776
  // src/reel-set/StaticReelSet.ts
3656
- import assert12 from "assert";
3777
+ import assert13 from "assert";
3657
3778
  var StaticReelSet = class extends ReelSet {
3658
3779
  reels;
3659
3780
  csvPath;
@@ -3663,7 +3784,7 @@ var StaticReelSet = class extends ReelSet {
3663
3784
  this.reels = [];
3664
3785
  this._strReels = opts.reels || [];
3665
3786
  this.csvPath = opts.csvPath || "";
3666
- assert12(
3787
+ assert13(
3667
3788
  opts.reels || opts.csvPath,
3668
3789
  `Either 'reels' or 'csvPath' must be provided for StaticReelSet ${this.id}`
3669
3790
  );