@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.js CHANGED
@@ -86,6 +86,7 @@ var import_fs2 = __toESM(require("fs"));
86
86
  var import_path = __toESM(require("path"));
87
87
  var import_assert6 = __toESM(require("assert"));
88
88
  var import_zlib = __toESM(require("zlib"));
89
+ var import_readline2 = __toESM(require("readline"));
89
90
  var import_esbuild = require("esbuild");
90
91
  var import_worker_threads = require("worker_threads");
91
92
 
@@ -253,6 +254,7 @@ var RandomNumberGenerator = class {
253
254
 
254
255
  // utils.ts
255
256
  var import_fs = __toESM(require("fs"));
257
+ var import_readline = __toESM(require("readline"));
256
258
  function createDirIfNotExists(dirPath) {
257
259
  if (!import_fs.default.existsSync(dirPath)) {
258
260
  import_fs.default.mkdirSync(dirPath, { recursive: true });
@@ -284,6 +286,29 @@ var JSONL = class {
284
286
  static parse(jsonl) {
285
287
  return jsonl.split("\n").filter((s) => s !== "").map((str) => JSON.parse(str));
286
288
  }
289
+ static async convertToJson(inputPath, outputPath) {
290
+ const writeStream = import_fs.default.createWriteStream(outputPath, { encoding: "utf-8" });
291
+ writeStream.write("[\n");
292
+ const rl = import_readline.default.createInterface({
293
+ input: import_fs.default.createReadStream(inputPath),
294
+ crlfDelay: Infinity
295
+ });
296
+ let isFirst = true;
297
+ for await (const line of rl) {
298
+ if (line.trim() === "") continue;
299
+ if (!isFirst) {
300
+ writeStream.write(",\n");
301
+ }
302
+ writeStream.write(line);
303
+ isFirst = false;
304
+ }
305
+ writeStream.write("\n]");
306
+ writeStream.end();
307
+ return new Promise((resolve, reject) => {
308
+ writeStream.on("finish", () => resolve());
309
+ writeStream.on("error", reject);
310
+ });
311
+ }
287
312
  };
288
313
 
289
314
  // src/result-set/index.ts
@@ -747,6 +772,9 @@ var Board = class {
747
772
  newPaddingTopSymbols[ridx].unshift(padSymbol);
748
773
  }
749
774
  }
775
+ this.lastDrawnReelStops = this.lastDrawnReelStops.map((stop, ridx) => {
776
+ return newFirstSymbolPositions[ridx] ?? stop;
777
+ });
750
778
  return {
751
779
  newBoardSymbols,
752
780
  newPaddingTopSymbols
@@ -1092,6 +1120,27 @@ var GameService = class extends AbstractService {
1092
1120
  this.ctx().state.totalFreespinAmount += amount;
1093
1121
  this.ctx().state.triggeredFreespins = true;
1094
1122
  }
1123
+ /**
1124
+ * Dedupes win symbols.
1125
+ *
1126
+ * Since it may be possible that multiple win combinations include the same symbol (e.g. Wilds),\
1127
+ * this method ensures that each symbol is only listed once.
1128
+ *
1129
+ * If you want to tumble based on winning symbols, run them through this method first.
1130
+ */
1131
+ dedupeWinSymbols(winCombinations) {
1132
+ const symbolsMap = /* @__PURE__ */ new Map();
1133
+ winCombinations.forEach((wc) => {
1134
+ wc.symbols.forEach((s) => {
1135
+ symbolsMap.set(`${s.reelIndex},${s.posIndex}`, {
1136
+ reelIdx: s.reelIndex,
1137
+ rowIdx: s.posIndex
1138
+ });
1139
+ });
1140
+ });
1141
+ const symbolsToRemove = Array.from(symbolsMap.values());
1142
+ return symbolsToRemove;
1143
+ }
1095
1144
  };
1096
1145
 
1097
1146
  // src/service/wallet.ts
@@ -1464,8 +1513,10 @@ var Wallet = class {
1464
1513
  };
1465
1514
 
1466
1515
  // src/simulation/index.ts
1516
+ var import_promises = require("stream/promises");
1467
1517
  var completedSimulations = 0;
1468
1518
  var TEMP_FILENAME = "__temp_compiled_src_IGNORE.js";
1519
+ var TEMP_FOLDER = "temp_files";
1469
1520
  var Simulation = class {
1470
1521
  gameConfigOpts;
1471
1522
  gameConfig;
@@ -1474,15 +1525,15 @@ var Simulation = class {
1474
1525
  debug = false;
1475
1526
  actualSims = 0;
1476
1527
  library;
1477
- recorder;
1478
1528
  wallet;
1529
+ recordsWriteStream;
1530
+ hasWrittenRecord = false;
1479
1531
  constructor(opts, gameConfigOpts) {
1480
1532
  this.gameConfig = createGameConfig(gameConfigOpts);
1481
1533
  this.gameConfigOpts = gameConfigOpts;
1482
1534
  this.simRunsAmount = opts.simRunsAmount || {};
1483
1535
  this.concurrency = (opts.concurrency || 6) >= 2 ? opts.concurrency || 6 : 2;
1484
1536
  this.library = /* @__PURE__ */ new Map();
1485
- this.recorder = new Recorder();
1486
1537
  this.wallet = new Wallet();
1487
1538
  const gameModeKeys = Object.keys(this.gameConfig.gameModes);
1488
1539
  (0, import_assert6.default)(
@@ -1508,7 +1559,7 @@ var Simulation = class {
1508
1559
  completedSimulations = 0;
1509
1560
  this.wallet = new Wallet();
1510
1561
  this.library = /* @__PURE__ */ new Map();
1511
- this.recorder = new Recorder();
1562
+ this.hasWrittenRecord = false;
1512
1563
  debugDetails[mode] = {};
1513
1564
  console.log(`
1514
1565
  Simulating game mode: ${mode}`);
@@ -1520,8 +1571,59 @@ Simulating game mode: ${mode}`);
1520
1571
  `Tried to simulate game mode "${mode}", but it's not configured in the game config.`
1521
1572
  );
1522
1573
  }
1574
+ const booksPath = import_path.default.join(
1575
+ this.gameConfig.rootDir,
1576
+ this.gameConfig.outputDir,
1577
+ `books_${mode}.jsonl`
1578
+ );
1579
+ const tempRecordsPath = import_path.default.join(
1580
+ this.gameConfig.rootDir,
1581
+ this.gameConfig.outputDir,
1582
+ TEMP_FOLDER,
1583
+ `temp_records_${mode}.jsonl`
1584
+ );
1585
+ createDirIfNotExists(
1586
+ import_path.default.join(this.gameConfig.rootDir, this.gameConfig.outputDir)
1587
+ );
1588
+ createDirIfNotExists(
1589
+ import_path.default.join(this.gameConfig.rootDir, this.gameConfig.outputDir, TEMP_FOLDER)
1590
+ );
1591
+ this.recordsWriteStream = import_fs2.default.createWriteStream(tempRecordsPath);
1523
1592
  const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
1524
1593
  await this.spawnWorkersForGameMode({ mode, simNumsToCriteria });
1594
+ const finalBookStream = import_fs2.default.createWriteStream(booksPath);
1595
+ const numSims = Object.keys(simNumsToCriteria).length;
1596
+ const chunks = this.getSimRangesForChunks(numSims, this.concurrency);
1597
+ let isFirstChunk = true;
1598
+ for (let i = 0; i < chunks.length; i++) {
1599
+ const tempBookPath = import_path.default.join(
1600
+ this.gameConfig.rootDir,
1601
+ this.gameConfig.outputDir,
1602
+ TEMP_FOLDER,
1603
+ `temp_books_${mode}_${i}.jsonl`
1604
+ );
1605
+ if (import_fs2.default.existsSync(tempBookPath)) {
1606
+ if (!isFirstChunk) {
1607
+ finalBookStream.write("\n");
1608
+ }
1609
+ const content = import_fs2.default.createReadStream(tempBookPath);
1610
+ for await (const chunk of content) {
1611
+ finalBookStream.write(chunk);
1612
+ }
1613
+ import_fs2.default.rmSync(tempBookPath);
1614
+ isFirstChunk = false;
1615
+ }
1616
+ }
1617
+ finalBookStream.end();
1618
+ await new Promise((resolve) => finalBookStream.on("finish", resolve));
1619
+ if (this.recordsWriteStream) {
1620
+ await new Promise((resolve) => {
1621
+ this.recordsWriteStream.end(() => {
1622
+ resolve();
1623
+ });
1624
+ });
1625
+ this.recordsWriteStream = void 0;
1626
+ }
1525
1627
  createDirIfNotExists(
1526
1628
  import_path.default.join(
1527
1629
  this.gameConfig.rootDir,
@@ -1532,11 +1634,13 @@ Simulating game mode: ${mode}`);
1532
1634
  createDirIfNotExists(
1533
1635
  import_path.default.join(this.gameConfig.rootDir, this.gameConfig.outputDir, "publish_files")
1534
1636
  );
1637
+ console.log(`Writing final files for game mode: ${mode} ...`);
1535
1638
  this.writeLookupTableCSV(mode);
1536
1639
  this.writeLookupTableSegmentedCSV(mode);
1537
1640
  this.writeRecords(mode);
1538
1641
  await this.writeBooksJson(mode);
1539
1642
  this.writeIndexJson();
1643
+ console.log(`Mode ${mode} done!`);
1540
1644
  debugDetails[mode].rtp = this.wallet.getCumulativeWins() / (runs * this.gameConfig.gameModes[mode].cost);
1541
1645
  debugDetails[mode].wins = this.wallet.getCumulativeWins();
1542
1646
  debugDetails[mode].winsPerSpinType = this.wallet.getCumulativeWinsPerSpinType();
@@ -1615,6 +1719,12 @@ Simulating game mode: ${mode}`);
1615
1719
  index
1616
1720
  }
1617
1721
  });
1722
+ const tempBookPath = import_path.default.join(
1723
+ basePath,
1724
+ TEMP_FOLDER,
1725
+ `temp_books_${mode}_${index}.jsonl`
1726
+ );
1727
+ const bookStream = import_fs2.default.createWriteStream(tempBookPath);
1618
1728
  worker.on("message", (msg) => {
1619
1729
  if (msg.type === "log") {
1620
1730
  } else if (msg.type === "complete") {
@@ -1623,15 +1733,34 @@ Simulating game mode: ${mode}`);
1623
1733
  logArrowProgress(completedSimulations, totalSims);
1624
1734
  }
1625
1735
  const book = Book.fromSerialized(msg.book);
1736
+ const bookData = {
1737
+ id: book.id,
1738
+ payoutMultiplier: book.payout,
1739
+ events: book.events
1740
+ };
1741
+ const prefix = book.id === simStart ? "" : "\n";
1742
+ bookStream.write(prefix + JSONL.stringify([bookData]));
1743
+ book.events = [];
1626
1744
  this.library.set(book.id, book);
1745
+ if (this.recordsWriteStream) {
1746
+ for (const record of msg.records) {
1747
+ const recordPrefix = this.hasWrittenRecord ? "\n" : "";
1748
+ this.recordsWriteStream.write(recordPrefix + JSONL.stringify([record]));
1749
+ this.hasWrittenRecord = true;
1750
+ }
1751
+ }
1627
1752
  this.wallet.mergeSerialized(msg.wallet);
1628
- this.mergeRecords(msg.records);
1629
1753
  } else if (msg.type === "done") {
1630
1754
  resolve(true);
1631
1755
  }
1632
1756
  });
1633
1757
  worker.on("error", (error) => {
1634
- console.error("Error:", error);
1758
+ process.stdout.write(`
1759
+ ${error.message}
1760
+ `);
1761
+ process.stdout.write(`
1762
+ ${error.stack}
1763
+ `);
1635
1764
  reject(error);
1636
1765
  });
1637
1766
  worker.on("exit", (code) => {
@@ -1768,16 +1897,61 @@ Simulating game mode: ${mode}`);
1768
1897
  writeFile(outputFilePath, rows.join("\n"));
1769
1898
  return outputFilePath;
1770
1899
  }
1771
- writeRecords(gameMode) {
1772
- const outputFileName = `force_record_${gameMode}.json`;
1773
- const outputFilePath = import_path.default.join(
1900
+ async writeRecords(mode) {
1901
+ const tempRecordsPath = import_path.default.join(
1774
1902
  this.gameConfig.rootDir,
1775
1903
  this.gameConfig.outputDir,
1776
- outputFileName
1904
+ TEMP_FOLDER,
1905
+ `temp_records_${mode}.jsonl`
1777
1906
  );
1778
- writeFile(outputFilePath, JSON.stringify(this.recorder.records, null, 2));
1779
- if (this.debug) this.logSymbolOccurrences();
1780
- return outputFilePath;
1907
+ const forceRecordsPath = import_path.default.join(
1908
+ this.gameConfig.rootDir,
1909
+ this.gameConfig.outputDir,
1910
+ `force_record_${mode}.json`
1911
+ );
1912
+ const aggregatedRecords = /* @__PURE__ */ new Map();
1913
+ if (import_fs2.default.existsSync(tempRecordsPath)) {
1914
+ const fileStream = import_fs2.default.createReadStream(tempRecordsPath);
1915
+ const rl = import_readline2.default.createInterface({
1916
+ input: fileStream,
1917
+ crlfDelay: Infinity
1918
+ });
1919
+ for await (const line of rl) {
1920
+ if (line.trim() === "") continue;
1921
+ const record = JSON.parse(line);
1922
+ const key = JSON.stringify(record.search);
1923
+ let existing = aggregatedRecords.get(key);
1924
+ if (!existing) {
1925
+ existing = {
1926
+ search: record.search,
1927
+ timesTriggered: 0,
1928
+ bookIds: []
1929
+ };
1930
+ aggregatedRecords.set(key, existing);
1931
+ }
1932
+ existing.timesTriggered += record.timesTriggered;
1933
+ for (const bookId of record.bookIds) {
1934
+ existing.bookIds.push(bookId);
1935
+ }
1936
+ }
1937
+ }
1938
+ import_fs2.default.rmSync(forceRecordsPath, { force: true });
1939
+ const writeStream = import_fs2.default.createWriteStream(forceRecordsPath, { encoding: "utf-8" });
1940
+ writeStream.write("[\n");
1941
+ let isFirst = true;
1942
+ for (const record of aggregatedRecords.values()) {
1943
+ if (!isFirst) {
1944
+ writeStream.write(",\n");
1945
+ }
1946
+ writeStream.write(JSON.stringify(record));
1947
+ isFirst = false;
1948
+ }
1949
+ writeStream.write("\n]");
1950
+ writeStream.end();
1951
+ await new Promise((resolve) => {
1952
+ writeStream.on("finish", () => resolve());
1953
+ });
1954
+ import_fs2.default.rmSync(tempRecordsPath, { force: true });
1781
1955
  }
1782
1956
  writeIndexJson() {
1783
1957
  const outputFilePath = import_path.default.join(
@@ -1799,54 +1973,25 @@ Simulating game mode: ${mode}`);
1799
1973
  writeFile(outputFilePath, JSON.stringify({ modes }, null, 2));
1800
1974
  }
1801
1975
  async writeBooksJson(gameMode) {
1802
- const outputFileName = `books_${gameMode}.jsonl`;
1803
1976
  const outputFilePath = import_path.default.join(
1804
1977
  this.gameConfig.rootDir,
1805
1978
  this.gameConfig.outputDir,
1806
- outputFileName
1979
+ `books_${gameMode}.jsonl`
1807
1980
  );
1808
- const books = Array.from(this.library.values()).map((b) => b.serialize()).map((b) => ({
1809
- id: b.id,
1810
- payoutMultiplier: b.payout,
1811
- events: b.events
1812
- })).sort((a, b) => a.id - b.id);
1813
- const contents = JSONL.stringify(books);
1814
- writeFile(outputFilePath, contents);
1815
- const compressedFileName = `books_${gameMode}.jsonl.zst`;
1816
1981
  const compressedFilePath = import_path.default.join(
1817
1982
  this.gameConfig.rootDir,
1818
1983
  this.gameConfig.outputDir,
1819
1984
  "publish_files",
1820
- compressedFileName
1985
+ `books_${gameMode}.jsonl.zst`
1821
1986
  );
1822
1987
  import_fs2.default.rmSync(compressedFilePath, { force: true });
1823
- const compressed = import_zlib.default.zstdCompressSync(Buffer.from(contents));
1824
- import_fs2.default.writeFileSync(compressedFilePath, compressed);
1825
- }
1826
- logSymbolOccurrences() {
1827
- const validRecords = this.recorder.records.filter(
1828
- (r) => r.search.some((s) => s.name === "symbolId") && r.search.some((s) => s.name === "kind")
1829
- );
1830
- const structuredRecords = validRecords.map((r) => {
1831
- const symbolEntry = r.search.find((s) => s.name === "symbolId");
1832
- const kindEntry = r.search.find((s) => s.name === "kind");
1833
- const spinTypeEntry = r.search.find((s) => s.name === "spinType");
1834
- return {
1835
- symbol: symbolEntry ? symbolEntry.value : "unknown",
1836
- kind: kindEntry ? kindEntry.value : "unknown",
1837
- spinType: spinTypeEntry ? spinTypeEntry.value : "unknown",
1838
- timesTriggered: r.timesTriggered
1839
- };
1840
- }).sort((a, b) => {
1841
- if (a.symbol < b.symbol) return -1;
1842
- if (a.symbol > b.symbol) return 1;
1843
- if (a.kind < b.kind) return -1;
1844
- if (a.kind > b.kind) return 1;
1845
- if (a.spinType < b.spinType) return -1;
1846
- if (a.spinType > b.spinType) return 1;
1847
- return 0;
1848
- });
1849
- console.table(structuredRecords);
1988
+ if (import_fs2.default.existsSync(outputFilePath)) {
1989
+ await (0, import_promises.pipeline)(
1990
+ import_fs2.default.createReadStream(outputFilePath),
1991
+ import_zlib.default.createZstdCompress(),
1992
+ import_fs2.default.createWriteStream(compressedFilePath)
1993
+ );
1994
+ }
1850
1995
  }
1851
1996
  /**
1852
1997
  * Compiles user configured game to JS for use in different Node processes
@@ -1884,32 +2029,6 @@ Simulating game mode: ${mode}`);
1884
2029
  }
1885
2030
  return result;
1886
2031
  }
1887
- mergeRecords(otherRecords) {
1888
- for (const otherRecord of otherRecords) {
1889
- let record = this.recorder.records.find((r) => {
1890
- if (r.search.length !== otherRecord.search.length) return false;
1891
- for (let i = 0; i < r.search.length; i++) {
1892
- if (r.search[i].name !== otherRecord.search[i].name) return false;
1893
- if (r.search[i].value !== otherRecord.search[i].value) return false;
1894
- }
1895
- return true;
1896
- });
1897
- if (!record) {
1898
- record = {
1899
- search: otherRecord.search,
1900
- timesTriggered: 0,
1901
- bookIds: []
1902
- };
1903
- this.recorder.records.push(record);
1904
- }
1905
- record.timesTriggered += otherRecord.timesTriggered;
1906
- for (const bookId of otherRecord.bookIds) {
1907
- if (!record.bookIds.includes(bookId)) {
1908
- record.bookIds.push(bookId);
1909
- }
1910
- }
1911
- }
1912
- }
1913
2032
  /**
1914
2033
  * Generates reelset CSV files for all game modes.
1915
2034
  */
@@ -2523,11 +2642,6 @@ var Optimizer = class {
2523
2642
  );
2524
2643
  }
2525
2644
  }
2526
- const criteria = configMode.resultSets.map((r) => r.criteria);
2527
- (0, import_assert9.default)(
2528
- conditions.every((c) => criteria.includes(c)),
2529
- `Not all ResultSet criteria in game mode "${k}" are defined as optimization conditions.`
2530
- );
2531
2645
  let gameModeRtp = configMode.rtp;
2532
2646
  let paramRtp = 0;
2533
2647
  for (const cond of conditions) {
@@ -2580,6 +2694,7 @@ async function rustProgram(...args) {
2580
2694
  }
2581
2695
 
2582
2696
  // src/slot-game/index.ts
2697
+ var import_worker_threads4 = require("worker_threads");
2583
2698
  var SlotGame = class {
2584
2699
  configOpts;
2585
2700
  simulation;
@@ -2655,6 +2770,7 @@ var SlotGame = class {
2655
2770
  if (opts.doAnalysis) {
2656
2771
  await this.runAnalysis(opts.analysisOpts || { gameModes: [] });
2657
2772
  }
2773
+ if (import_worker_threads4.isMainThread) console.log("Finishing up...");
2658
2774
  }
2659
2775
  /**
2660
2776
  * Gets the game configuration.
@@ -2936,13 +3052,14 @@ var ClusterWinType = class extends WinType {
2936
3052
  }
2937
3053
  }
2938
3054
  }
2939
- potentialClusters.forEach((cluster) => {
3055
+ for (const cluster of potentialClusters) {
2940
3056
  const kind = cluster.length;
2941
3057
  let baseSymbol = cluster.find((s) => !this.isWild(s.symbol))?.symbol;
2942
3058
  if (!baseSymbol) baseSymbol = cluster[0].symbol;
2943
3059
  const payout = this.getSymbolPayout(baseSymbol, kind);
3060
+ if (payout === 0) continue;
2944
3061
  if (!baseSymbol.pays || Object.keys(baseSymbol.pays).length === 0) {
2945
- return;
3062
+ continue;
2946
3063
  }
2947
3064
  clusterWins.push({
2948
3065
  payout,
@@ -2955,7 +3072,7 @@ var ClusterWinType = class extends WinType {
2955
3072
  posIndex: s.row
2956
3073
  }))
2957
3074
  });
2958
- });
3075
+ }
2959
3076
  for (const win of clusterWins) {
2960
3077
  this.ctx.services.data.recordSymbolOccurrence({
2961
3078
  kind: win.kind,
@@ -3120,7 +3237,7 @@ var ManywaysWinType = class extends WinType {
3120
3237
  // src/reel-set/GeneratedReelSet.ts
3121
3238
  var import_fs5 = __toESM(require("fs"));
3122
3239
  var import_path7 = __toESM(require("path"));
3123
- var import_worker_threads4 = require("worker_threads");
3240
+ var import_worker_threads5 = require("worker_threads");
3124
3241
 
3125
3242
  // src/reel-set/index.ts
3126
3243
  var import_fs4 = __toESM(require("fs"));
@@ -3465,7 +3582,7 @@ var GeneratedReelSet = class extends ReelSet {
3465
3582
  }
3466
3583
  }
3467
3584
  const csvString = csvRows.map((row) => row.join(",")).join("\n");
3468
- if (import_worker_threads4.isMainThread) {
3585
+ if (import_worker_threads5.isMainThread) {
3469
3586
  import_fs5.default.writeFileSync(filePath, csvString);
3470
3587
  this.reels = this.parseReelsetCSV(filePath, config);
3471
3588
  console.log(