@slot-engine/core 0.2.3 → 0.2.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.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;
@@ -785,7 +786,6 @@ declare class DataService<TGameModes extends AnyGameModes = AnyGameModes, TSymbo
785
786
  recordSymbolOccurrence(data: {
786
787
  kind: number;
787
788
  symbolId: string;
788
- spinType: SpinType;
789
789
  [key: string]: any;
790
790
  }): void;
791
791
  /**
@@ -1246,7 +1246,40 @@ interface PayoutStatistics {
1246
1246
  };
1247
1247
  }
1248
1248
  interface AnalysisOpts {
1249
+ /**
1250
+ * Which game modes to analyze.
1251
+ */
1249
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;
1250
1283
  }
1251
1284
  interface Statistics {
1252
1285
  gameMode: string;
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;
@@ -785,7 +786,6 @@ declare class DataService<TGameModes extends AnyGameModes = AnyGameModes, TSymbo
785
786
  recordSymbolOccurrence(data: {
786
787
  kind: number;
787
788
  symbolId: string;
788
- spinType: SpinType;
789
789
  [key: string]: any;
790
790
  }): void;
791
791
  /**
@@ -1246,7 +1246,40 @@ interface PayoutStatistics {
1246
1246
  };
1247
1247
  }
1248
1248
  interface AnalysisOpts {
1249
+ /**
1250
+ * Which game modes to analyze.
1251
+ */
1249
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;
1250
1283
  }
1251
1284
  interface Statistics {
1252
1285
  gameMode: string;
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) {
@@ -447,11 +448,11 @@ var GameSymbol = class _GameSymbol {
447
448
  * Creates a clone of this GameSymbol.
448
449
  */
449
450
  clone() {
450
- return new _GameSymbol({
451
- id: this.id,
452
- pays: this.pays ? { ...this.pays } : void 0,
453
- properties: Object.fromEntries(this.properties)
454
- });
451
+ const cloned = Object.create(_GameSymbol.prototype);
452
+ cloned.id = this.id;
453
+ cloned.pays = this.pays;
454
+ cloned.properties = this.properties.size > 0 ? new Map(this.properties) : /* @__PURE__ */ new Map();
455
+ return cloned;
455
456
  }
456
457
  };
457
458
 
@@ -504,6 +505,9 @@ var Board = class {
504
505
  setSymbol(reelIndex, rowIndex, symbol) {
505
506
  this.reels[reelIndex] = this.reels[reelIndex] || [];
506
507
  this.reels[reelIndex][rowIndex] = symbol;
508
+ this.updateSymbol(reelIndex, rowIndex, {
509
+ position: [reelIndex, rowIndex]
510
+ });
507
511
  }
508
512
  removeSymbol(reelIndex, rowIndex) {
509
513
  if (this.reels[reelIndex]) {
@@ -712,16 +716,19 @@ var Board = class {
712
716
  const reelLength = opts.reels[ridx].length;
713
717
  for (let p = padSymbols - 1; p >= 0; p--) {
714
718
  const topPos = ((reelPos - (p + 1)) % reelLength + reelLength) % reelLength;
715
- this.paddingTop[ridx].push(opts.reels[ridx][topPos]);
719
+ this.paddingTop[ridx].push(opts.reels[ridx][topPos].clone());
716
720
  const bottomPos = (reelPos + symbolsPerReel[ridx] + p) % reelLength;
717
- this.paddingBottom[ridx].unshift(opts.reels[ridx][bottomPos]);
721
+ this.paddingBottom[ridx].unshift(opts.reels[ridx][bottomPos].clone());
718
722
  }
719
723
  for (let row = 0; row < symbolsPerReel[ridx]; row++) {
720
724
  const symbol = opts.reels[ridx][(reelPos + row) % reelLength];
721
725
  if (!symbol) {
722
726
  throw new Error(`Failed to get symbol at pos ${reelPos + row} on reel ${ridx}`);
723
727
  }
724
- this.reels[ridx][row] = symbol;
728
+ this.reels[ridx][row] = symbol.clone();
729
+ this.updateSymbol(ridx, row, {
730
+ position: [ridx, row]
731
+ });
725
732
  }
726
733
  }
727
734
  return {
@@ -789,6 +796,7 @@ var Board = class {
789
796
  newSymbol = forcedSym;
790
797
  }
791
798
  (0, import_assert3.default)(newSymbol, "Failed to get new symbol for tumbling.");
799
+ newSymbol = newSymbol.clone();
792
800
  this.reels[ridx].unshift(newSymbol);
793
801
  newFirstSymbolPositions[ridx] = symbolPos;
794
802
  if (!newBoardSymbols[ridx]) {
@@ -802,7 +810,7 @@ var Board = class {
802
810
  if (firstSymbolPos === void 0) continue;
803
811
  for (let p = 1; p <= padSymbols; p++) {
804
812
  const topPos = (firstSymbolPos - p + reels[ridx].length) % reels[ridx].length;
805
- const padSymbol = reels[ridx][topPos];
813
+ const padSymbol = reels[ridx][topPos]?.clone();
806
814
  (0, import_assert3.default)(padSymbol, "Failed to get new padding symbol for tumbling.");
807
815
  this.paddingTop[ridx].unshift(padSymbol);
808
816
  if (!newPaddingTopSymbols[ridx]) {
@@ -816,6 +824,14 @@ var Board = class {
816
824
  return newFirstSymbolPositions[ridx] ?? stop;
817
825
  });
818
826
  }
827
+ for (let ridx = 0; ridx < reelsAmount; ridx++) {
828
+ const reel = this.reels[ridx];
829
+ for (let rowIdx = 0; rowIdx < reel.length; rowIdx++) {
830
+ this.updateSymbol(ridx, rowIdx, {
831
+ position: [ridx, rowIdx]
832
+ });
833
+ }
834
+ }
819
835
  return {
820
836
  newBoardSymbols,
821
837
  newPaddingTopSymbols
@@ -1101,7 +1117,10 @@ var DataService = class extends AbstractService {
1101
1117
  * Calls `ctx.services.data.record()` with the provided data.
1102
1118
  */
1103
1119
  recordSymbolOccurrence(data) {
1104
- this.record(data);
1120
+ this.record({
1121
+ ...data,
1122
+ spinType: this.ctx().state.currentSpinType
1123
+ });
1105
1124
  }
1106
1125
  /**
1107
1126
  * Adds an event to the book.
@@ -3123,15 +3142,21 @@ function getLessBetHitrate(payoutWeights, cost) {
3123
3142
 
3124
3143
  // src/analysis/index.ts
3125
3144
  var import_worker_threads3 = require("worker_threads");
3145
+ var import_chalk3 = __toESM(require("chalk"));
3126
3146
  var Analysis = class {
3127
3147
  game;
3128
3148
  constructor(game) {
3129
3149
  this.game = game;
3130
3150
  }
3131
- async runAnalysis(gameModes) {
3151
+ async runAnalysis(opts) {
3152
+ const { gameModes, recordStats = [] } = opts;
3132
3153
  if (!import_worker_threads3.isMainThread) return;
3154
+ console.log(import_chalk3.default.gray("Starting analysis..."));
3133
3155
  this.getNumberStats(gameModes);
3134
3156
  this.getWinRanges(gameModes);
3157
+ if (recordStats.length > 0) {
3158
+ this.getRecordStats(gameModes, recordStats);
3159
+ }
3135
3160
  console.log("Analysis complete. Files written to build directory.");
3136
3161
  }
3137
3162
  getNumberStats(gameModes) {
@@ -3308,6 +3333,85 @@ var Analysis = class {
3308
3333
  }
3309
3334
  writeJsonFile(meta.paths.statsPayouts, payoutRanges);
3310
3335
  }
3336
+ getRecordStats(gameModes, recordStatsConfig) {
3337
+ const meta = this.game.getMetadata();
3338
+ const allStats = [];
3339
+ for (const modeStr of gameModes) {
3340
+ const lutOptimized = parseLookupTable(
3341
+ import_fs4.default.readFileSync(meta.paths.lookupTablePublish(modeStr), "utf-8")
3342
+ );
3343
+ const totalWeight = getTotalLutWeight(lutOptimized);
3344
+ const weightMap = /* @__PURE__ */ new Map();
3345
+ lutOptimized.forEach(([bookId, weight]) => {
3346
+ weightMap.set(bookId, weight);
3347
+ });
3348
+ const forceRecordsPath = meta.paths.forceRecords(modeStr);
3349
+ if (!import_fs4.default.existsSync(forceRecordsPath)) continue;
3350
+ const forceRecords = JSON.parse(
3351
+ import_fs4.default.readFileSync(forceRecordsPath, "utf-8")
3352
+ );
3353
+ const modeStats = {
3354
+ gameMode: modeStr,
3355
+ groups: []
3356
+ };
3357
+ for (const config of recordStatsConfig) {
3358
+ const groupName = config.name || config.groupBy.join("_");
3359
+ const aggregated = /* @__PURE__ */ new Map();
3360
+ for (const record of forceRecords) {
3361
+ const searchMap = new Map(record.search.map((s) => [s.name, s.value]));
3362
+ if (config.filter) {
3363
+ let matches = true;
3364
+ for (const [key2, value] of Object.entries(config.filter)) {
3365
+ if (searchMap.get(key2) !== value) {
3366
+ matches = false;
3367
+ break;
3368
+ }
3369
+ }
3370
+ if (!matches) continue;
3371
+ }
3372
+ const hasAllProps = config.groupBy.every((prop) => searchMap.has(prop));
3373
+ if (!hasAllProps) continue;
3374
+ const key = config.groupBy.map((prop) => searchMap.get(prop)).join("|");
3375
+ const properties = Object.fromEntries(
3376
+ config.groupBy.map((prop) => [prop, searchMap.get(prop)])
3377
+ );
3378
+ let totalWeight2 = 0;
3379
+ for (const bookId of record.bookIds) {
3380
+ totalWeight2 += weightMap.get(bookId) ?? 0;
3381
+ }
3382
+ const existing = aggregated.get(key);
3383
+ if (existing) {
3384
+ existing.count += record.timesTriggered;
3385
+ existing.totalWeight += totalWeight2;
3386
+ } else {
3387
+ aggregated.set(key, {
3388
+ properties,
3389
+ count: record.timesTriggered,
3390
+ totalWeight: totalWeight2
3391
+ });
3392
+ }
3393
+ }
3394
+ const items = Array.from(aggregated.entries()).map(([key, data]) => {
3395
+ const hitRate = round(totalWeight / data.totalWeight, 4);
3396
+ return {
3397
+ key,
3398
+ properties: data.properties,
3399
+ count: data.count,
3400
+ hitRateString: `1 in ${Math.round(hitRate).toLocaleString()}`,
3401
+ hitRate
3402
+ };
3403
+ }).sort((a, b) => a.hitRate - b.hitRate);
3404
+ modeStats.groups.push({
3405
+ name: groupName,
3406
+ groupBy: config.groupBy,
3407
+ filter: config.filter,
3408
+ items
3409
+ });
3410
+ }
3411
+ allStats.push(modeStats);
3412
+ }
3413
+ writeJsonFile(meta.paths.statsRecords, allStats);
3414
+ }
3311
3415
  getGameModeConfig(mode) {
3312
3416
  const config = this.game.getConfig().gameModes[mode];
3313
3417
  (0, import_assert6.default)(config, `Game mode "${mode}" not found in game config`);
@@ -3672,7 +3776,7 @@ var SlotGame = class _SlotGame {
3672
3776
  */
3673
3777
  runAnalysis(opts) {
3674
3778
  this.analyzer = new Analysis(this);
3675
- this.analyzer.runAnalysis(opts.gameModes);
3779
+ this.analyzer.runAnalysis(opts);
3676
3780
  }
3677
3781
  /**
3678
3782
  * Runs the configured tasks: simulation, optimization, and/or analysis.