@slot-engine/core 0.2.2 → 0.2.4

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.d.mts CHANGED
@@ -64,6 +64,7 @@ type PermanentFilePaths = {
64
64
  simulationSummary: string;
65
65
  statsPayouts: string;
66
66
  statsSummary: string;
67
+ statsRecords: string;
67
68
  };
68
69
  type TemporaryFilePaths = {
69
70
  tempBooks: (mode: string, i: number) => string;
@@ -558,6 +559,10 @@ declare class BoardService<TGameModes extends AnyGameModes = AnyGameModes, TSymb
558
559
  * Removes the symbol at the specified reel and row index.
559
560
  */
560
561
  removeSymbol(reelIndex: number, rowIndex: number): void;
562
+ /**
563
+ * Updates properties of the symbol at the specified reel and row index.
564
+ */
565
+ updateSymbol(reelIndex: number, rowIndex: number, properties: Record<string, any>): void;
561
566
  private resetReels;
562
567
  /**
563
568
  * Sets the anticipation value for a specific reel.
@@ -781,7 +786,6 @@ declare class DataService<TGameModes extends AnyGameModes = AnyGameModes, TSymbo
781
786
  recordSymbolOccurrence(data: {
782
787
  kind: number;
783
788
  symbolId: string;
784
- spinType: SpinType;
785
789
  [key: string]: any;
786
790
  }): void;
787
791
  /**
@@ -1242,7 +1246,40 @@ interface PayoutStatistics {
1242
1246
  };
1243
1247
  }
1244
1248
  interface AnalysisOpts {
1249
+ /**
1250
+ * Which game modes to analyze.
1251
+ */
1245
1252
  gameModes: string[];
1253
+ /**
1254
+ * Configure which recorded properties to analyze.
1255
+ * This will provide you with hit rates for the specified groupings.
1256
+ * Each entry defines a grouping strategy for statistics.
1257
+ *
1258
+ * @example
1259
+ * ```ts
1260
+ * recordStats: [
1261
+ * { groupBy: ["symbolId", "kind", "spinType"] }, // All win combinations
1262
+ * { groupBy: ["symbolId", "kind"], filter: { spinType: "basegame" } }, // Base game win combinations only
1263
+ * { groupBy: ["criteria"] }, // Hit rate by result set criteria
1264
+ * ]
1265
+ * ```
1266
+ */
1267
+ recordStats?: RecordStatsConfig[];
1268
+ }
1269
+ interface RecordStatsConfig {
1270
+ /**
1271
+ * Properties to group by from the recorded search entries.\
1272
+ * E.g. `["symbolId", "kind", "spinType"]` for symbol hit rates.
1273
+ */
1274
+ groupBy: string[];
1275
+ /**
1276
+ * Optional filter to only include records matching these values.
1277
+ */
1278
+ filter?: Record<string, string>;
1279
+ /**
1280
+ * Optional custom name for this stats group in the output.
1281
+ */
1282
+ name?: string;
1246
1283
  }
1247
1284
  interface Statistics {
1248
1285
  gameMode: string;
@@ -1554,7 +1591,9 @@ declare class ManywaysWinType extends WinType {
1554
1591
  * Calculates wins based on the defined paylines and provided board state.\
1555
1592
  * Retrieve the results using `getWins()` after.
1556
1593
  */
1557
- evaluateWins(board: Reels): this;
1594
+ evaluateWins(board: Reels, opts?: {
1595
+ jumpGaps?: boolean;
1596
+ }): this;
1558
1597
  private getWayLength;
1559
1598
  }
1560
1599
  interface ManywaysWinTypeOpts extends WinTypeOpts {
@@ -1748,6 +1787,10 @@ declare class StandaloneBoard {
1748
1787
  * Removes the symbol at the specified reel and row index.
1749
1788
  */
1750
1789
  removeSymbol(reelIndex: number, rowIndex: number): void;
1790
+ /**
1791
+ * Updates properties of the symbol at the specified reel and row index.
1792
+ */
1793
+ updateSymbol(reelIndex: number, rowIndex: number, properties: Record<string, any>): void;
1751
1794
  private resetReels;
1752
1795
  /**
1753
1796
  * Sets the anticipation value for a specific reel.
package/dist/index.d.ts CHANGED
@@ -64,6 +64,7 @@ type PermanentFilePaths = {
64
64
  simulationSummary: string;
65
65
  statsPayouts: string;
66
66
  statsSummary: string;
67
+ statsRecords: string;
67
68
  };
68
69
  type TemporaryFilePaths = {
69
70
  tempBooks: (mode: string, i: number) => string;
@@ -558,6 +559,10 @@ declare class BoardService<TGameModes extends AnyGameModes = AnyGameModes, TSymb
558
559
  * Removes the symbol at the specified reel and row index.
559
560
  */
560
561
  removeSymbol(reelIndex: number, rowIndex: number): void;
562
+ /**
563
+ * Updates properties of the symbol at the specified reel and row index.
564
+ */
565
+ updateSymbol(reelIndex: number, rowIndex: number, properties: Record<string, any>): void;
561
566
  private resetReels;
562
567
  /**
563
568
  * Sets the anticipation value for a specific reel.
@@ -781,7 +786,6 @@ declare class DataService<TGameModes extends AnyGameModes = AnyGameModes, TSymbo
781
786
  recordSymbolOccurrence(data: {
782
787
  kind: number;
783
788
  symbolId: string;
784
- spinType: SpinType;
785
789
  [key: string]: any;
786
790
  }): void;
787
791
  /**
@@ -1242,7 +1246,40 @@ interface PayoutStatistics {
1242
1246
  };
1243
1247
  }
1244
1248
  interface AnalysisOpts {
1249
+ /**
1250
+ * Which game modes to analyze.
1251
+ */
1245
1252
  gameModes: string[];
1253
+ /**
1254
+ * Configure which recorded properties to analyze.
1255
+ * This will provide you with hit rates for the specified groupings.
1256
+ * Each entry defines a grouping strategy for statistics.
1257
+ *
1258
+ * @example
1259
+ * ```ts
1260
+ * recordStats: [
1261
+ * { groupBy: ["symbolId", "kind", "spinType"] }, // All win combinations
1262
+ * { groupBy: ["symbolId", "kind"], filter: { spinType: "basegame" } }, // Base game win combinations only
1263
+ * { groupBy: ["criteria"] }, // Hit rate by result set criteria
1264
+ * ]
1265
+ * ```
1266
+ */
1267
+ recordStats?: RecordStatsConfig[];
1268
+ }
1269
+ interface RecordStatsConfig {
1270
+ /**
1271
+ * Properties to group by from the recorded search entries.\
1272
+ * E.g. `["symbolId", "kind", "spinType"]` for symbol hit rates.
1273
+ */
1274
+ groupBy: string[];
1275
+ /**
1276
+ * Optional filter to only include records matching these values.
1277
+ */
1278
+ filter?: Record<string, string>;
1279
+ /**
1280
+ * Optional custom name for this stats group in the output.
1281
+ */
1282
+ name?: string;
1246
1283
  }
1247
1284
  interface Statistics {
1248
1285
  gameMode: string;
@@ -1554,7 +1591,9 @@ declare class ManywaysWinType extends WinType {
1554
1591
  * Calculates wins based on the defined paylines and provided board state.\
1555
1592
  * Retrieve the results using `getWins()` after.
1556
1593
  */
1557
- evaluateWins(board: Reels): this;
1594
+ evaluateWins(board: Reels, opts?: {
1595
+ jumpGaps?: boolean;
1596
+ }): this;
1558
1597
  private getWayLength;
1559
1598
  }
1560
1599
  interface ManywaysWinTypeOpts extends WinTypeOpts {
@@ -1748,6 +1787,10 @@ declare class StandaloneBoard {
1748
1787
  * Removes the symbol at the specified reel and row index.
1749
1788
  */
1750
1789
  removeSymbol(reelIndex: number, rowIndex: number): void;
1790
+ /**
1791
+ * Updates properties of the symbol at the specified reel and row index.
1792
+ */
1793
+ updateSymbol(reelIndex: number, rowIndex: number, properties: Record<string, any>): void;
1751
1794
  private resetReels;
1752
1795
  /**
1753
1796
  * Sets the anticipation value for a specific reel.
package/dist/index.js CHANGED
@@ -92,7 +92,8 @@ function createPermanentFilePaths(basePath) {
92
92
  publishFiles: import_path.default.join(basePath, "publish_files"),
93
93
  simulationSummary: import_path.default.join(basePath, "simulation_summary.json"),
94
94
  statsPayouts: import_path.default.join(basePath, "stats_payouts.json"),
95
- statsSummary: import_path.default.join(basePath, "stats_summary.json")
95
+ statsSummary: import_path.default.join(basePath, "stats_summary.json"),
96
+ statsRecords: import_path.default.join(basePath, "stats_records.json")
96
97
  };
97
98
  }
98
99
  function createTemporaryFilePaths(basePath, tempFolder) {
@@ -510,6 +511,14 @@ var Board = class {
510
511
  this.reels[reelIndex].splice(rowIndex, 1);
511
512
  }
512
513
  }
514
+ updateSymbol(reelIndex, rowIndex, properties) {
515
+ const symbol = this.getSymbol(reelIndex, rowIndex);
516
+ if (symbol) {
517
+ for (const [key, value] of Object.entries(properties)) {
518
+ symbol.properties.set(key, value);
519
+ }
520
+ }
521
+ }
513
522
  makeEmptyReels(opts) {
514
523
  const length = opts.reelsAmount ?? opts.ctx.services.game.getCurrentGameMode().reelsAmount;
515
524
  (0, import_assert3.default)(length, "Cannot make empty reels without context or reelsAmount.");
@@ -879,6 +888,12 @@ var BoardService = class extends AbstractService {
879
888
  removeSymbol(reelIndex, rowIndex) {
880
889
  this.board.removeSymbol(reelIndex, rowIndex);
881
890
  }
891
+ /**
892
+ * Updates properties of the symbol at the specified reel and row index.
893
+ */
894
+ updateSymbol(reelIndex, rowIndex, properties) {
895
+ this.board.updateSymbol(reelIndex, rowIndex, properties);
896
+ }
882
897
  resetReels() {
883
898
  this.board.resetReels({
884
899
  ctx: this.ctx()
@@ -1087,7 +1102,10 @@ var DataService = class extends AbstractService {
1087
1102
  * Calls `ctx.services.data.record()` with the provided data.
1088
1103
  */
1089
1104
  recordSymbolOccurrence(data) {
1090
- this.record(data);
1105
+ this.record({
1106
+ ...data,
1107
+ spinType: this.ctx().state.currentSpinType
1108
+ });
1091
1109
  }
1092
1110
  /**
1093
1111
  * Adds an event to the book.
@@ -3109,15 +3127,21 @@ function getLessBetHitrate(payoutWeights, cost) {
3109
3127
 
3110
3128
  // src/analysis/index.ts
3111
3129
  var import_worker_threads3 = require("worker_threads");
3130
+ var import_chalk3 = __toESM(require("chalk"));
3112
3131
  var Analysis = class {
3113
3132
  game;
3114
3133
  constructor(game) {
3115
3134
  this.game = game;
3116
3135
  }
3117
- async runAnalysis(gameModes) {
3136
+ async runAnalysis(opts) {
3137
+ const { gameModes, recordStats = [] } = opts;
3118
3138
  if (!import_worker_threads3.isMainThread) return;
3139
+ console.log(import_chalk3.default.gray("Starting analysis..."));
3119
3140
  this.getNumberStats(gameModes);
3120
3141
  this.getWinRanges(gameModes);
3142
+ if (recordStats.length > 0) {
3143
+ this.getRecordStats(gameModes, recordStats);
3144
+ }
3121
3145
  console.log("Analysis complete. Files written to build directory.");
3122
3146
  }
3123
3147
  getNumberStats(gameModes) {
@@ -3294,6 +3318,85 @@ var Analysis = class {
3294
3318
  }
3295
3319
  writeJsonFile(meta.paths.statsPayouts, payoutRanges);
3296
3320
  }
3321
+ getRecordStats(gameModes, recordStatsConfig) {
3322
+ const meta = this.game.getMetadata();
3323
+ const allStats = [];
3324
+ for (const modeStr of gameModes) {
3325
+ const lutOptimized = parseLookupTable(
3326
+ import_fs4.default.readFileSync(meta.paths.lookupTablePublish(modeStr), "utf-8")
3327
+ );
3328
+ const totalWeight = getTotalLutWeight(lutOptimized);
3329
+ const weightMap = /* @__PURE__ */ new Map();
3330
+ lutOptimized.forEach(([bookId, weight]) => {
3331
+ weightMap.set(bookId, weight);
3332
+ });
3333
+ const forceRecordsPath = meta.paths.forceRecords(modeStr);
3334
+ if (!import_fs4.default.existsSync(forceRecordsPath)) continue;
3335
+ const forceRecords = JSON.parse(
3336
+ import_fs4.default.readFileSync(forceRecordsPath, "utf-8")
3337
+ );
3338
+ const modeStats = {
3339
+ gameMode: modeStr,
3340
+ groups: []
3341
+ };
3342
+ for (const config of recordStatsConfig) {
3343
+ const groupName = config.name || config.groupBy.join("_");
3344
+ const aggregated = /* @__PURE__ */ new Map();
3345
+ for (const record of forceRecords) {
3346
+ const searchMap = new Map(record.search.map((s) => [s.name, s.value]));
3347
+ if (config.filter) {
3348
+ let matches = true;
3349
+ for (const [key2, value] of Object.entries(config.filter)) {
3350
+ if (searchMap.get(key2) !== value) {
3351
+ matches = false;
3352
+ break;
3353
+ }
3354
+ }
3355
+ if (!matches) continue;
3356
+ }
3357
+ const hasAllProps = config.groupBy.every((prop) => searchMap.has(prop));
3358
+ if (!hasAllProps) continue;
3359
+ const key = config.groupBy.map((prop) => searchMap.get(prop)).join("|");
3360
+ const properties = Object.fromEntries(
3361
+ config.groupBy.map((prop) => [prop, searchMap.get(prop)])
3362
+ );
3363
+ let totalWeight2 = 0;
3364
+ for (const bookId of record.bookIds) {
3365
+ totalWeight2 += weightMap.get(bookId) ?? 0;
3366
+ }
3367
+ const existing = aggregated.get(key);
3368
+ if (existing) {
3369
+ existing.count += record.timesTriggered;
3370
+ existing.totalWeight += totalWeight2;
3371
+ } else {
3372
+ aggregated.set(key, {
3373
+ properties,
3374
+ count: record.timesTriggered,
3375
+ totalWeight: totalWeight2
3376
+ });
3377
+ }
3378
+ }
3379
+ const items = Array.from(aggregated.entries()).map(([key, data]) => {
3380
+ const hitRate = round(totalWeight / data.totalWeight, 4);
3381
+ return {
3382
+ key,
3383
+ properties: data.properties,
3384
+ count: data.count,
3385
+ hitRateString: `1 in ${Math.round(hitRate).toLocaleString()}`,
3386
+ hitRate
3387
+ };
3388
+ }).sort((a, b) => a.hitRate - b.hitRate);
3389
+ modeStats.groups.push({
3390
+ name: groupName,
3391
+ groupBy: config.groupBy,
3392
+ filter: config.filter,
3393
+ items
3394
+ });
3395
+ }
3396
+ allStats.push(modeStats);
3397
+ }
3398
+ writeJsonFile(meta.paths.statsRecords, allStats);
3399
+ }
3297
3400
  getGameModeConfig(mode) {
3298
3401
  const config = this.game.getConfig().gameModes[mode];
3299
3402
  (0, import_assert6.default)(config, `Game mode "${mode}" not found in game config`);
@@ -3658,7 +3761,7 @@ var SlotGame = class _SlotGame {
3658
3761
  */
3659
3762
  runAnalysis(opts) {
3660
3763
  this.analyzer = new Analysis(this);
3661
- this.analyzer.runAnalysis(opts.gameModes);
3764
+ this.analyzer.runAnalysis(opts);
3662
3765
  }
3663
3766
  /**
3664
3767
  * Runs the configured tasks: simulation, optimization, and/or analysis.
@@ -4104,27 +4207,36 @@ var ManywaysWinType = class extends WinType {
4104
4207
  * Calculates wins based on the defined paylines and provided board state.\
4105
4208
  * Retrieve the results using `getWins()` after.
4106
4209
  */
4107
- evaluateWins(board) {
4210
+ evaluateWins(board, opts = {}) {
4108
4211
  this.validateConfig();
4212
+ const { jumpGaps = false } = opts;
4109
4213
  const waysWins = [];
4110
4214
  const reels = board;
4111
4215
  const possibleWaysWins = /* @__PURE__ */ new Map();
4112
4216
  const candidateSymbols = /* @__PURE__ */ new Map();
4113
- let searchReelIdx = 0;
4114
- let searchActive = true;
4115
- while (searchActive && searchReelIdx < reels.length) {
4116
- const reel = reels[searchReelIdx];
4117
- let hasWild = false;
4118
- for (const symbol of reel) {
4119
- candidateSymbols.set(symbol.id, symbol);
4120
- if (this.isWild(symbol)) {
4121
- hasWild = true;
4217
+ if (jumpGaps) {
4218
+ for (const reel of reels) {
4219
+ for (const symbol of reel) {
4220
+ candidateSymbols.set(symbol.id, symbol);
4122
4221
  }
4123
4222
  }
4124
- if (!hasWild) {
4125
- searchActive = false;
4223
+ } else {
4224
+ let searchReelIdx = 0;
4225
+ let searchActive = true;
4226
+ while (searchActive && searchReelIdx < reels.length) {
4227
+ const reel = reels[searchReelIdx];
4228
+ let hasWild = false;
4229
+ for (const symbol of reel) {
4230
+ candidateSymbols.set(symbol.id, symbol);
4231
+ if (this.isWild(symbol)) {
4232
+ hasWild = true;
4233
+ }
4234
+ }
4235
+ if (!hasWild) {
4236
+ searchActive = false;
4237
+ }
4238
+ searchReelIdx++;
4126
4239
  }
4127
- searchReelIdx++;
4128
4240
  }
4129
4241
  for (const baseSymbol of candidateSymbols.values()) {
4130
4242
  let symbolList = {};
@@ -4140,7 +4252,7 @@ var ManywaysWinType = class extends WinType {
4140
4252
  symbolList[ridx].push({ reel: ridx, row: sidx, symbol });
4141
4253
  }
4142
4254
  }
4143
- if (!symbolList[ridx]) {
4255
+ if (!symbolList[ridx] && !jumpGaps) {
4144
4256
  isInterrupted = true;
4145
4257
  break;
4146
4258
  }
@@ -4156,7 +4268,7 @@ var ManywaysWinType = class extends WinType {
4156
4268
  for (const [baseSymbolId, symbolList] of possibleWaysWins.entries()) {
4157
4269
  const wayLength = this.getWayLength(symbolList);
4158
4270
  let baseSymbol = Object.values(symbolList).flatMap((l) => l.map((s) => s)).find((s) => !this.isWild(s.symbol))?.symbol;
4159
- if (!baseSymbol) baseSymbol = symbolList[0][0].symbol;
4271
+ if (!baseSymbol) baseSymbol = symbolList[Object.keys(symbolList)[0]][0].symbol;
4160
4272
  const singleWayPayout = this.getSymbolPayout(baseSymbol, wayLength);
4161
4273
  const totalWays = Object.values(symbolList).reduce(
4162
4274
  (ways, syms) => ways * syms.length,
@@ -4190,7 +4302,7 @@ var ManywaysWinType = class extends WinType {
4190
4302
  return this;
4191
4303
  }
4192
4304
  getWayLength(symbolList) {
4193
- return Math.max(...Object.keys(symbolList).map((k) => parseInt(k, 10))) + 1;
4305
+ return Object.keys(symbolList).length;
4194
4306
  }
4195
4307
  };
4196
4308
 
@@ -4652,6 +4764,12 @@ var StandaloneBoard = class {
4652
4764
  removeSymbol(reelIndex, rowIndex) {
4653
4765
  this.board.removeSymbol(reelIndex, rowIndex);
4654
4766
  }
4767
+ /**
4768
+ * Updates properties of the symbol at the specified reel and row index.
4769
+ */
4770
+ updateSymbol(reelIndex, rowIndex, properties) {
4771
+ this.board.updateSymbol(reelIndex, rowIndex, properties);
4772
+ }
4655
4773
  resetReels() {
4656
4774
  this.board.resetReels({
4657
4775
  ctx: this.ctx