@slot-engine/core 0.1.3 → 0.1.5

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
@@ -34,6 +34,7 @@ import fs2 from "fs";
34
34
  import path from "path";
35
35
  import assert6 from "assert";
36
36
  import zlib from "zlib";
37
+ import readline2 from "readline";
37
38
  import { buildSync } from "esbuild";
38
39
  import { Worker, isMainThread, parentPort, workerData } from "worker_threads";
39
40
 
@@ -201,6 +202,7 @@ var RandomNumberGenerator = class {
201
202
 
202
203
  // utils.ts
203
204
  import fs from "fs";
205
+ import readline from "readline";
204
206
  function createDirIfNotExists(dirPath) {
205
207
  if (!fs.existsSync(dirPath)) {
206
208
  fs.mkdirSync(dirPath, { recursive: true });
@@ -232,6 +234,29 @@ var JSONL = class {
232
234
  static parse(jsonl) {
233
235
  return jsonl.split("\n").filter((s) => s !== "").map((str) => JSON.parse(str));
234
236
  }
237
+ static async convertToJson(inputPath, outputPath) {
238
+ const writeStream = fs.createWriteStream(outputPath, { encoding: "utf-8" });
239
+ writeStream.write("[\n");
240
+ const rl = readline.createInterface({
241
+ input: fs.createReadStream(inputPath),
242
+ crlfDelay: Infinity
243
+ });
244
+ let isFirst = true;
245
+ for await (const line of rl) {
246
+ if (line.trim() === "") continue;
247
+ if (!isFirst) {
248
+ writeStream.write(",\n");
249
+ }
250
+ writeStream.write(line);
251
+ isFirst = false;
252
+ }
253
+ writeStream.write("\n]");
254
+ writeStream.end();
255
+ return new Promise((resolve, reject) => {
256
+ writeStream.on("finish", () => resolve());
257
+ writeStream.on("error", reject);
258
+ });
259
+ }
235
260
  };
236
261
 
237
262
  // src/result-set/index.ts
@@ -695,6 +720,9 @@ var Board = class {
695
720
  newPaddingTopSymbols[ridx].unshift(padSymbol);
696
721
  }
697
722
  }
723
+ this.lastDrawnReelStops = this.lastDrawnReelStops.map((stop, ridx) => {
724
+ return newFirstSymbolPositions[ridx] ?? stop;
725
+ });
698
726
  return {
699
727
  newBoardSymbols,
700
728
  newPaddingTopSymbols
@@ -1040,6 +1068,27 @@ var GameService = class extends AbstractService {
1040
1068
  this.ctx().state.totalFreespinAmount += amount;
1041
1069
  this.ctx().state.triggeredFreespins = true;
1042
1070
  }
1071
+ /**
1072
+ * Dedupes win symbols.
1073
+ *
1074
+ * Since it may be possible that multiple win combinations include the same symbol (e.g. Wilds),\
1075
+ * this method ensures that each symbol is only listed once.
1076
+ *
1077
+ * If you want to tumble based on winning symbols, run them through this method first.
1078
+ */
1079
+ dedupeWinSymbols(winCombinations) {
1080
+ const symbolsMap = /* @__PURE__ */ new Map();
1081
+ winCombinations.forEach((wc) => {
1082
+ wc.symbols.forEach((s) => {
1083
+ symbolsMap.set(`${s.reelIndex},${s.posIndex}`, {
1084
+ reelIdx: s.reelIndex,
1085
+ rowIdx: s.posIndex
1086
+ });
1087
+ });
1088
+ });
1089
+ const symbolsToRemove = Array.from(symbolsMap.values());
1090
+ return symbolsToRemove;
1091
+ }
1043
1092
  };
1044
1093
 
1045
1094
  // src/service/wallet.ts
@@ -1412,8 +1461,10 @@ var Wallet = class {
1412
1461
  };
1413
1462
 
1414
1463
  // src/simulation/index.ts
1464
+ import { pipeline } from "stream/promises";
1415
1465
  var completedSimulations = 0;
1416
1466
  var TEMP_FILENAME = "__temp_compiled_src_IGNORE.js";
1467
+ var TEMP_FOLDER = "temp_files";
1417
1468
  var Simulation = class {
1418
1469
  gameConfigOpts;
1419
1470
  gameConfig;
@@ -1422,15 +1473,15 @@ var Simulation = class {
1422
1473
  debug = false;
1423
1474
  actualSims = 0;
1424
1475
  library;
1425
- recorder;
1426
1476
  wallet;
1477
+ recordsWriteStream;
1478
+ hasWrittenRecord = false;
1427
1479
  constructor(opts, gameConfigOpts) {
1428
1480
  this.gameConfig = createGameConfig(gameConfigOpts);
1429
1481
  this.gameConfigOpts = gameConfigOpts;
1430
1482
  this.simRunsAmount = opts.simRunsAmount || {};
1431
1483
  this.concurrency = (opts.concurrency || 6) >= 2 ? opts.concurrency || 6 : 2;
1432
1484
  this.library = /* @__PURE__ */ new Map();
1433
- this.recorder = new Recorder();
1434
1485
  this.wallet = new Wallet();
1435
1486
  const gameModeKeys = Object.keys(this.gameConfig.gameModes);
1436
1487
  assert6(
@@ -1456,7 +1507,7 @@ var Simulation = class {
1456
1507
  completedSimulations = 0;
1457
1508
  this.wallet = new Wallet();
1458
1509
  this.library = /* @__PURE__ */ new Map();
1459
- this.recorder = new Recorder();
1510
+ this.hasWrittenRecord = false;
1460
1511
  debugDetails[mode] = {};
1461
1512
  console.log(`
1462
1513
  Simulating game mode: ${mode}`);
@@ -1468,8 +1519,59 @@ Simulating game mode: ${mode}`);
1468
1519
  `Tried to simulate game mode "${mode}", but it's not configured in the game config.`
1469
1520
  );
1470
1521
  }
1522
+ const booksPath = path.join(
1523
+ this.gameConfig.rootDir,
1524
+ this.gameConfig.outputDir,
1525
+ `books_${mode}.jsonl`
1526
+ );
1527
+ const tempRecordsPath = path.join(
1528
+ this.gameConfig.rootDir,
1529
+ this.gameConfig.outputDir,
1530
+ TEMP_FOLDER,
1531
+ `temp_records_${mode}.jsonl`
1532
+ );
1533
+ createDirIfNotExists(
1534
+ path.join(this.gameConfig.rootDir, this.gameConfig.outputDir)
1535
+ );
1536
+ createDirIfNotExists(
1537
+ path.join(this.gameConfig.rootDir, this.gameConfig.outputDir, TEMP_FOLDER)
1538
+ );
1539
+ this.recordsWriteStream = fs2.createWriteStream(tempRecordsPath);
1471
1540
  const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1472
1541
  await this.spawnWorkersForGameMode({ mode, simNumsToCriteria });
1542
+ const finalBookStream = fs2.createWriteStream(booksPath);
1543
+ const numSims = Object.keys(simNumsToCriteria).length;
1544
+ const chunks = this.getSimRangesForChunks(numSims, this.concurrency);
1545
+ let isFirstChunk = true;
1546
+ for (let i = 0; i < chunks.length; i++) {
1547
+ const tempBookPath = path.join(
1548
+ this.gameConfig.rootDir,
1549
+ this.gameConfig.outputDir,
1550
+ TEMP_FOLDER,
1551
+ `temp_books_${mode}_${i}.jsonl`
1552
+ );
1553
+ if (fs2.existsSync(tempBookPath)) {
1554
+ if (!isFirstChunk) {
1555
+ finalBookStream.write("\n");
1556
+ }
1557
+ const content = fs2.createReadStream(tempBookPath);
1558
+ for await (const chunk of content) {
1559
+ finalBookStream.write(chunk);
1560
+ }
1561
+ fs2.rmSync(tempBookPath);
1562
+ isFirstChunk = false;
1563
+ }
1564
+ }
1565
+ finalBookStream.end();
1566
+ await new Promise((resolve) => finalBookStream.on("finish", resolve));
1567
+ if (this.recordsWriteStream) {
1568
+ await new Promise((resolve) => {
1569
+ this.recordsWriteStream.end(() => {
1570
+ resolve();
1571
+ });
1572
+ });
1573
+ this.recordsWriteStream = void 0;
1574
+ }
1473
1575
  createDirIfNotExists(
1474
1576
  path.join(
1475
1577
  this.gameConfig.rootDir,
@@ -1480,11 +1582,13 @@ Simulating game mode: ${mode}`);
1480
1582
  createDirIfNotExists(
1481
1583
  path.join(this.gameConfig.rootDir, this.gameConfig.outputDir, "publish_files")
1482
1584
  );
1585
+ console.log(`Writing final files for game mode: ${mode} ...`);
1483
1586
  this.writeLookupTableCSV(mode);
1484
1587
  this.writeLookupTableSegmentedCSV(mode);
1485
1588
  this.writeRecords(mode);
1486
1589
  await this.writeBooksJson(mode);
1487
1590
  this.writeIndexJson();
1591
+ console.log(`Mode ${mode} done!`);
1488
1592
  debugDetails[mode].rtp = this.wallet.getCumulativeWins() / (runs * this.gameConfig.gameModes[mode].cost);
1489
1593
  debugDetails[mode].wins = this.wallet.getCumulativeWins();
1490
1594
  debugDetails[mode].winsPerSpinType = this.wallet.getCumulativeWinsPerSpinType();
@@ -1563,6 +1667,12 @@ Simulating game mode: ${mode}`);
1563
1667
  index
1564
1668
  }
1565
1669
  });
1670
+ const tempBookPath = path.join(
1671
+ basePath,
1672
+ TEMP_FOLDER,
1673
+ `temp_books_${mode}_${index}.jsonl`
1674
+ );
1675
+ const bookStream = fs2.createWriteStream(tempBookPath);
1566
1676
  worker.on("message", (msg) => {
1567
1677
  if (msg.type === "log") {
1568
1678
  } else if (msg.type === "complete") {
@@ -1571,15 +1681,34 @@ Simulating game mode: ${mode}`);
1571
1681
  logArrowProgress(completedSimulations, totalSims);
1572
1682
  }
1573
1683
  const book = Book.fromSerialized(msg.book);
1684
+ const bookData = {
1685
+ id: book.id,
1686
+ payoutMultiplier: book.payout,
1687
+ events: book.events
1688
+ };
1689
+ const prefix = book.id === simStart ? "" : "\n";
1690
+ bookStream.write(prefix + JSONL.stringify([bookData]));
1691
+ book.events = [];
1574
1692
  this.library.set(book.id, book);
1693
+ if (this.recordsWriteStream) {
1694
+ for (const record of msg.records) {
1695
+ const recordPrefix = this.hasWrittenRecord ? "\n" : "";
1696
+ this.recordsWriteStream.write(recordPrefix + JSONL.stringify([record]));
1697
+ this.hasWrittenRecord = true;
1698
+ }
1699
+ }
1575
1700
  this.wallet.mergeSerialized(msg.wallet);
1576
- this.mergeRecords(msg.records);
1577
1701
  } else if (msg.type === "done") {
1578
1702
  resolve(true);
1579
1703
  }
1580
1704
  });
1581
1705
  worker.on("error", (error) => {
1582
- console.error("Error:", error);
1706
+ process.stdout.write(`
1707
+ ${error.message}
1708
+ `);
1709
+ process.stdout.write(`
1710
+ ${error.stack}
1711
+ `);
1583
1712
  reject(error);
1584
1713
  });
1585
1714
  worker.on("exit", (code) => {
@@ -1716,16 +1845,61 @@ Simulating game mode: ${mode}`);
1716
1845
  writeFile(outputFilePath, rows.join("\n"));
1717
1846
  return outputFilePath;
1718
1847
  }
1719
- writeRecords(gameMode) {
1720
- const outputFileName = `force_record_${gameMode}.json`;
1721
- const outputFilePath = path.join(
1848
+ async writeRecords(mode) {
1849
+ const tempRecordsPath = path.join(
1722
1850
  this.gameConfig.rootDir,
1723
1851
  this.gameConfig.outputDir,
1724
- outputFileName
1852
+ TEMP_FOLDER,
1853
+ `temp_records_${mode}.jsonl`
1725
1854
  );
1726
- writeFile(outputFilePath, JSON.stringify(this.recorder.records, null, 2));
1727
- if (this.debug) this.logSymbolOccurrences();
1728
- return outputFilePath;
1855
+ const forceRecordsPath = path.join(
1856
+ this.gameConfig.rootDir,
1857
+ this.gameConfig.outputDir,
1858
+ `force_record_${mode}.json`
1859
+ );
1860
+ const aggregatedRecords = /* @__PURE__ */ new Map();
1861
+ if (fs2.existsSync(tempRecordsPath)) {
1862
+ const fileStream = fs2.createReadStream(tempRecordsPath);
1863
+ const rl = readline2.createInterface({
1864
+ input: fileStream,
1865
+ crlfDelay: Infinity
1866
+ });
1867
+ for await (const line of rl) {
1868
+ if (line.trim() === "") continue;
1869
+ const record = JSON.parse(line);
1870
+ const key = JSON.stringify(record.search);
1871
+ let existing = aggregatedRecords.get(key);
1872
+ if (!existing) {
1873
+ existing = {
1874
+ search: record.search,
1875
+ timesTriggered: 0,
1876
+ bookIds: []
1877
+ };
1878
+ aggregatedRecords.set(key, existing);
1879
+ }
1880
+ existing.timesTriggered += record.timesTriggered;
1881
+ for (const bookId of record.bookIds) {
1882
+ existing.bookIds.push(bookId);
1883
+ }
1884
+ }
1885
+ }
1886
+ fs2.rmSync(forceRecordsPath, { force: true });
1887
+ const writeStream = fs2.createWriteStream(forceRecordsPath, { encoding: "utf-8" });
1888
+ writeStream.write("[\n");
1889
+ let isFirst = true;
1890
+ for (const record of aggregatedRecords.values()) {
1891
+ if (!isFirst) {
1892
+ writeStream.write(",\n");
1893
+ }
1894
+ writeStream.write(JSON.stringify(record));
1895
+ isFirst = false;
1896
+ }
1897
+ writeStream.write("\n]");
1898
+ writeStream.end();
1899
+ await new Promise((resolve) => {
1900
+ writeStream.on("finish", () => resolve());
1901
+ });
1902
+ fs2.rmSync(tempRecordsPath, { force: true });
1729
1903
  }
1730
1904
  writeIndexJson() {
1731
1905
  const outputFilePath = path.join(
@@ -1747,54 +1921,25 @@ Simulating game mode: ${mode}`);
1747
1921
  writeFile(outputFilePath, JSON.stringify({ modes }, null, 2));
1748
1922
  }
1749
1923
  async writeBooksJson(gameMode) {
1750
- const outputFileName = `books_${gameMode}.jsonl`;
1751
1924
  const outputFilePath = path.join(
1752
1925
  this.gameConfig.rootDir,
1753
1926
  this.gameConfig.outputDir,
1754
- outputFileName
1927
+ `books_${gameMode}.jsonl`
1755
1928
  );
1756
- const books = Array.from(this.library.values()).map((b) => b.serialize()).map((b) => ({
1757
- id: b.id,
1758
- payoutMultiplier: b.payout,
1759
- events: b.events
1760
- })).sort((a, b) => a.id - b.id);
1761
- const contents = JSONL.stringify(books);
1762
- writeFile(outputFilePath, contents);
1763
- const compressedFileName = `books_${gameMode}.jsonl.zst`;
1764
1929
  const compressedFilePath = path.join(
1765
1930
  this.gameConfig.rootDir,
1766
1931
  this.gameConfig.outputDir,
1767
1932
  "publish_files",
1768
- compressedFileName
1933
+ `books_${gameMode}.jsonl.zst`
1769
1934
  );
1770
1935
  fs2.rmSync(compressedFilePath, { force: true });
1771
- const compressed = zlib.zstdCompressSync(Buffer.from(contents));
1772
- fs2.writeFileSync(compressedFilePath, compressed);
1773
- }
1774
- logSymbolOccurrences() {
1775
- const validRecords = this.recorder.records.filter(
1776
- (r) => r.search.some((s) => s.name === "symbolId") && r.search.some((s) => s.name === "kind")
1777
- );
1778
- const structuredRecords = validRecords.map((r) => {
1779
- const symbolEntry = r.search.find((s) => s.name === "symbolId");
1780
- const kindEntry = r.search.find((s) => s.name === "kind");
1781
- const spinTypeEntry = r.search.find((s) => s.name === "spinType");
1782
- return {
1783
- symbol: symbolEntry ? symbolEntry.value : "unknown",
1784
- kind: kindEntry ? kindEntry.value : "unknown",
1785
- spinType: spinTypeEntry ? spinTypeEntry.value : "unknown",
1786
- timesTriggered: r.timesTriggered
1787
- };
1788
- }).sort((a, b) => {
1789
- if (a.symbol < b.symbol) return -1;
1790
- if (a.symbol > b.symbol) return 1;
1791
- if (a.kind < b.kind) return -1;
1792
- if (a.kind > b.kind) return 1;
1793
- if (a.spinType < b.spinType) return -1;
1794
- if (a.spinType > b.spinType) return 1;
1795
- return 0;
1796
- });
1797
- console.table(structuredRecords);
1936
+ if (fs2.existsSync(outputFilePath)) {
1937
+ await pipeline(
1938
+ fs2.createReadStream(outputFilePath),
1939
+ zlib.createZstdCompress(),
1940
+ fs2.createWriteStream(compressedFilePath)
1941
+ );
1942
+ }
1798
1943
  }
1799
1944
  /**
1800
1945
  * Compiles user configured game to JS for use in different Node processes
@@ -1832,32 +1977,6 @@ Simulating game mode: ${mode}`);
1832
1977
  }
1833
1978
  return result;
1834
1979
  }
1835
- mergeRecords(otherRecords) {
1836
- for (const otherRecord of otherRecords) {
1837
- let record = this.recorder.records.find((r) => {
1838
- if (r.search.length !== otherRecord.search.length) return false;
1839
- for (let i = 0; i < r.search.length; i++) {
1840
- if (r.search[i].name !== otherRecord.search[i].name) return false;
1841
- if (r.search[i].value !== otherRecord.search[i].value) return false;
1842
- }
1843
- return true;
1844
- });
1845
- if (!record) {
1846
- record = {
1847
- search: otherRecord.search,
1848
- timesTriggered: 0,
1849
- bookIds: []
1850
- };
1851
- this.recorder.records.push(record);
1852
- }
1853
- record.timesTriggered += otherRecord.timesTriggered;
1854
- for (const bookId of otherRecord.bookIds) {
1855
- if (!record.bookIds.includes(bookId)) {
1856
- record.bookIds.push(bookId);
1857
- }
1858
- }
1859
- }
1860
- }
1861
1980
  /**
1862
1981
  * Generates reelset CSV files for all game modes.
1863
1982
  */
@@ -2471,11 +2590,6 @@ var Optimizer = class {
2471
2590
  );
2472
2591
  }
2473
2592
  }
2474
- const criteria = configMode.resultSets.map((r) => r.criteria);
2475
- assert9(
2476
- conditions.every((c) => criteria.includes(c)),
2477
- `Not all ResultSet criteria in game mode "${k}" are defined as optimization conditions.`
2478
- );
2479
2593
  let gameModeRtp = configMode.rtp;
2480
2594
  let paramRtp = 0;
2481
2595
  for (const cond of conditions) {
@@ -2528,6 +2642,7 @@ async function rustProgram(...args) {
2528
2642
  }
2529
2643
 
2530
2644
  // src/slot-game/index.ts
2645
+ import { isMainThread as isMainThread4 } from "worker_threads";
2531
2646
  var SlotGame = class {
2532
2647
  configOpts;
2533
2648
  simulation;
@@ -2603,6 +2718,7 @@ var SlotGame = class {
2603
2718
  if (opts.doAnalysis) {
2604
2719
  await this.runAnalysis(opts.analysisOpts || { gameModes: [] });
2605
2720
  }
2721
+ if (isMainThread4) console.log("Finishing up...");
2606
2722
  }
2607
2723
  /**
2608
2724
  * Gets the game configuration.
@@ -2884,13 +3000,14 @@ var ClusterWinType = class extends WinType {
2884
3000
  }
2885
3001
  }
2886
3002
  }
2887
- potentialClusters.forEach((cluster) => {
3003
+ for (const cluster of potentialClusters) {
2888
3004
  const kind = cluster.length;
2889
3005
  let baseSymbol = cluster.find((s) => !this.isWild(s.symbol))?.symbol;
2890
3006
  if (!baseSymbol) baseSymbol = cluster[0].symbol;
2891
3007
  const payout = this.getSymbolPayout(baseSymbol, kind);
3008
+ if (payout === 0) continue;
2892
3009
  if (!baseSymbol.pays || Object.keys(baseSymbol.pays).length === 0) {
2893
- return;
3010
+ continue;
2894
3011
  }
2895
3012
  clusterWins.push({
2896
3013
  payout,
@@ -2903,7 +3020,7 @@ var ClusterWinType = class extends WinType {
2903
3020
  posIndex: s.row
2904
3021
  }))
2905
3022
  });
2906
- });
3023
+ }
2907
3024
  for (const win of clusterWins) {
2908
3025
  this.ctx.services.data.recordSymbolOccurrence({
2909
3026
  kind: win.kind,
@@ -3068,7 +3185,7 @@ var ManywaysWinType = class extends WinType {
3068
3185
  // src/reel-set/GeneratedReelSet.ts
3069
3186
  import fs5 from "fs";
3070
3187
  import path7 from "path";
3071
- import { isMainThread as isMainThread4 } from "worker_threads";
3188
+ import { isMainThread as isMainThread5 } from "worker_threads";
3072
3189
 
3073
3190
  // src/reel-set/index.ts
3074
3191
  import fs4 from "fs";
@@ -3413,7 +3530,7 @@ var GeneratedReelSet = class extends ReelSet {
3413
3530
  }
3414
3531
  }
3415
3532
  const csvString = csvRows.map((row) => row.join(",")).join("\n");
3416
- if (isMainThread4) {
3533
+ if (isMainThread5) {
3417
3534
  fs5.writeFileSync(filePath, csvString);
3418
3535
  this.reels = this.parseReelsetCSV(filePath, config);
3419
3536
  console.log(