@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 +34 -1
- package/dist/index.d.ts +34 -1
- package/dist/index.js +117 -13
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +117 -13
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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(
|
|
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(
|
|
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
|
|
3779
|
+
this.analyzer.runAnalysis(opts);
|
|
3676
3780
|
}
|
|
3677
3781
|
/**
|
|
3678
3782
|
* Runs the configured tasks: simulation, optimization, and/or analysis.
|