@slot-engine/core 0.2.3 → 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;
@@ -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) {
@@ -1101,7 +1102,10 @@ var DataService = class extends AbstractService {
1101
1102
  * Calls `ctx.services.data.record()` with the provided data.
1102
1103
  */
1103
1104
  recordSymbolOccurrence(data) {
1104
- this.record(data);
1105
+ this.record({
1106
+ ...data,
1107
+ spinType: this.ctx().state.currentSpinType
1108
+ });
1105
1109
  }
1106
1110
  /**
1107
1111
  * Adds an event to the book.
@@ -3123,15 +3127,21 @@ function getLessBetHitrate(payoutWeights, cost) {
3123
3127
 
3124
3128
  // src/analysis/index.ts
3125
3129
  var import_worker_threads3 = require("worker_threads");
3130
+ var import_chalk3 = __toESM(require("chalk"));
3126
3131
  var Analysis = class {
3127
3132
  game;
3128
3133
  constructor(game) {
3129
3134
  this.game = game;
3130
3135
  }
3131
- async runAnalysis(gameModes) {
3136
+ async runAnalysis(opts) {
3137
+ const { gameModes, recordStats = [] } = opts;
3132
3138
  if (!import_worker_threads3.isMainThread) return;
3139
+ console.log(import_chalk3.default.gray("Starting analysis..."));
3133
3140
  this.getNumberStats(gameModes);
3134
3141
  this.getWinRanges(gameModes);
3142
+ if (recordStats.length > 0) {
3143
+ this.getRecordStats(gameModes, recordStats);
3144
+ }
3135
3145
  console.log("Analysis complete. Files written to build directory.");
3136
3146
  }
3137
3147
  getNumberStats(gameModes) {
@@ -3308,6 +3318,85 @@ var Analysis = class {
3308
3318
  }
3309
3319
  writeJsonFile(meta.paths.statsPayouts, payoutRanges);
3310
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
+ }
3311
3400
  getGameModeConfig(mode) {
3312
3401
  const config = this.game.getConfig().gameModes[mode];
3313
3402
  (0, import_assert6.default)(config, `Game mode "${mode}" not found in game config`);
@@ -3672,7 +3761,7 @@ var SlotGame = class _SlotGame {
3672
3761
  */
3673
3762
  runAnalysis(opts) {
3674
3763
  this.analyzer = new Analysis(this);
3675
- this.analyzer.runAnalysis(opts.gameModes);
3764
+ this.analyzer.runAnalysis(opts);
3676
3765
  }
3677
3766
  /**
3678
3767
  * Runs the configured tasks: simulation, optimization, and/or analysis.