@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.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 = [];
@@ -1309,7 +1301,11 @@ var Book = class _Book {
1309
1301
  */
1310
1302
  addEvent(event) {
1311
1303
  const index = this.events.length + 1;
1312
- this.events.push({ index, ...event });
1304
+ this.events.push({
1305
+ index,
1306
+ type: event.type,
1307
+ data: copy(event.data)
1308
+ });
1313
1309
  }
1314
1310
  /**
1315
1311
  * Intended for internal use only.
@@ -1324,17 +1320,6 @@ var Book = class _Book {
1324
1320
  freespinsWins: this.freespinsWins
1325
1321
  };
1326
1322
  }
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
1323
  };
1339
1324
 
1340
1325
  // src/wallet/index.ts
@@ -1560,6 +1545,107 @@ var Wallet = class {
1560
1545
 
1561
1546
  // src/simulation/index.ts
1562
1547
  import { pipeline } from "stream/promises";
1548
+
1549
+ // src/simulation/utils.ts
1550
+ import assert6 from "assert";
1551
+ function hashStringToInt(input) {
1552
+ let h = 2166136261;
1553
+ for (let i = 0; i < input.length; i++) {
1554
+ h ^= input.charCodeAt(i);
1555
+ h = Math.imul(h, 16777619);
1556
+ }
1557
+ return h >>> 0;
1558
+ }
1559
+ function splitCountsAcrossChunks(totalCounts, chunkSizes) {
1560
+ const total = chunkSizes.reduce((a, b) => a + b, 0);
1561
+ const allCriteria = Object.keys(totalCounts);
1562
+ const totalCountsSum = allCriteria.reduce((s, c) => s + (totalCounts[c] ?? 0), 0);
1563
+ assert6(
1564
+ totalCountsSum === total,
1565
+ `Counts (${totalCountsSum}) must match chunk total (${total}).`
1566
+ );
1567
+ const perChunk = chunkSizes.map(() => ({}));
1568
+ for (const criteria of allCriteria) {
1569
+ const count = totalCounts[criteria] ?? 0;
1570
+ if (count <= 0) {
1571
+ for (let i = 0; i < chunkSizes.length; i++) perChunk[i][criteria] = 0;
1572
+ continue;
1573
+ }
1574
+ let chunks = chunkSizes.map((size) => count * size / total);
1575
+ chunks = chunks.map((x) => Math.floor(x));
1576
+ let assigned = chunks.reduce((a, b) => a + b, 0);
1577
+ let remaining = count - assigned;
1578
+ const remainders = chunks.map((x, i) => ({ i, r: x - Math.floor(x) })).sort((a, b) => b.r - a.r);
1579
+ for (let i = 0; i < chunkSizes.length; i++) {
1580
+ perChunk[i][criteria] = chunks[i];
1581
+ }
1582
+ let idx = 0;
1583
+ while (remaining > 0) {
1584
+ perChunk[remainders[idx].i][criteria] += 1;
1585
+ remaining--;
1586
+ idx = (idx + 1) % remainders.length;
1587
+ }
1588
+ }
1589
+ const chunkTotals = () => perChunk.map((m) => Object.values(m).reduce((s, v) => s + v, 0));
1590
+ let totals = chunkTotals();
1591
+ const getDeficits = () => totals.map((t, i) => chunkSizes[i] - t);
1592
+ let deficits = getDeficits();
1593
+ for (let target = 0; target < chunkSizes.length; target++) {
1594
+ while (deficits[target] > 0) {
1595
+ const src = deficits.findIndex((d) => d < 0);
1596
+ assert6(src !== -1, "No surplus chunk found, but deficits remain.");
1597
+ const crit = allCriteria.find((c) => (perChunk[src][c] ?? 0) > 0);
1598
+ assert6(crit, `No movable criteria found from surplus chunk ${src}.`);
1599
+ perChunk[src][crit] -= 1;
1600
+ perChunk[target][crit] = (perChunk[target][crit] ?? 0) + 1;
1601
+ totals[src] -= 1;
1602
+ totals[target] += 1;
1603
+ deficits[src] += 1;
1604
+ deficits[target] -= 1;
1605
+ }
1606
+ }
1607
+ totals = chunkTotals();
1608
+ for (let i = 0; i < chunkSizes.length; i++) {
1609
+ assert6(
1610
+ totals[i] === chunkSizes[i],
1611
+ `Chunk ${i} size mismatch. Expected ${chunkSizes[i]}, got ${totals[i]}`
1612
+ );
1613
+ }
1614
+ for (const c of allCriteria) {
1615
+ const sum = perChunk.reduce((s, m) => s + (m[c] ?? 0), 0);
1616
+ assert6(sum === (totalCounts[c] ?? 0), `Chunk split mismatch for criteria "${c}"`);
1617
+ }
1618
+ return perChunk;
1619
+ }
1620
+ function createCriteriaSampler(counts, seed) {
1621
+ const rng = new RandomNumberGenerator();
1622
+ rng.setSeed(seed);
1623
+ const keys = Object.keys(counts).filter((k) => (counts[k] ?? 0) > 0);
1624
+ const remaining = Object.fromEntries(keys.map((k) => [k, counts[k] ?? 0]));
1625
+ let remainingTotal = Object.values(remaining).reduce((a, b) => a + b, 0);
1626
+ return () => {
1627
+ if (remainingTotal <= 0) return "N/A";
1628
+ const roll = Math.min(
1629
+ remainingTotal - Number.EPSILON,
1630
+ rng.randomFloat(0, remainingTotal)
1631
+ );
1632
+ let acc = 0;
1633
+ for (const k of keys) {
1634
+ const w = remaining[k] ?? 0;
1635
+ if (w <= 0) continue;
1636
+ acc += w;
1637
+ if (roll < acc) {
1638
+ remaining[k] = w - 1;
1639
+ remainingTotal--;
1640
+ return k;
1641
+ }
1642
+ }
1643
+ remainingTotal--;
1644
+ return keys.find((k) => (remaining[k] ?? 0) > 0) ?? "N/A";
1645
+ };
1646
+ }
1647
+
1648
+ // src/simulation/index.ts
1563
1649
  var completedSimulations = 0;
1564
1650
  var TEMP_FILENAME = "__temp_compiled_src_IGNORE.js";
1565
1651
  var TEMP_FOLDER = "temp_files";
@@ -1570,25 +1656,46 @@ var Simulation = class {
1570
1656
  concurrency;
1571
1657
  debug = false;
1572
1658
  actualSims = 0;
1573
- library;
1574
1659
  wallet;
1575
1660
  recordsWriteStream;
1576
1661
  hasWrittenRecord = false;
1662
+ maxPendingSims;
1663
+ maxHighWaterMark;
1664
+ PATHS = {};
1665
+ // Worker related
1666
+ credits = 0;
1667
+ creditWaiters = [];
1668
+ creditListenerInit = false;
1577
1669
  constructor(opts, gameConfigOpts) {
1578
1670
  this.gameConfig = createGameConfig(gameConfigOpts);
1579
1671
  this.gameConfigOpts = gameConfigOpts;
1580
1672
  this.simRunsAmount = opts.simRunsAmount || {};
1581
1673
  this.concurrency = (opts.concurrency || 6) >= 2 ? opts.concurrency || 6 : 2;
1582
- this.library = /* @__PURE__ */ new Map();
1583
1674
  this.wallet = new Wallet();
1675
+ this.maxPendingSims = Math.max(10, opts.maxPendingSims ?? 250);
1676
+ this.maxHighWaterMark = (opts.maxDiskBuffer ?? 50) * 1024 * 1024;
1584
1677
  const gameModeKeys = Object.keys(this.gameConfig.gameModes);
1585
- assert6(
1678
+ assert7(
1586
1679
  Object.values(this.gameConfig.gameModes).map((m) => gameModeKeys.includes(m.name)).every((v) => v === true),
1587
1680
  "Game mode name must match its key in the gameModes object."
1588
1681
  );
1589
- if (isMainThread) {
1590
- this.preprocessFiles();
1591
- }
1682
+ this.PATHS.base = path.join(this.gameConfig.rootDir, this.gameConfig.outputDir);
1683
+ this.PATHS = {
1684
+ ...this.PATHS,
1685
+ books: (mode) => path.join(this.PATHS.base, `books_${mode}.jsonl`),
1686
+ booksCompressed: (mode) => path.join(this.PATHS.base, "publish_files", `books_${mode}.jsonl.zst`),
1687
+ tempBooks: (mode, i) => path.join(this.PATHS.base, TEMP_FOLDER, `temp_books_${mode}_${i}.jsonl`),
1688
+ lookupTable: (mode) => path.join(this.PATHS.base, `lookUpTable_${mode}.csv`),
1689
+ tempLookupTable: (mode, i) => path.join(this.PATHS.base, TEMP_FOLDER, `temp_lookup_${mode}_${i}.csv`),
1690
+ lookupTableSegmented: (mode) => path.join(this.PATHS.base, `lookUpTableSegmented_${mode}.csv`),
1691
+ tempLookupTableSegmented: (mode, i) => path.join(this.PATHS.base, TEMP_FOLDER, `temp_lookup_segmented_${mode}_${i}.csv`),
1692
+ lookupTablePublish: (mode) => path.join(this.PATHS.base, "publish_files", `lookUpTable_${mode}_0.csv`),
1693
+ tempRecords: (mode) => path.join(this.PATHS.base, TEMP_FOLDER, `temp_records_${mode}.jsonl`),
1694
+ forceRecords: (mode) => path.join(this.PATHS.base, `force_record_${mode}.json`),
1695
+ indexJson: path.join(this.PATHS.base, "publish_files", "index.json"),
1696
+ optimizationFiles: path.join(this.PATHS.base, "optimization_files"),
1697
+ publishFiles: path.join(this.PATHS.base, "publish_files")
1698
+ };
1592
1699
  }
1593
1700
  async runSimulation(opts) {
1594
1701
  const debug = opts.debug || false;
@@ -1600,11 +1707,11 @@ var Simulation = class {
1600
1707
  }
1601
1708
  this.generateReelsetFiles();
1602
1709
  if (isMainThread) {
1710
+ this.preprocessFiles();
1603
1711
  const debugDetails = {};
1604
1712
  for (const mode of gameModesToSimulate) {
1605
1713
  completedSimulations = 0;
1606
1714
  this.wallet = new Wallet();
1607
- this.library = /* @__PURE__ */ new Map();
1608
1715
  this.hasWrittenRecord = false;
1609
1716
  debugDetails[mode] = {};
1610
1717
  console.log(`
@@ -1617,37 +1724,37 @@ Simulating game mode: ${mode}`);
1617
1724
  `Tried to simulate game mode "${mode}", but it's not configured in the game config.`
1618
1725
  );
1619
1726
  }
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)
1727
+ const booksPath = this.PATHS.books(mode);
1728
+ const tempRecordsPath = this.PATHS.tempRecords(mode);
1729
+ createDirIfNotExists(this.PATHS.base);
1730
+ createDirIfNotExists(path.join(this.PATHS.base, TEMP_FOLDER));
1731
+ this.recordsWriteStream = fs2.createWriteStream(tempRecordsPath, {
1732
+ highWaterMark: this.maxHighWaterMark
1733
+ }).setMaxListeners(30);
1734
+ const criteriaCounts = ResultSet.getNumberOfSimsForCriteria(this, mode);
1735
+ const totalSims = Object.values(criteriaCounts).reduce((a, b) => a + b, 0);
1736
+ assert7(
1737
+ totalSims === runs,
1738
+ `Criteria mismatch for mode "${mode}". Expected ${runs}, got ${totalSims}`
1633
1739
  );
1634
- createDirIfNotExists(
1635
- path.join(this.gameConfig.rootDir, this.gameConfig.outputDir, TEMP_FOLDER)
1740
+ const chunks = this.getSimRangesForChunks(totalSims, this.concurrency);
1741
+ const chunkSizes = chunks.map(([s, e]) => Math.max(0, e - s + 1));
1742
+ const chunkCriteriaCounts = splitCountsAcrossChunks(criteriaCounts, chunkSizes);
1743
+ await this.spawnWorkersForGameMode({
1744
+ mode,
1745
+ chunks,
1746
+ chunkCriteriaCounts,
1747
+ totalSims
1748
+ });
1749
+ createDirIfNotExists(this.PATHS.optimizationFiles);
1750
+ createDirIfNotExists(this.PATHS.publishFiles);
1751
+ console.log(
1752
+ `Writing final files for game mode "${mode}". This may take a while...`
1636
1753
  );
1637
- this.recordsWriteStream = fs2.createWriteStream(tempRecordsPath);
1638
- const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1639
- await this.spawnWorkersForGameMode({ mode, simNumsToCriteria });
1640
1754
  const finalBookStream = fs2.createWriteStream(booksPath);
1641
- const numSims = Object.keys(simNumsToCriteria).length;
1642
- const chunks = this.getSimRangesForChunks(numSims, this.concurrency);
1643
1755
  let isFirstChunk = true;
1644
1756
  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
- );
1757
+ const tempBookPath = this.PATHS.tempBooks(mode, i);
1651
1758
  if (fs2.existsSync(tempBookPath)) {
1652
1759
  if (!isFirstChunk) {
1653
1760
  finalBookStream.write("\n");
@@ -1662,6 +1769,16 @@ Simulating game mode: ${mode}`);
1662
1769
  }
1663
1770
  finalBookStream.end();
1664
1771
  await new Promise((resolve) => finalBookStream.on("finish", resolve));
1772
+ const lutPath = this.PATHS.lookupTable(mode);
1773
+ const lutPathPublish = this.PATHS.lookupTablePublish(mode);
1774
+ const lutSegmentedPath = this.PATHS.lookupTableSegmented(mode);
1775
+ await this.mergeCsv(chunks, lutPath, (i) => `temp_lookup_${mode}_${i}.csv`);
1776
+ fs2.copyFileSync(lutPath, lutPathPublish);
1777
+ await this.mergeCsv(
1778
+ chunks,
1779
+ lutSegmentedPath,
1780
+ (i) => `temp_lookup_segmented_${mode}_${i}.csv`
1781
+ );
1665
1782
  if (this.recordsWriteStream) {
1666
1783
  await new Promise((resolve) => {
1667
1784
  this.recordsWriteStream.end(() => {
@@ -1670,26 +1787,21 @@ Simulating game mode: ${mode}`);
1670
1787
  });
1671
1788
  this.recordsWriteStream = void 0;
1672
1789
  }
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);
1790
+ await this.writeRecords(mode);
1687
1791
  await this.writeBooksJson(mode);
1688
1792
  this.writeIndexJson();
1689
1793
  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();
1794
+ debugDetails[mode].rtp = round(
1795
+ this.wallet.getCumulativeWins() / (runs * this.gameConfig.gameModes[mode].cost),
1796
+ 3
1797
+ );
1798
+ debugDetails[mode].wins = round(this.wallet.getCumulativeWins(), 3);
1799
+ debugDetails[mode].winsPerSpinType = Object.fromEntries(
1800
+ Object.entries(this.wallet.getCumulativeWinsPerSpinType()).map(([k, v]) => [
1801
+ k,
1802
+ round(v, 3)
1803
+ ])
1804
+ );
1693
1805
  console.timeEnd(mode);
1694
1806
  }
1695
1807
  console.log("\n=== SIMULATION SUMMARY ===");
@@ -1699,14 +1811,14 @@ Simulating game mode: ${mode}`);
1699
1811
  let actualSims = 0;
1700
1812
  const criteriaToRetries = {};
1701
1813
  if (!isMainThread) {
1702
- const { mode, simStart, simEnd, index } = workerData;
1703
- const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1814
+ const { mode, simStart, simEnd, index, criteriaCounts } = workerData;
1815
+ const seed = hashStringToInt(mode) + index >>> 0;
1816
+ const nextCriteria = createCriteriaSampler(criteriaCounts, seed);
1704
1817
  for (let simId = simStart; simId <= simEnd; simId++) {
1705
1818
  if (this.debug) desiredSims++;
1706
- const criteria = simNumsToCriteria[simId] || "N/A";
1707
- if (!criteriaToRetries[criteria]) {
1708
- criteriaToRetries[criteria] = 0;
1709
- }
1819
+ const criteria = nextCriteria();
1820
+ if (!criteriaToRetries[criteria]) criteriaToRetries[criteria] = 0;
1821
+ await this.acquireCredit();
1710
1822
  this.runSingleSimulation({ simId, mode, criteria, index });
1711
1823
  if (this.debug) {
1712
1824
  criteriaToRetries[criteria] += this.actualSims - 1;
@@ -1721,30 +1833,31 @@ Simulating game mode: ${mode}`);
1721
1833
  type: "done",
1722
1834
  workerNum: index
1723
1835
  });
1836
+ parentPort?.removeAllListeners();
1837
+ parentPort?.close();
1724
1838
  }
1725
1839
  }
1726
1840
  /**
1727
1841
  * Runs all simulations for a specific game mode.
1728
1842
  */
1729
1843
  async spawnWorkersForGameMode(opts) {
1730
- const { mode, simNumsToCriteria } = opts;
1731
- const numSims = Object.keys(simNumsToCriteria).length;
1732
- const simRangesPerChunk = this.getSimRangesForChunks(numSims, this.concurrency);
1844
+ const { mode, chunks, chunkCriteriaCounts, totalSims } = opts;
1733
1845
  await Promise.all(
1734
- simRangesPerChunk.map(([simStart, simEnd], index) => {
1846
+ chunks.map(([simStart, simEnd], index) => {
1735
1847
  return this.callWorker({
1736
- basePath: path.join(this.gameConfig.rootDir, this.gameConfig.outputDir),
1848
+ basePath: this.PATHS.base,
1737
1849
  mode,
1738
1850
  simStart,
1739
1851
  simEnd,
1740
1852
  index,
1741
- totalSims: numSims
1853
+ totalSims,
1854
+ criteriaCounts: chunkCriteriaCounts[index]
1742
1855
  });
1743
1856
  })
1744
1857
  );
1745
1858
  }
1746
1859
  async callWorker(opts) {
1747
- const { mode, simEnd, simStart, basePath, index, totalSims } = opts;
1860
+ const { mode, simEnd, simStart, basePath, index, totalSims, criteriaCounts } = opts;
1748
1861
  function logArrowProgress(current, total) {
1749
1862
  const percentage = current / total * 100;
1750
1863
  const progressBarLength = 50;
@@ -1755,6 +1868,11 @@ Simulating game mode: ${mode}`);
1755
1868
  process.stdout.write("\n");
1756
1869
  }
1757
1870
  }
1871
+ const write = async (stream, chunk) => {
1872
+ if (!stream.write(chunk)) {
1873
+ await new Promise((resolve) => stream.once("drain", resolve));
1874
+ }
1875
+ };
1758
1876
  return new Promise((resolve, reject) => {
1759
1877
  const scriptPath = path.join(basePath, TEMP_FILENAME);
1760
1878
  const worker = new Worker(scriptPath, {
@@ -1762,42 +1880,77 @@ Simulating game mode: ${mode}`);
1762
1880
  mode,
1763
1881
  simStart,
1764
1882
  simEnd,
1765
- index
1883
+ index,
1884
+ criteriaCounts
1766
1885
  }
1767
1886
  });
1768
- const tempBookPath = path.join(
1769
- basePath,
1770
- TEMP_FOLDER,
1771
- `temp_books_${mode}_${index}.jsonl`
1772
- );
1773
- const bookStream = fs2.createWriteStream(tempBookPath);
1887
+ worker.postMessage({ type: "credit", amount: this.maxPendingSims });
1888
+ const tempBookPath = this.PATHS.tempBooks(mode, index);
1889
+ const bookStream = fs2.createWriteStream(tempBookPath, {
1890
+ highWaterMark: this.maxHighWaterMark
1891
+ });
1892
+ const tempLookupPath = this.PATHS.tempLookupTable(mode, index);
1893
+ const lookupStream = fs2.createWriteStream(tempLookupPath, {
1894
+ highWaterMark: this.maxHighWaterMark
1895
+ });
1896
+ const tempLookupSegPath = this.PATHS.tempLookupTableSegmented(mode, index);
1897
+ const lookupSegmentedStream = fs2.createWriteStream(tempLookupSegPath, {
1898
+ highWaterMark: this.maxHighWaterMark
1899
+ });
1900
+ let writeChain = Promise.resolve();
1774
1901
  worker.on("message", (msg) => {
1775
1902
  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;
1903
+ return;
1904
+ }
1905
+ if (msg.type === "complete") {
1906
+ writeChain = writeChain.then(async () => {
1907
+ completedSimulations++;
1908
+ if (completedSimulations % 250 === 0) {
1909
+ logArrowProgress(completedSimulations, totalSims);
1796
1910
  }
1797
- }
1798
- this.wallet.mergeSerialized(msg.wallet);
1799
- } else if (msg.type === "done") {
1800
- resolve(true);
1911
+ const book = msg.book;
1912
+ const bookData = {
1913
+ id: book.id,
1914
+ payoutMultiplier: book.payout,
1915
+ events: book.events
1916
+ };
1917
+ const prefix = book.id === simStart ? "" : "\n";
1918
+ await write(bookStream, prefix + JSONL.stringify([bookData]));
1919
+ await write(lookupStream, `${book.id},1,${Math.round(book.payout)}
1920
+ `);
1921
+ await write(
1922
+ lookupSegmentedStream,
1923
+ `${book.id},${book.criteria},${book.basegameWins},${book.freespinsWins}
1924
+ `
1925
+ );
1926
+ if (this.recordsWriteStream) {
1927
+ for (const record of msg.records) {
1928
+ const recordPrefix = this.hasWrittenRecord ? "\n" : "";
1929
+ await write(
1930
+ this.recordsWriteStream,
1931
+ recordPrefix + JSONL.stringify([record])
1932
+ );
1933
+ this.hasWrittenRecord = true;
1934
+ }
1935
+ }
1936
+ this.wallet.mergeSerialized(msg.wallet);
1937
+ worker.postMessage({ type: "credit", amount: 1 });
1938
+ }).catch(reject);
1939
+ return;
1940
+ }
1941
+ if (msg.type === "done") {
1942
+ writeChain.then(async () => {
1943
+ bookStream.end();
1944
+ lookupStream.end();
1945
+ lookupSegmentedStream.end();
1946
+ await Promise.all([
1947
+ new Promise((r) => bookStream.on("finish", () => r())),
1948
+ new Promise((r) => lookupStream.on("finish", () => r())),
1949
+ new Promise((r) => lookupSegmentedStream.on("finish", () => r()))
1950
+ ]);
1951
+ resolve(true);
1952
+ }).catch(reject);
1953
+ return;
1801
1954
  }
1802
1955
  });
1803
1956
  worker.on("error", (error) => {
@@ -1858,6 +2011,31 @@ ${error.stack}
1858
2011
  records: ctx.services.data._getRecords()
1859
2012
  });
1860
2013
  }
2014
+ initCreditListener() {
2015
+ if (this.creditListenerInit) return;
2016
+ this.creditListenerInit = true;
2017
+ parentPort?.on("message", (msg) => {
2018
+ if (msg?.type !== "credit") return;
2019
+ const amount = Number(msg?.amount ?? 0);
2020
+ if (!Number.isFinite(amount) || amount <= 0) return;
2021
+ this.credits += amount;
2022
+ while (this.credits > 0 && this.creditWaiters.length > 0) {
2023
+ this.credits -= 1;
2024
+ const resolve = this.creditWaiters.shift();
2025
+ resolve();
2026
+ }
2027
+ });
2028
+ }
2029
+ acquireCredit() {
2030
+ this.initCreditListener();
2031
+ if (this.credits > 0) {
2032
+ this.credits -= 1;
2033
+ return Promise.resolve();
2034
+ }
2035
+ return new Promise((resolve) => {
2036
+ this.creditWaiters.push(resolve);
2037
+ });
2038
+ }
1861
2039
  /**
1862
2040
  * If a simulation does not meet the required criteria, reset the state to run it again.
1863
2041
  *
@@ -1885,7 +2063,7 @@ ${error.stack}
1885
2063
  ctx.state.totalFreespinAmount = 0;
1886
2064
  ctx.state.triggeredMaxWin = false;
1887
2065
  ctx.state.triggeredFreespins = false;
1888
- ctx.state.userData = ctx.config.userState || {};
2066
+ ctx.state.userData = copy(ctx.config.userState);
1889
2067
  }
1890
2068
  /**
1891
2069
  * Contains and executes the entire game logic:
@@ -1900,64 +2078,9 @@ ${error.stack}
1900
2078
  handleGameFlow(ctx) {
1901
2079
  this.gameConfig.hooks.onHandleGameFlow(ctx);
1902
2080
  }
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
2081
  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
- );
2082
+ const tempRecordsPath = this.PATHS.tempRecords(mode);
2083
+ const forceRecordsPath = this.PATHS.forceRecords(mode);
1961
2084
  const aggregatedRecords = /* @__PURE__ */ new Map();
1962
2085
  if (fs2.existsSync(tempRecordsPath)) {
1963
2086
  const fileStream = fs2.createReadStream(tempRecordsPath);
@@ -2003,15 +2126,10 @@ ${error.stack}
2003
2126
  fs2.rmSync(tempRecordsPath, { force: true });
2004
2127
  }
2005
2128
  writeIndexJson() {
2006
- const outputFilePath = path.join(
2007
- this.gameConfig.rootDir,
2008
- this.gameConfig.outputDir,
2009
- "publish_files",
2010
- "index.json"
2011
- );
2129
+ const outputFilePath = this.PATHS.indexJson;
2012
2130
  const modes = Object.keys(this.simRunsAmount).map((id) => {
2013
2131
  const mode = this.gameConfig.gameModes[id];
2014
- assert6(mode, `Game mode "${id}" not found in game config.`);
2132
+ assert7(mode, `Game mode "${id}" not found in game config.`);
2015
2133
  return {
2016
2134
  name: mode.name,
2017
2135
  cost: mode.cost,
@@ -2022,17 +2140,8 @@ ${error.stack}
2022
2140
  writeFile(outputFilePath, JSON.stringify({ modes }, null, 2));
2023
2141
  }
2024
2142
  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
- );
2143
+ const outputFilePath = this.PATHS.books(gameMode);
2144
+ const compressedFilePath = this.PATHS.booksCompressed(gameMode);
2036
2145
  fs2.rmSync(compressedFilePath, { force: true });
2037
2146
  if (fs2.existsSync(outputFilePath)) {
2038
2147
  await pipeline(
@@ -2065,11 +2174,12 @@ ${error.stack}
2065
2174
  });
2066
2175
  }
2067
2176
  getSimRangesForChunks(total, chunks) {
2068
- const base = Math.floor(total / chunks);
2069
- const remainder = total % chunks;
2177
+ const realChunks = Math.min(chunks, Math.max(total, 1));
2178
+ const base = Math.floor(total / realChunks);
2179
+ const remainder = total % realChunks;
2070
2180
  const result = [];
2071
2181
  let current = 1;
2072
- for (let i = 0; i < chunks; i++) {
2182
+ for (let i = 0; i < realChunks; i++) {
2073
2183
  const size = base + (i < remainder ? 1 : 0);
2074
2184
  const start = current;
2075
2185
  const end = current + size - 1;
@@ -2095,6 +2205,22 @@ ${error.stack}
2095
2205
  }
2096
2206
  }
2097
2207
  }
2208
+ async mergeCsv(chunks, outPath, tempName) {
2209
+ fs2.rmSync(outPath, { force: true });
2210
+ const out = fs2.createWriteStream(outPath);
2211
+ let wroteAny = false;
2212
+ for (let i = 0; i < chunks.length; i++) {
2213
+ const p = path.join(this.PATHS.base, TEMP_FOLDER, tempName(i));
2214
+ if (!fs2.existsSync(p)) continue;
2215
+ if (wroteAny) out.write("");
2216
+ const rs = fs2.createReadStream(p);
2217
+ for await (const buf of rs) out.write(buf);
2218
+ fs2.rmSync(p);
2219
+ wroteAny = true;
2220
+ }
2221
+ out.end();
2222
+ await new Promise((resolve) => out.on("finish", resolve));
2223
+ }
2098
2224
  /**
2099
2225
  * Confirms all pending records and adds them to the main records list.
2100
2226
  */
@@ -2130,7 +2256,7 @@ ${error.stack}
2130
2256
  // src/analysis/index.ts
2131
2257
  import fs3 from "fs";
2132
2258
  import path2 from "path";
2133
- import assert7 from "assert";
2259
+ import assert8 from "assert";
2134
2260
 
2135
2261
  // src/analysis/utils.ts
2136
2262
  function parseLookupTable(content) {
@@ -2307,7 +2433,7 @@ var Analysis = class {
2307
2433
  booksJsonlCompressed
2308
2434
  };
2309
2435
  for (const p of Object.values(paths[modeStr])) {
2310
- assert7(
2436
+ assert8(
2311
2437
  fs3.existsSync(p),
2312
2438
  `File "${p}" does not exist. Run optimization to auto-create it.`
2313
2439
  );
@@ -2429,14 +2555,14 @@ var Analysis = class {
2429
2555
  }
2430
2556
  getGameModeConfig(mode) {
2431
2557
  const config = this.gameConfig.gameModes[mode];
2432
- assert7(config, `Game mode "${mode}" not found in game config`);
2558
+ assert8(config, `Game mode "${mode}" not found in game config`);
2433
2559
  return config;
2434
2560
  }
2435
2561
  };
2436
2562
 
2437
2563
  // src/optimizer/index.ts
2438
2564
  import path5 from "path";
2439
- import assert9 from "assert";
2565
+ import assert10 from "assert";
2440
2566
  import { spawn } from "child_process";
2441
2567
  import { isMainThread as isMainThread3 } from "worker_threads";
2442
2568
 
@@ -2537,7 +2663,7 @@ function makeSetupFile(optimizer, gameMode) {
2537
2663
  }
2538
2664
 
2539
2665
  // src/optimizer/OptimizationConditions.ts
2540
- import assert8 from "assert";
2666
+ import assert9 from "assert";
2541
2667
  var OptimizationConditions = class {
2542
2668
  rtp;
2543
2669
  avgWin;
@@ -2548,14 +2674,14 @@ var OptimizationConditions = class {
2548
2674
  constructor(opts) {
2549
2675
  let { rtp, avgWin, hitRate, searchConditions, priority } = opts;
2550
2676
  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.");
2677
+ 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
2678
  rtp = Math.round(avgWin / Number(hitRate) * 1e5) / 1e5;
2553
2679
  }
2554
2680
  let noneCount = 0;
2555
2681
  for (const val of [rtp, avgWin, hitRate]) {
2556
2682
  if (val === void 0) noneCount++;
2557
2683
  }
2558
- assert8(noneCount <= 1, "Invalid combination of optimization conditions.");
2684
+ assert9(noneCount <= 1, "Invalid combination of optimization conditions.");
2559
2685
  this.searchRange = [-1, -1];
2560
2686
  this.forceSearch = {};
2561
2687
  if (typeof searchConditions === "number") {
@@ -2683,7 +2809,7 @@ var Optimizer = class {
2683
2809
  }
2684
2810
  gameModeRtp = Math.round(gameModeRtp * 1e3) / 1e3;
2685
2811
  paramRtp = Math.round(paramRtp * 1e3) / 1e3;
2686
- assert9(
2812
+ assert10(
2687
2813
  gameModeRtp === paramRtp,
2688
2814
  `Sum of all RTP conditions (${paramRtp}) does not match the game mode RTP (${gameModeRtp}) in game mode "${k}".`
2689
2815
  );
@@ -2822,7 +2948,7 @@ var defineSymbols = (symbols) => symbols;
2822
2948
  var defineGameModes = (gameModes) => gameModes;
2823
2949
 
2824
2950
  // src/game-mode/index.ts
2825
- import assert10 from "assert";
2951
+ import assert11 from "assert";
2826
2952
  var GameMode = class {
2827
2953
  name;
2828
2954
  _reelsAmount;
@@ -2845,12 +2971,12 @@ var GameMode = class {
2845
2971
  this.reelSets = opts.reelSets;
2846
2972
  this.resultSets = opts.resultSets;
2847
2973
  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(
2974
+ assert11(this.rtp >= 0.9 && this.rtp <= 0.99, "RTP must be between 0.9 and 0.99");
2975
+ assert11(
2850
2976
  this.symbolsPerReel.length === this.reelsAmount,
2851
2977
  "symbolsPerReel length must match reelsAmount."
2852
2978
  );
2853
- assert10(this.reelSets.length > 0, "GameMode must have at least one ReelSet defined.");
2979
+ assert11(this.reelSets.length > 0, "GameMode must have at least one ReelSet defined.");
2854
2980
  }
2855
2981
  /**
2856
2982
  * Intended for internal use only.
@@ -2863,7 +2989,7 @@ var GameMode = class {
2863
2989
  * Intended for internal use only.
2864
2990
  */
2865
2991
  _setSymbolsPerReel(symbolsPerReel) {
2866
- assert10(
2992
+ assert11(
2867
2993
  symbolsPerReel.length === this._reelsAmount,
2868
2994
  "symbolsPerReel length must match reelsAmount."
2869
2995
  );
@@ -2929,7 +3055,7 @@ var WinType = class {
2929
3055
  };
2930
3056
 
2931
3057
  // src/win-types/LinesWinType.ts
2932
- import assert11 from "assert";
3058
+ import assert12 from "assert";
2933
3059
  var LinesWinType = class extends WinType {
2934
3060
  lines;
2935
3061
  constructor(opts) {
@@ -2983,8 +3109,8 @@ var LinesWinType = class extends WinType {
2983
3109
  if (!baseSymbol) {
2984
3110
  baseSymbol = thisSymbol;
2985
3111
  }
2986
- assert11(baseSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
2987
- assert11(thisSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3112
+ assert12(baseSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
3113
+ assert12(thisSymbol, `No symbol found at line ${lineNum}, reel ${ridx}`);
2988
3114
  if (potentialWinLine.length == 0) {
2989
3115
  if (this.isWild(thisSymbol)) {
2990
3116
  potentialWildLine.push({ reel: ridx, row: sidx, symbol: thisSymbol });
@@ -3653,7 +3779,7 @@ var GeneratedReelSet = class extends ReelSet {
3653
3779
  };
3654
3780
 
3655
3781
  // src/reel-set/StaticReelSet.ts
3656
- import assert12 from "assert";
3782
+ import assert13 from "assert";
3657
3783
  var StaticReelSet = class extends ReelSet {
3658
3784
  reels;
3659
3785
  csvPath;
@@ -3663,7 +3789,7 @@ var StaticReelSet = class extends ReelSet {
3663
3789
  this.reels = [];
3664
3790
  this._strReels = opts.reels || [];
3665
3791
  this.csvPath = opts.csvPath || "";
3666
- assert12(
3792
+ assert13(
3667
3793
  opts.reels || opts.csvPath,
3668
3794
  `Either 'reels' or 'csvPath' must be provided for StaticReelSet ${this.id}`
3669
3795
  );