@slot-engine/core 0.0.1
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/.turbo/turbo-build.log +33 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +7 -0
- package/README.md +8 -0
- package/dist/index.d.mts +1306 -0
- package/dist/index.d.ts +1306 -0
- package/dist/index.js +2929 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2874 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lib/zstd.exe +0 -0
- package/dist/optimizer-rust/Cargo.toml +19 -0
- package/dist/optimizer-rust/src/exes.rs +154 -0
- package/dist/optimizer-rust/src/main.rs +1659 -0
- package/index.ts +205 -0
- package/lib/zstd.exe +0 -0
- package/optimizer-rust/Cargo.toml +19 -0
- package/optimizer-rust/src/exes.rs +154 -0
- package/optimizer-rust/src/main.rs +1659 -0
- package/package.json +33 -0
- package/src/Board.ts +527 -0
- package/src/Book.ts +83 -0
- package/src/GameConfig.ts +148 -0
- package/src/GameMode.ts +86 -0
- package/src/GameState.ts +272 -0
- package/src/GameSymbol.ts +61 -0
- package/src/ReelGenerator.ts +589 -0
- package/src/ResultSet.ts +207 -0
- package/src/Simulation.ts +625 -0
- package/src/SlotGame.ts +117 -0
- package/src/Wallet.ts +203 -0
- package/src/WinType.ts +102 -0
- package/src/analysis/index.ts +198 -0
- package/src/analysis/utils.ts +128 -0
- package/src/optimizer/OptimizationConditions.ts +99 -0
- package/src/optimizer/OptimizationParameters.ts +46 -0
- package/src/optimizer/OptimizationScaling.ts +18 -0
- package/src/optimizer/index.ts +142 -0
- package/src/utils/math-config.ts +109 -0
- package/src/utils/setup-file.ts +36 -0
- package/src/utils/zstd.ts +28 -0
- package/src/winTypes/ClusterWinType.ts +3 -0
- package/src/winTypes/LinesWinType.ts +208 -0
- package/src/winTypes/ManywaysWinType.ts +3 -0
- package/tsconfig.json +19 -0
- package/utils.ts +270 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2874 @@
|
|
|
1
|
+
// src/GameConfig.ts
|
|
2
|
+
var GameConfig = class _GameConfig {
|
|
3
|
+
config;
|
|
4
|
+
constructor(opts) {
|
|
5
|
+
this.config = {
|
|
6
|
+
id: opts.id,
|
|
7
|
+
name: opts.name,
|
|
8
|
+
providerNumber: opts.providerNumber,
|
|
9
|
+
gameModes: opts.gameModes,
|
|
10
|
+
symbols: /* @__PURE__ */ new Map(),
|
|
11
|
+
padSymbols: opts.padSymbols || 0,
|
|
12
|
+
scatterToFreespins: opts.scatterToFreespins,
|
|
13
|
+
anticipationTriggers: {
|
|
14
|
+
[_GameConfig.SPIN_TYPE.BASE_GAME]: getAnticipationTrigger(
|
|
15
|
+
_GameConfig.SPIN_TYPE.BASE_GAME
|
|
16
|
+
),
|
|
17
|
+
[_GameConfig.SPIN_TYPE.FREE_SPINS]: getAnticipationTrigger(
|
|
18
|
+
_GameConfig.SPIN_TYPE.FREE_SPINS
|
|
19
|
+
)
|
|
20
|
+
},
|
|
21
|
+
maxWinX: opts.maxWinX,
|
|
22
|
+
hooks: opts.hooks,
|
|
23
|
+
userState: opts.userState,
|
|
24
|
+
outputDir: "__build__"
|
|
25
|
+
};
|
|
26
|
+
for (const symbol of opts.symbols) {
|
|
27
|
+
if (!this.config.symbols.has(symbol.id)) {
|
|
28
|
+
this.config.symbols.set(symbol.id, symbol);
|
|
29
|
+
} else {
|
|
30
|
+
console.warn(
|
|
31
|
+
`Symbol with id "${symbol.id}" already exists in the game config. Skipping duplicate. This is probably not intentional.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function getAnticipationTrigger(spinType) {
|
|
36
|
+
return Math.min(...Object.keys(opts.scatterToFreespins[spinType]).map(Number)) - 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Generates reelset CSV files for all game modes.
|
|
41
|
+
*/
|
|
42
|
+
generateReelsetFiles() {
|
|
43
|
+
for (const mode of Object.values(this.config.gameModes)) {
|
|
44
|
+
if (mode.reelSets && mode.reelSets.length > 0) {
|
|
45
|
+
for (const reelGenerator of Object.values(mode.reelSets)) {
|
|
46
|
+
reelGenerator.associatedGameModeName = mode.name;
|
|
47
|
+
reelGenerator.outputDir = this.config.outputDir;
|
|
48
|
+
reelGenerator.generateReels(this);
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Game mode "${mode.name}" has no reel sets defined. Cannot generate reelset files.`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Retrieves a reel set by its ID within a specific game mode.
|
|
59
|
+
*/
|
|
60
|
+
getReelsetById(gameMode, id) {
|
|
61
|
+
const reelSet = this.config.gameModes[gameMode].reelSets.find((rs) => rs.id === id);
|
|
62
|
+
if (!reelSet) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Reel set with id "${id}" not found in game mode "${gameMode}". Available reel sets: ${this.config.gameModes[gameMode].reelSets.map((rs) => rs.id).join(", ")}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return reelSet;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Retrieves the number of free spins awarded for a given spin type and scatter count.
|
|
71
|
+
*/
|
|
72
|
+
getFreeSpinsForScatters(spinType, scatterCount) {
|
|
73
|
+
const freespinsConfig = this.config.scatterToFreespins[spinType];
|
|
74
|
+
if (!freespinsConfig) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`No free spins configuration found for spin type "${spinType}". Please check your game configuration.`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return freespinsConfig[scatterCount] || 0;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Retrieves a result set by its criteria within a specific game mode.
|
|
83
|
+
*/
|
|
84
|
+
getGameModeCriteria(mode, criteria) {
|
|
85
|
+
const gameMode = this.config.gameModes[mode];
|
|
86
|
+
if (!gameMode) {
|
|
87
|
+
throw new Error(`Game mode "${mode}" not found in game config.`);
|
|
88
|
+
}
|
|
89
|
+
const resultSet = gameMode.resultSets.find((rs) => rs.criteria === criteria);
|
|
90
|
+
if (!resultSet) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Criteria "${criteria}" not found in game mode "${mode}". Available criteria: ${gameMode.resultSets.map((rs) => rs.criteria).join(", ")}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return resultSet;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Returns all configured symbols as an array.
|
|
99
|
+
*/
|
|
100
|
+
getSymbolArray() {
|
|
101
|
+
return Array.from(this.config.symbols).map(([n, v]) => v);
|
|
102
|
+
}
|
|
103
|
+
static SPIN_TYPE = {
|
|
104
|
+
BASE_GAME: "basegame",
|
|
105
|
+
FREE_SPINS: "freespins"
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// src/GameMode.ts
|
|
110
|
+
var GameMode = class {
|
|
111
|
+
name;
|
|
112
|
+
reelsAmount;
|
|
113
|
+
symbolsPerReel;
|
|
114
|
+
cost;
|
|
115
|
+
rtp;
|
|
116
|
+
reelSets;
|
|
117
|
+
resultSets;
|
|
118
|
+
isBonusBuy;
|
|
119
|
+
constructor(opts) {
|
|
120
|
+
this.name = opts.name;
|
|
121
|
+
this.reelsAmount = opts.reelsAmount;
|
|
122
|
+
this.symbolsPerReel = opts.symbolsPerReel;
|
|
123
|
+
this.cost = opts.cost;
|
|
124
|
+
this.rtp = opts.rtp;
|
|
125
|
+
this.reelSets = opts.reelSets;
|
|
126
|
+
this.resultSets = opts.resultSets;
|
|
127
|
+
this.isBonusBuy = opts.isBonusBuy;
|
|
128
|
+
if (this.symbolsPerReel.length !== this.reelsAmount) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`symbolsPerReel length (${this.symbolsPerReel.length}) must match reelsAmount (${this.reelsAmount}).`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (this.resultSets.length == 0) {
|
|
134
|
+
throw new Error("GameMode must have at least one ResultSet defined.");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// src/GameSymbol.ts
|
|
140
|
+
var GameSymbol = class _GameSymbol {
|
|
141
|
+
id;
|
|
142
|
+
pays;
|
|
143
|
+
properties;
|
|
144
|
+
constructor(opts) {
|
|
145
|
+
this.id = opts.id;
|
|
146
|
+
this.pays = opts.pays;
|
|
147
|
+
this.properties = new Map(Object.entries(opts.properties || {}));
|
|
148
|
+
if (this.pays && Object.keys(this.pays).length === 0) {
|
|
149
|
+
throw new Error(`GameSymbol "${this.id}" must have pays defined.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Compares this symbol to another symbol or a set of properties.
|
|
154
|
+
*/
|
|
155
|
+
compare(symbolOrProperties) {
|
|
156
|
+
if (!symbolOrProperties) {
|
|
157
|
+
console.warn("No symbol or properties provided for comparison.");
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
if (symbolOrProperties instanceof _GameSymbol) {
|
|
161
|
+
return this.id === symbolOrProperties.id;
|
|
162
|
+
} else {
|
|
163
|
+
for (const [key, value] of Object.entries(symbolOrProperties)) {
|
|
164
|
+
if (!this.properties.has(key) || this.properties.get(key) !== value) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// src/ReelGenerator.ts
|
|
174
|
+
import fs2 from "fs";
|
|
175
|
+
import path from "path";
|
|
176
|
+
|
|
177
|
+
// utils.ts
|
|
178
|
+
import fs from "fs";
|
|
179
|
+
function weightedRandom(weights, rng) {
|
|
180
|
+
const totalWeight = Object.values(weights).reduce(
|
|
181
|
+
(sum, weight) => sum + weight,
|
|
182
|
+
0
|
|
183
|
+
);
|
|
184
|
+
const randomValue = rng.randomFloat(0, 1) * totalWeight;
|
|
185
|
+
let cumulativeWeight = 0;
|
|
186
|
+
for (const [key, weight] of Object.entries(weights)) {
|
|
187
|
+
cumulativeWeight += weight;
|
|
188
|
+
if (randomValue < cumulativeWeight) {
|
|
189
|
+
return key;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
throw new Error("No item selected in weighted random selection.");
|
|
193
|
+
}
|
|
194
|
+
function randomItem(array, rng) {
|
|
195
|
+
if (array.length === 0) {
|
|
196
|
+
throw new Error("Cannot select a random item from an empty array.");
|
|
197
|
+
}
|
|
198
|
+
const randomIndex = Math.floor(rng.randomFloat(0, 1) * array.length);
|
|
199
|
+
return array[randomIndex];
|
|
200
|
+
}
|
|
201
|
+
function createDirIfNotExists(dirPath) {
|
|
202
|
+
if (!fs.existsSync(dirPath)) {
|
|
203
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function shuffle(array, rng) {
|
|
207
|
+
const newArray = [...array];
|
|
208
|
+
let currentIndex = newArray.length, randomIndex;
|
|
209
|
+
while (currentIndex != 0) {
|
|
210
|
+
randomIndex = Math.floor(rng.randomFloat(0, 1) * currentIndex);
|
|
211
|
+
currentIndex--;
|
|
212
|
+
[newArray[currentIndex], newArray[randomIndex]] = [
|
|
213
|
+
newArray[randomIndex],
|
|
214
|
+
newArray[currentIndex]
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
return newArray;
|
|
218
|
+
}
|
|
219
|
+
function writeJsonFile(filePath, data) {
|
|
220
|
+
try {
|
|
221
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), {
|
|
222
|
+
encoding: "utf8"
|
|
223
|
+
});
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw new Error(`Failed to write JSON file at ${filePath}: ${error}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function writeFile(filePath, data) {
|
|
229
|
+
try {
|
|
230
|
+
fs.writeFileSync(filePath, data, { encoding: "utf8" });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
throw new Error(`Failed to write file at ${filePath}: ${error}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
var RandomNumberGenerator = class {
|
|
236
|
+
mIdum;
|
|
237
|
+
mIy;
|
|
238
|
+
mIv;
|
|
239
|
+
NTAB;
|
|
240
|
+
IA;
|
|
241
|
+
IM;
|
|
242
|
+
IQ;
|
|
243
|
+
IR;
|
|
244
|
+
NDIV;
|
|
245
|
+
AM;
|
|
246
|
+
RNMX;
|
|
247
|
+
_currentSeed = 0;
|
|
248
|
+
constructor() {
|
|
249
|
+
this.mIdum = 0;
|
|
250
|
+
this.mIy = 0;
|
|
251
|
+
this.mIv = [];
|
|
252
|
+
this.NTAB = 32;
|
|
253
|
+
this.IA = 16807;
|
|
254
|
+
this.IM = 2147483647;
|
|
255
|
+
this.IQ = 127773;
|
|
256
|
+
this.IR = 2836;
|
|
257
|
+
this.NDIV = 1 + (this.IM - 1) / this.NTAB;
|
|
258
|
+
this.AM = 1 / this.IM;
|
|
259
|
+
this.RNMX = 1 - 12e-8;
|
|
260
|
+
}
|
|
261
|
+
getCurrentSeed() {
|
|
262
|
+
return this._currentSeed;
|
|
263
|
+
}
|
|
264
|
+
setCurrentSeed(seed) {
|
|
265
|
+
this._currentSeed = seed;
|
|
266
|
+
}
|
|
267
|
+
setSeed(seed) {
|
|
268
|
+
this.mIdum = seed;
|
|
269
|
+
this.setCurrentSeed(seed);
|
|
270
|
+
if (seed >= 0) {
|
|
271
|
+
this.mIdum = -seed;
|
|
272
|
+
}
|
|
273
|
+
this.mIy = 0;
|
|
274
|
+
}
|
|
275
|
+
setSeedIfDifferent(seed) {
|
|
276
|
+
if (this.getCurrentSeed() !== seed) {
|
|
277
|
+
this.setSeed(seed);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
generateRandomNumber() {
|
|
281
|
+
let k;
|
|
282
|
+
let j;
|
|
283
|
+
if (this.mIdum <= 0 || this.mIy === 0) {
|
|
284
|
+
if (-this.mIdum < 1) {
|
|
285
|
+
this.mIdum = 1;
|
|
286
|
+
} else {
|
|
287
|
+
this.mIdum = -this.mIdum;
|
|
288
|
+
}
|
|
289
|
+
for (j = this.NTAB + 7; j >= 0; j -= 1) {
|
|
290
|
+
k = Math.floor(this.mIdum / this.IQ);
|
|
291
|
+
this.mIdum = Math.floor(this.IA * (this.mIdum - k * this.IQ) - this.IR * k);
|
|
292
|
+
if (this.mIdum < 0) {
|
|
293
|
+
this.mIdum += this.IM;
|
|
294
|
+
}
|
|
295
|
+
if (j < this.NTAB) {
|
|
296
|
+
this.mIv[j] = this.mIdum;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
;
|
|
300
|
+
[this.mIy] = this.mIv;
|
|
301
|
+
}
|
|
302
|
+
k = Math.floor(this.mIdum / this.IQ);
|
|
303
|
+
this.mIdum = Math.floor(this.IA * (this.mIdum - k * this.IQ) - this.IR * k);
|
|
304
|
+
if (this.mIdum < 0) {
|
|
305
|
+
this.mIdum += this.IM;
|
|
306
|
+
}
|
|
307
|
+
j = Math.floor(this.mIy / this.NDIV);
|
|
308
|
+
this.mIy = Math.floor(this.mIv[j]);
|
|
309
|
+
this.mIv[j] = this.mIdum;
|
|
310
|
+
return this.mIy;
|
|
311
|
+
}
|
|
312
|
+
randomFloat(low, high) {
|
|
313
|
+
let float = this.AM * this.generateRandomNumber();
|
|
314
|
+
if (float > this.RNMX) {
|
|
315
|
+
float = this.RNMX;
|
|
316
|
+
}
|
|
317
|
+
return float * (high - low) + low;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
function copy(obj) {
|
|
321
|
+
return JSON.parse(JSON.stringify(obj));
|
|
322
|
+
}
|
|
323
|
+
var JSONL = class {
|
|
324
|
+
static stringify(array) {
|
|
325
|
+
return array.map((object) => JSON.stringify(object)).join("\n");
|
|
326
|
+
}
|
|
327
|
+
static parse(jsonl) {
|
|
328
|
+
return jsonl.split("\n").filter((s) => s !== "").map((str) => JSON.parse(str));
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// src/ReelGenerator.ts
|
|
333
|
+
import { isMainThread } from "worker_threads";
|
|
334
|
+
var ReelGenerator = class {
|
|
335
|
+
id;
|
|
336
|
+
associatedGameModeName = "";
|
|
337
|
+
symbolWeights = /* @__PURE__ */ new Map();
|
|
338
|
+
rowsAmount;
|
|
339
|
+
reels = [];
|
|
340
|
+
limitSymbolsToReels;
|
|
341
|
+
spaceBetweenSameSymbols;
|
|
342
|
+
spaceBetweenSymbols;
|
|
343
|
+
preferStackedSymbols;
|
|
344
|
+
symbolStacks;
|
|
345
|
+
symbolQuotas;
|
|
346
|
+
outputDir = "";
|
|
347
|
+
csvPath = "";
|
|
348
|
+
overrideExisting;
|
|
349
|
+
rng;
|
|
350
|
+
constructor(opts) {
|
|
351
|
+
this.id = opts.id;
|
|
352
|
+
this.symbolWeights = new Map(Object.entries(opts.symbolWeights));
|
|
353
|
+
this.rowsAmount = opts.rowsAmount || 250;
|
|
354
|
+
this.outputDir = opts.outputDir;
|
|
355
|
+
if (opts.limitSymbolsToReels) this.limitSymbolsToReels = opts.limitSymbolsToReels;
|
|
356
|
+
this.overrideExisting = opts.overrideExisting || false;
|
|
357
|
+
this.spaceBetweenSameSymbols = opts.spaceBetweenSameSymbols;
|
|
358
|
+
this.spaceBetweenSymbols = opts.spaceBetweenSymbols;
|
|
359
|
+
this.preferStackedSymbols = opts.preferStackedSymbols;
|
|
360
|
+
this.symbolStacks = opts.symbolStacks;
|
|
361
|
+
this.symbolQuotas = opts.symbolQuotas;
|
|
362
|
+
if (typeof this.spaceBetweenSameSymbols == "number" && (this.spaceBetweenSameSymbols < 1 || this.spaceBetweenSameSymbols > 8) || typeof this.spaceBetweenSameSymbols == "object" && Object.values(this.spaceBetweenSameSymbols).some((v) => v < 1 || v > 8)) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`spaceBetweenSameSymbols must be between 1 and 8, got ${this.spaceBetweenSameSymbols}.`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (Object.values(this.spaceBetweenSymbols || {}).some(
|
|
368
|
+
(o) => Object.values(o).some((v) => v < 1 || v > 8)
|
|
369
|
+
)) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`spaceBetweenSymbols must be between 1 and 8, got ${this.spaceBetweenSymbols}.`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (this.preferStackedSymbols && (this.preferStackedSymbols < 0 || this.preferStackedSymbols > 100)) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`preferStackedSymbols must be between 0 and 100, got ${this.preferStackedSymbols}.`
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
this.rng = new RandomNumberGenerator();
|
|
380
|
+
this.rng.setSeed(opts.seed ?? 0);
|
|
381
|
+
}
|
|
382
|
+
validateConfig({ config }) {
|
|
383
|
+
config.symbols.forEach((symbol) => {
|
|
384
|
+
if (!this.symbolWeights.has(symbol.id)) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
[
|
|
387
|
+
`Symbol "${symbol.id}" is not defined in the symbol weights of the reel generator ${this.id} for mode ${this.associatedGameModeName}.`,
|
|
388
|
+
`Please ensure all symbols have weights defined.
|
|
389
|
+
`
|
|
390
|
+
].join(" ")
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
for (const [symbolId, weight] of this.symbolWeights.entries()) {
|
|
395
|
+
if (!config.symbols.has(symbolId)) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
[
|
|
398
|
+
`Symbol "${symbolId}" is defined in the reel generator's symbol weights, but does not exist in the game config.`,
|
|
399
|
+
`Please ensure all symbols in the reel generator are defined in the game config.
|
|
400
|
+
`
|
|
401
|
+
].join(" ")
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (this.limitSymbolsToReels && Object.keys(this.limitSymbolsToReels).length == 0) {
|
|
406
|
+
this.limitSymbolsToReels = void 0;
|
|
407
|
+
}
|
|
408
|
+
if (this.outputDir === "") {
|
|
409
|
+
throw new Error("Output directory must be specified for the ReelGenerator.");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
isSymbolAllowedOnReel(symbolId, reelIdx) {
|
|
413
|
+
if (!this.limitSymbolsToReels) return true;
|
|
414
|
+
const allowedReels = this.limitSymbolsToReels[symbolId];
|
|
415
|
+
if (!allowedReels || allowedReels.length === 0) return true;
|
|
416
|
+
return allowedReels.includes(reelIdx);
|
|
417
|
+
}
|
|
418
|
+
resolveStacking(symbolId, reelIdx) {
|
|
419
|
+
const cfg = this.symbolStacks?.[symbolId];
|
|
420
|
+
if (!cfg) return null;
|
|
421
|
+
const STACKING_MIN = 1;
|
|
422
|
+
const STACKING_MAX = 4;
|
|
423
|
+
const chance = typeof cfg.chance === "number" ? cfg.chance : cfg.chance?.[reelIdx] ?? 0;
|
|
424
|
+
if (chance <= 0) return null;
|
|
425
|
+
let min = typeof cfg.min === "number" ? cfg.min : cfg.min?.[reelIdx] ?? STACKING_MIN;
|
|
426
|
+
let max = typeof cfg.max === "number" ? cfg.max : cfg.max?.[reelIdx] ?? STACKING_MAX;
|
|
427
|
+
return { chance, min, max };
|
|
428
|
+
}
|
|
429
|
+
tryPlaceStack(reel, gameConf, reelIdx, symbolId, startIndex, maxStack) {
|
|
430
|
+
if (!this.isSymbolAllowedOnReel(symbolId, reelIdx)) return 0;
|
|
431
|
+
let canPlace = 0;
|
|
432
|
+
for (let j = 0; j < maxStack; j++) {
|
|
433
|
+
const idx = (startIndex + j) % this.rowsAmount;
|
|
434
|
+
if (reel[idx] !== null) break;
|
|
435
|
+
canPlace++;
|
|
436
|
+
}
|
|
437
|
+
if (canPlace === 0) return 0;
|
|
438
|
+
const symObj = gameConf.config.symbols.get(symbolId);
|
|
439
|
+
if (!symObj) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`Symbol with id "${symbolId}" not found in the game config symbols map.`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
for (let j = 0; j < canPlace; j++) {
|
|
445
|
+
const idx = (startIndex + j) % reel.length;
|
|
446
|
+
reel[idx] = symObj;
|
|
447
|
+
}
|
|
448
|
+
return canPlace;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Checks if a symbol can be placed at the target index without violating spacing rules.
|
|
452
|
+
*/
|
|
453
|
+
violatesSpacing(reel, symbolId, targetIndex) {
|
|
454
|
+
const circDist = (a, b) => {
|
|
455
|
+
const diff = Math.abs(a - b);
|
|
456
|
+
return Math.min(diff, this.rowsAmount - diff);
|
|
457
|
+
};
|
|
458
|
+
const spacingType = this.spaceBetweenSameSymbols ?? void 0;
|
|
459
|
+
const sameSpacing = typeof spacingType === "number" ? spacingType : spacingType?.[symbolId] ?? 0;
|
|
460
|
+
for (let i = 0; i <= reel.length; i++) {
|
|
461
|
+
const placed = reel[i];
|
|
462
|
+
if (!placed) continue;
|
|
463
|
+
const dist = circDist(targetIndex, i);
|
|
464
|
+
if (sameSpacing >= 1 && placed.id === symbolId) {
|
|
465
|
+
if (dist <= sameSpacing) return true;
|
|
466
|
+
}
|
|
467
|
+
if (this.spaceBetweenSymbols) {
|
|
468
|
+
const forward = this.spaceBetweenSymbols[symbolId]?.[placed.id] ?? 0;
|
|
469
|
+
if (forward >= 1 && dist <= forward) return true;
|
|
470
|
+
const reverse = this.spaceBetweenSymbols[placed.id]?.[symbolId] ?? 0;
|
|
471
|
+
if (reverse >= 1 && dist <= reverse) return true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
generateReels(gameConf) {
|
|
477
|
+
this.validateConfig(gameConf);
|
|
478
|
+
const gameMode = gameConf.config.gameModes[this.associatedGameModeName];
|
|
479
|
+
if (!gameMode) {
|
|
480
|
+
throw new Error(
|
|
481
|
+
`Error generating reels for game mode "${this.associatedGameModeName}". It's not defined in the game config.`
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
const filePath = path.join(
|
|
485
|
+
gameConf.config.outputDir,
|
|
486
|
+
`reels_${this.associatedGameModeName}-${this.id}.csv`
|
|
487
|
+
);
|
|
488
|
+
this.csvPath = filePath;
|
|
489
|
+
const exists = fs2.existsSync(filePath);
|
|
490
|
+
const reelsAmount = gameMode.reelsAmount;
|
|
491
|
+
const weightsObj = Object.fromEntries(this.symbolWeights);
|
|
492
|
+
for (let ridx = 0; ridx < reelsAmount; ridx++) {
|
|
493
|
+
const reel = new Array(this.rowsAmount).fill(null);
|
|
494
|
+
const reelQuotas = {};
|
|
495
|
+
const quotaCounts = {};
|
|
496
|
+
let totalReelsQuota = 0;
|
|
497
|
+
for (const [sym, quotaConf] of Object.entries(this.symbolQuotas || {})) {
|
|
498
|
+
const q = typeof quotaConf === "number" ? quotaConf : quotaConf[ridx];
|
|
499
|
+
if (!q) continue;
|
|
500
|
+
reelQuotas[sym] = q;
|
|
501
|
+
totalReelsQuota += q;
|
|
502
|
+
}
|
|
503
|
+
if (totalReelsQuota > 100) {
|
|
504
|
+
throw new Error(
|
|
505
|
+
`Total symbol quotas for reel ${ridx} exceed 100%. Adjust your configuration on ReelGenerator "${this.id}".`
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
if (totalReelsQuota > 0) {
|
|
509
|
+
for (const [sym, quota] of Object.entries(reelQuotas)) {
|
|
510
|
+
const quotaCount = Math.max(1, Math.floor(this.rowsAmount * quota / 100));
|
|
511
|
+
quotaCounts[sym] = quotaCount;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
for (const [sym, targetCount] of Object.entries(quotaCounts)) {
|
|
515
|
+
let remaining = targetCount;
|
|
516
|
+
let attempts = 0;
|
|
517
|
+
while (remaining > 0) {
|
|
518
|
+
if (attempts++ > this.rowsAmount * 10) {
|
|
519
|
+
throw new Error(
|
|
520
|
+
`Failed to place ${targetCount} of symbol ${sym} on reel ${ridx} (likely spacing/stacking too strict).`
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const pos = Math.round(this.rng.randomFloat(0, this.rowsAmount - 1));
|
|
524
|
+
const stackCfg = this.resolveStacking(sym, ridx);
|
|
525
|
+
let placed = 0;
|
|
526
|
+
if (stackCfg && Math.round(this.rng.randomFloat(1, 100)) <= stackCfg.chance) {
|
|
527
|
+
const stackSize = Math.max(
|
|
528
|
+
0,
|
|
529
|
+
Math.round(this.rng.randomFloat(stackCfg.min, stackCfg.max))
|
|
530
|
+
);
|
|
531
|
+
const toPlace = Math.min(stackSize, remaining);
|
|
532
|
+
placed = this.tryPlaceStack(reel, gameConf, ridx, sym, pos, toPlace);
|
|
533
|
+
}
|
|
534
|
+
if (placed === 0 && reel[pos] === null && this.isSymbolAllowedOnReel(sym, ridx) && !this.violatesSpacing(reel, sym, pos)) {
|
|
535
|
+
reel[pos] = gameConf.config.symbols.get(sym);
|
|
536
|
+
placed = 1;
|
|
537
|
+
}
|
|
538
|
+
remaining -= placed;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
for (let r = 0; r < this.rowsAmount; r++) {
|
|
542
|
+
if (reel[r] !== null) continue;
|
|
543
|
+
let chosenSymbolId = weightedRandom(weightsObj, this.rng);
|
|
544
|
+
const nextHasStackCfg = !!this.resolveStacking(chosenSymbolId, ridx);
|
|
545
|
+
if (!nextHasStackCfg && this.preferStackedSymbols && reel.length > 0) {
|
|
546
|
+
const prevSymbol = r - 1 >= 0 ? reel[r - 1] : reel[reel.length - 1];
|
|
547
|
+
if (prevSymbol && Math.round(this.rng.randomFloat(1, 100)) <= this.preferStackedSymbols && (!this.spaceBetweenSameSymbols || !this.violatesSpacing(reel, prevSymbol.id, r))) {
|
|
548
|
+
chosenSymbolId = prevSymbol.id;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (this.preferStackedSymbols && reel.length > 0) {
|
|
552
|
+
const prevSymbol = r - 1 >= 0 ? reel[r - 1] : reel[reel.length - 1];
|
|
553
|
+
if (Math.round(this.rng.randomFloat(1, 100)) <= this.preferStackedSymbols && (!this.spaceBetweenSameSymbols || !this.violatesSpacing(reel, prevSymbol.id, r))) {
|
|
554
|
+
chosenSymbolId = prevSymbol.id;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const stackCfg = this.resolveStacking(chosenSymbolId, ridx);
|
|
558
|
+
if (stackCfg && this.isSymbolAllowedOnReel(chosenSymbolId, ridx)) {
|
|
559
|
+
const roll = Math.round(this.rng.randomFloat(1, 100));
|
|
560
|
+
if (roll <= stackCfg.chance) {
|
|
561
|
+
const desiredSize = Math.max(
|
|
562
|
+
1,
|
|
563
|
+
Math.round(this.rng.randomFloat(stackCfg.min, stackCfg.max))
|
|
564
|
+
);
|
|
565
|
+
const placed = this.tryPlaceStack(
|
|
566
|
+
reel,
|
|
567
|
+
gameConf,
|
|
568
|
+
ridx,
|
|
569
|
+
chosenSymbolId,
|
|
570
|
+
r,
|
|
571
|
+
desiredSize
|
|
572
|
+
);
|
|
573
|
+
if (placed > 0) {
|
|
574
|
+
r += placed - 1;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
let tries = 0;
|
|
580
|
+
const maxTries = 2500;
|
|
581
|
+
while (!this.isSymbolAllowedOnReel(chosenSymbolId, ridx) || this.violatesSpacing(reel, chosenSymbolId, r)) {
|
|
582
|
+
if (++tries > maxTries) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
[
|
|
585
|
+
`Failed to place a symbol on reel ${ridx} at position ${r} after ${maxTries} attempts.
|
|
586
|
+
`,
|
|
587
|
+
"Try to change the seed or adjust your configuration.\n"
|
|
588
|
+
].join(" ")
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
chosenSymbolId = weightedRandom(weightsObj, this.rng);
|
|
592
|
+
const hasStackCfg = !!this.resolveStacking(chosenSymbolId, ridx);
|
|
593
|
+
if (!hasStackCfg && this.preferStackedSymbols && reel.length > 0) {
|
|
594
|
+
const prevSymbol = r - 1 >= 0 ? reel[r - 1] : reel[reel.length - 1];
|
|
595
|
+
if (prevSymbol && Math.round(this.rng.randomFloat(1, 100)) <= this.preferStackedSymbols && (!this.spaceBetweenSameSymbols || !this.violatesSpacing(reel, prevSymbol.id, r))) {
|
|
596
|
+
chosenSymbolId = prevSymbol.id;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const symbol = gameConf.config.symbols.get(chosenSymbolId);
|
|
601
|
+
if (!symbol) {
|
|
602
|
+
throw new Error(
|
|
603
|
+
`Symbol with id "${chosenSymbolId}" not found in the game config symbols map.`
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
reel[r] = symbol;
|
|
607
|
+
}
|
|
608
|
+
if (reel.some((s) => s === null)) {
|
|
609
|
+
throw new Error(`Reel ${ridx} has unfilled positions after generation.`);
|
|
610
|
+
}
|
|
611
|
+
this.reels.push(reel);
|
|
612
|
+
}
|
|
613
|
+
const csvRows = Array.from(
|
|
614
|
+
{ length: this.rowsAmount },
|
|
615
|
+
() => Array.from({ length: reelsAmount }, () => "")
|
|
616
|
+
);
|
|
617
|
+
for (let ridx = 0; ridx < reelsAmount; ridx++) {
|
|
618
|
+
for (let r = 0; r < this.rowsAmount; r++) {
|
|
619
|
+
csvRows[r][ridx] = this.reels[ridx][r].id;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const csvString = csvRows.map((row) => row.join(",")).join("\n");
|
|
623
|
+
if (isMainThread) {
|
|
624
|
+
createDirIfNotExists(this.outputDir);
|
|
625
|
+
fs2.writeFileSync(filePath, csvString);
|
|
626
|
+
this.reels = this.parseReelsetCSV(filePath, gameConf);
|
|
627
|
+
console.log(
|
|
628
|
+
`Generated reelset ${this.id} for game mode ${this.associatedGameModeName}`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
if (exists) {
|
|
632
|
+
this.reels = this.parseReelsetCSV(filePath, gameConf);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Reads a reelset CSV file and returns the reels as arrays of GameSymbols.
|
|
637
|
+
*/
|
|
638
|
+
parseReelsetCSV(reelSetPath, { config }) {
|
|
639
|
+
const csvData = fs2.readFileSync(reelSetPath, "utf8");
|
|
640
|
+
const rows = csvData.split("\n").filter((line) => line.trim() !== "");
|
|
641
|
+
const reels = Array.from(
|
|
642
|
+
{ length: config.gameModes[this.associatedGameModeName].reelsAmount },
|
|
643
|
+
() => []
|
|
644
|
+
);
|
|
645
|
+
rows.forEach((row) => {
|
|
646
|
+
const symsInRow = row.split(",").map((symbolId) => {
|
|
647
|
+
const symbol = config.symbols.get(symbolId.trim());
|
|
648
|
+
if (!symbol) {
|
|
649
|
+
throw new Error(`Symbol with id "${symbolId}" not found in game config.`);
|
|
650
|
+
}
|
|
651
|
+
return symbol;
|
|
652
|
+
});
|
|
653
|
+
symsInRow.forEach((symbol, ridx) => {
|
|
654
|
+
reels[ridx].push(symbol);
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
return reels;
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
// src/ResultSet.ts
|
|
662
|
+
var ResultSet = class {
|
|
663
|
+
criteria;
|
|
664
|
+
quota;
|
|
665
|
+
multiplier;
|
|
666
|
+
reelWeights;
|
|
667
|
+
userData;
|
|
668
|
+
forceMaxWin;
|
|
669
|
+
forceFreespins;
|
|
670
|
+
evaluate;
|
|
671
|
+
constructor(opts) {
|
|
672
|
+
this.criteria = opts.criteria;
|
|
673
|
+
this.quota = opts.quota;
|
|
674
|
+
this.multiplier = opts.multiplier;
|
|
675
|
+
this.reelWeights = opts.reelWeights;
|
|
676
|
+
this.userData = opts.userData;
|
|
677
|
+
this.forceMaxWin = opts.forceMaxWin;
|
|
678
|
+
this.forceFreespins = opts.forceFreespins;
|
|
679
|
+
this.evaluate = opts.evaluate;
|
|
680
|
+
if (this.quota < 0 || this.quota > 1) {
|
|
681
|
+
throw new Error(`Quota must be a float between 0 and 1, got ${this.quota}.`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
static assignCriteriaToSimulations(ctx, gameModeName) {
|
|
685
|
+
const rng = new RandomNumberGenerator();
|
|
686
|
+
rng.setSeed(0);
|
|
687
|
+
if (!ctx.simRunsAmount) {
|
|
688
|
+
throw new Error("Simulation configuration is not set.");
|
|
689
|
+
}
|
|
690
|
+
const simNums = ctx.simRunsAmount[gameModeName];
|
|
691
|
+
const resultSets = ctx.gameConfig.config.gameModes[gameModeName]?.resultSets;
|
|
692
|
+
if (!resultSets || resultSets.length === 0) {
|
|
693
|
+
throw new Error(`No ResultSets found for game mode: ${gameModeName}.`);
|
|
694
|
+
}
|
|
695
|
+
if (simNums === void 0 || simNums <= 0) {
|
|
696
|
+
throw new Error(`No simulations configured for game mode "${gameModeName}".`);
|
|
697
|
+
}
|
|
698
|
+
const numberOfSimsForCriteria = Object.fromEntries(
|
|
699
|
+
resultSets.map((rs) => [rs.criteria, Math.max(Math.floor(rs.quota * simNums), 1)])
|
|
700
|
+
);
|
|
701
|
+
let totalSims = Object.values(numberOfSimsForCriteria).reduce(
|
|
702
|
+
(sum, num) => sum + num,
|
|
703
|
+
0
|
|
704
|
+
);
|
|
705
|
+
let reduceSims = totalSims > simNums;
|
|
706
|
+
const criteriaToWeights = Object.fromEntries(
|
|
707
|
+
resultSets.map((rs) => [rs.criteria, rs.quota])
|
|
708
|
+
);
|
|
709
|
+
while (totalSims != simNums) {
|
|
710
|
+
const rs = weightedRandom(criteriaToWeights, rng);
|
|
711
|
+
if (reduceSims && numberOfSimsForCriteria[rs] > 1) {
|
|
712
|
+
numberOfSimsForCriteria[rs] -= 1;
|
|
713
|
+
} else if (!reduceSims) {
|
|
714
|
+
numberOfSimsForCriteria[rs] += 1;
|
|
715
|
+
}
|
|
716
|
+
totalSims = Object.values(numberOfSimsForCriteria).reduce(
|
|
717
|
+
(sum, num) => sum + num,
|
|
718
|
+
0
|
|
719
|
+
);
|
|
720
|
+
reduceSims = totalSims > simNums;
|
|
721
|
+
}
|
|
722
|
+
let allCriteria = [];
|
|
723
|
+
const simNumsToCriteria = {};
|
|
724
|
+
Object.entries(numberOfSimsForCriteria).forEach(([criteria, num]) => {
|
|
725
|
+
for (let i = 0; i <= num; i++) {
|
|
726
|
+
allCriteria.push(criteria);
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
allCriteria = shuffle(allCriteria, rng);
|
|
730
|
+
for (let i = 1; i <= Math.min(simNums, allCriteria.length); i++) {
|
|
731
|
+
simNumsToCriteria[i] = allCriteria[i];
|
|
732
|
+
}
|
|
733
|
+
return simNumsToCriteria;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Checks if core criteria is met, e.g. target multiplier or max win.
|
|
737
|
+
*/
|
|
738
|
+
meetsCriteria(ctx) {
|
|
739
|
+
const customEval = this.evaluate?.(copy(ctx));
|
|
740
|
+
const freespinsMet = this.forceFreespins ? ctx.state.triggeredFreespins : true;
|
|
741
|
+
const multiplierMet = this.multiplier !== void 0 ? ctx.wallet.getCurrentWin() === this.multiplier && !this.forceMaxWin : ctx.wallet.getCurrentWin() > 0 && (!this.forceMaxWin || true);
|
|
742
|
+
const maxWinMet = this.forceMaxWin ? ctx.wallet.getCurrentWin() >= ctx.config.maxWinX : true;
|
|
743
|
+
const coreCriteriaMet = freespinsMet && multiplierMet && maxWinMet;
|
|
744
|
+
const finalResult = customEval !== void 0 ? coreCriteriaMet && customEval === true : coreCriteriaMet;
|
|
745
|
+
if (this.forceMaxWin && maxWinMet) {
|
|
746
|
+
ctx.record({
|
|
747
|
+
maxwin: true
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
return finalResult;
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
// src/Wallet.ts
|
|
755
|
+
var Wallet = class {
|
|
756
|
+
/**
|
|
757
|
+
* Total win amount (as the bet multiplier) from all simulations.
|
|
758
|
+
*/
|
|
759
|
+
cumulativeWins = 0;
|
|
760
|
+
/**
|
|
761
|
+
* Total win amount (as the bet multiplier) per spin type.
|
|
762
|
+
*
|
|
763
|
+
* @example
|
|
764
|
+
* ```ts
|
|
765
|
+
* {
|
|
766
|
+
* basegame: 50,
|
|
767
|
+
* freespins: 100,
|
|
768
|
+
* superfreespins: 200,
|
|
769
|
+
* }
|
|
770
|
+
* ```
|
|
771
|
+
*/
|
|
772
|
+
cumulativeWinsPerSpinType = {
|
|
773
|
+
[GameConfig.SPIN_TYPE.BASE_GAME]: 0,
|
|
774
|
+
[GameConfig.SPIN_TYPE.FREE_SPINS]: 0
|
|
775
|
+
};
|
|
776
|
+
/**
|
|
777
|
+
* Current win amount (as the bet multiplier) for the ongoing simulation.
|
|
778
|
+
*/
|
|
779
|
+
currentWin = 0;
|
|
780
|
+
/**
|
|
781
|
+
* Current win amount (as the bet multiplier) for the ongoing simulation per spin type.
|
|
782
|
+
*
|
|
783
|
+
* @example
|
|
784
|
+
* ```ts
|
|
785
|
+
* {
|
|
786
|
+
* basegame: 50,
|
|
787
|
+
* freespins: 100,
|
|
788
|
+
* superfreespins: 200,
|
|
789
|
+
* }
|
|
790
|
+
* ```
|
|
791
|
+
*/
|
|
792
|
+
currentWinPerSpinType = {
|
|
793
|
+
[GameConfig.SPIN_TYPE.BASE_GAME]: 0,
|
|
794
|
+
[GameConfig.SPIN_TYPE.FREE_SPINS]: 0
|
|
795
|
+
};
|
|
796
|
+
/**
|
|
797
|
+
* Holds the current win amount for a single (free) spin.\
|
|
798
|
+
* After each spin, this amount is added to `currentWinPerSpinType` and then reset to zero.
|
|
799
|
+
*/
|
|
800
|
+
currentSpinWin = 0;
|
|
801
|
+
/**
|
|
802
|
+
* Current win amount (as the bet multiplier) for the ongoing tumble sequence.
|
|
803
|
+
*/
|
|
804
|
+
currentTumbleWin = 0;
|
|
805
|
+
constructor() {
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Updates the win for the current spin.
|
|
809
|
+
*
|
|
810
|
+
* Should be called after each tumble event, if applicable.\
|
|
811
|
+
* Or generally call this to add wins during a spin.
|
|
812
|
+
*
|
|
813
|
+
* After each (free) spin, this amount should be added to `currentWinPerSpinType` via `confirmSpinWin()`
|
|
814
|
+
*/
|
|
815
|
+
addSpinWin(amount) {
|
|
816
|
+
this.currentSpinWin += amount;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Assigns a win amount to the given spin type.
|
|
820
|
+
*
|
|
821
|
+
* Should be called after `addSpinWin()`, and after your tumble events are played out,\
|
|
822
|
+
* and after a (free) spin is played out to finalize the win.
|
|
823
|
+
*/
|
|
824
|
+
confirmSpinWin(spinType) {
|
|
825
|
+
if (!Object.keys(this.currentWinPerSpinType).includes(spinType)) {
|
|
826
|
+
throw new Error(`Spin type "${spinType}" does not exist in the wallet.`);
|
|
827
|
+
}
|
|
828
|
+
this.currentWinPerSpinType[spinType] += this.currentSpinWin;
|
|
829
|
+
this.currentWin += this.currentSpinWin;
|
|
830
|
+
this.currentSpinWin = 0;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Returns the accumulated win amount (as the bet multiplier) from all simulations.
|
|
834
|
+
*/
|
|
835
|
+
getCumulativeWins() {
|
|
836
|
+
return this.cumulativeWins;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Returns the accumulated win amount (as the bet multiplier) per spin type from all simulations.
|
|
840
|
+
*/
|
|
841
|
+
getCumulativeWinsPerSpinType() {
|
|
842
|
+
return this.cumulativeWinsPerSpinType;
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Returns the current win amount (as the bet multiplier) for the ongoing simulation.
|
|
846
|
+
*/
|
|
847
|
+
getCurrentWin() {
|
|
848
|
+
return this.currentWin;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Returns the current win amount (as the bet multiplier) per spin type for the ongoing simulation.
|
|
852
|
+
*/
|
|
853
|
+
getCurrentWinPerSpinType() {
|
|
854
|
+
return this.currentWinPerSpinType;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Adds a win to `currentSpinWin` and `currentTumbleWin`.
|
|
858
|
+
*
|
|
859
|
+
* After each (free) spin, this amount should be added to `currentWinPerSpinType` via `confirmSpinWin()`
|
|
860
|
+
*/
|
|
861
|
+
addTumbleWin(amount) {
|
|
862
|
+
this.currentTumbleWin += amount;
|
|
863
|
+
this.addSpinWin(amount);
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Resets the current win amounts to zero.
|
|
867
|
+
*/
|
|
868
|
+
resetCurrentWin() {
|
|
869
|
+
this.currentWin = 0;
|
|
870
|
+
this.currentSpinWin = 0;
|
|
871
|
+
this.currentTumbleWin = 0;
|
|
872
|
+
for (const spinType of Object.keys(this.currentWinPerSpinType)) {
|
|
873
|
+
this.currentWinPerSpinType[spinType] = 0;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Adds current wins to cumulative wins and resets current wins to zero.
|
|
878
|
+
*/
|
|
879
|
+
confirmWins(ctx) {
|
|
880
|
+
function process2(number) {
|
|
881
|
+
return Math.round(Math.min(number, ctx.config.maxWinX) * 100) / 100;
|
|
882
|
+
}
|
|
883
|
+
ctx.state.book.writePayout(ctx);
|
|
884
|
+
this.currentWin = process2(this.currentWin);
|
|
885
|
+
this.cumulativeWins += this.currentWin;
|
|
886
|
+
let spinTypeWins = 0;
|
|
887
|
+
for (const spinType of Object.keys(this.currentWinPerSpinType)) {
|
|
888
|
+
const st = spinType;
|
|
889
|
+
const spinTypeWin = process2(this.currentWinPerSpinType[st]);
|
|
890
|
+
this.cumulativeWinsPerSpinType[st] += spinTypeWin;
|
|
891
|
+
spinTypeWins += spinTypeWin;
|
|
892
|
+
}
|
|
893
|
+
if (process2(spinTypeWins) !== this.currentWin) {
|
|
894
|
+
throw new Error(
|
|
895
|
+
`Inconsistent wallet state: currentWin (${this.currentWin}) does not equal spinTypeWins (${spinTypeWins}).`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
this.resetCurrentWin();
|
|
899
|
+
}
|
|
900
|
+
serialize() {
|
|
901
|
+
return {
|
|
902
|
+
cumulativeWins: this.cumulativeWins,
|
|
903
|
+
cumulativeWinsPerSpinType: this.cumulativeWinsPerSpinType,
|
|
904
|
+
currentWin: this.currentWin,
|
|
905
|
+
currentWinPerSpinType: this.currentWinPerSpinType,
|
|
906
|
+
currentSpinWin: this.currentSpinWin,
|
|
907
|
+
currentTumbleWin: this.currentTumbleWin
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
merge(wallet) {
|
|
911
|
+
this.cumulativeWins += wallet.getCumulativeWins();
|
|
912
|
+
const otherWinsPerSpinType = wallet.getCumulativeWinsPerSpinType();
|
|
913
|
+
for (const spinType of Object.keys(this.cumulativeWinsPerSpinType)) {
|
|
914
|
+
this.cumulativeWinsPerSpinType[spinType] += otherWinsPerSpinType[spinType] || 0;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
mergeSerialized(data) {
|
|
918
|
+
this.cumulativeWins += data.cumulativeWins;
|
|
919
|
+
for (const spinType of Object.keys(this.cumulativeWinsPerSpinType)) {
|
|
920
|
+
this.cumulativeWinsPerSpinType[spinType] += data.cumulativeWinsPerSpinType[spinType] || 0;
|
|
921
|
+
}
|
|
922
|
+
this.currentWin += data.currentWin;
|
|
923
|
+
this.currentSpinWin += data.currentSpinWin;
|
|
924
|
+
this.currentTumbleWin += data.currentTumbleWin;
|
|
925
|
+
for (const spinType of Object.keys(this.currentWinPerSpinType)) {
|
|
926
|
+
this.currentWinPerSpinType[spinType] += data.currentWinPerSpinType[spinType] || 0;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
// src/Book.ts
|
|
932
|
+
var Book = class _Book {
|
|
933
|
+
id;
|
|
934
|
+
criteria = "N/A";
|
|
935
|
+
events = [];
|
|
936
|
+
payout = 0;
|
|
937
|
+
basegameWins = 0;
|
|
938
|
+
freespinsWins = 0;
|
|
939
|
+
constructor(opts) {
|
|
940
|
+
this.id = opts.id;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Adds an event to the book.
|
|
944
|
+
*/
|
|
945
|
+
addEvent(event) {
|
|
946
|
+
const index = this.events.length + 1;
|
|
947
|
+
this.events.push({ index, ...event });
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Transfers the win data from the wallet to the book.
|
|
951
|
+
*/
|
|
952
|
+
writePayout(ctx) {
|
|
953
|
+
function process2(number) {
|
|
954
|
+
return Math.round(Math.min(number, ctx.config.maxWinX) * 100) / 100;
|
|
955
|
+
}
|
|
956
|
+
this.payout = Math.round(process2(ctx.wallet.getCurrentWin()) * 100);
|
|
957
|
+
this.basegameWins = process2(
|
|
958
|
+
ctx.wallet.getCurrentWinPerSpinType()[GameConfig.SPIN_TYPE.BASE_GAME] || 0
|
|
959
|
+
);
|
|
960
|
+
this.freespinsWins = process2(
|
|
961
|
+
ctx.wallet.getCurrentWinPerSpinType()[GameConfig.SPIN_TYPE.FREE_SPINS] || 0
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
getPayout() {
|
|
965
|
+
return this.payout;
|
|
966
|
+
}
|
|
967
|
+
getBasegameWins() {
|
|
968
|
+
return this.basegameWins;
|
|
969
|
+
}
|
|
970
|
+
getFreespinsWins() {
|
|
971
|
+
return this.freespinsWins;
|
|
972
|
+
}
|
|
973
|
+
serialize() {
|
|
974
|
+
return {
|
|
975
|
+
id: this.id,
|
|
976
|
+
criteria: this.criteria,
|
|
977
|
+
events: this.events,
|
|
978
|
+
payout: this.payout,
|
|
979
|
+
basegameWins: this.basegameWins,
|
|
980
|
+
freespinsWins: this.freespinsWins
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
static fromSerialized(data) {
|
|
984
|
+
const book = new _Book({ id: data.id });
|
|
985
|
+
book.criteria = data.criteria;
|
|
986
|
+
book.events = data.events;
|
|
987
|
+
book.payout = data.payout;
|
|
988
|
+
book.basegameWins = data.basegameWins;
|
|
989
|
+
book.freespinsWins = data.freespinsWins;
|
|
990
|
+
return book;
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
// src/GameState.ts
|
|
995
|
+
var GameState = class extends GameConfig {
|
|
996
|
+
state;
|
|
997
|
+
/**
|
|
998
|
+
* The wallet stores win data for the current and all simulations, respectively.
|
|
999
|
+
*/
|
|
1000
|
+
wallet;
|
|
1001
|
+
/**
|
|
1002
|
+
* Recorder for statistical analysis (e.g. symbol occurrences, etc.).
|
|
1003
|
+
*/
|
|
1004
|
+
recorder;
|
|
1005
|
+
constructor(opts) {
|
|
1006
|
+
super(opts);
|
|
1007
|
+
this.state = {
|
|
1008
|
+
currentSpinType: GameConfig.SPIN_TYPE.BASE_GAME,
|
|
1009
|
+
library: /* @__PURE__ */ new Map(),
|
|
1010
|
+
book: new Book({ id: 0 }),
|
|
1011
|
+
currentGameMode: "N/A",
|
|
1012
|
+
currentSimulationId: 0,
|
|
1013
|
+
isCriteriaMet: false,
|
|
1014
|
+
currentFreespinAmount: 0,
|
|
1015
|
+
totalFreespinAmount: 0,
|
|
1016
|
+
rng: new RandomNumberGenerator(),
|
|
1017
|
+
userData: opts.userState || {},
|
|
1018
|
+
triggeredMaxWin: false,
|
|
1019
|
+
triggeredFreespins: false,
|
|
1020
|
+
// This is a placeholder ResultSet to avoid null checks elsewhere.
|
|
1021
|
+
currentResultSet: new ResultSet({
|
|
1022
|
+
criteria: "N/A",
|
|
1023
|
+
quota: 0,
|
|
1024
|
+
reelWeights: {
|
|
1025
|
+
[GameConfig.SPIN_TYPE.BASE_GAME]: {},
|
|
1026
|
+
[GameConfig.SPIN_TYPE.FREE_SPINS]: {}
|
|
1027
|
+
}
|
|
1028
|
+
})
|
|
1029
|
+
};
|
|
1030
|
+
this.wallet = new Wallet();
|
|
1031
|
+
this.recorder = {
|
|
1032
|
+
pendingRecords: [],
|
|
1033
|
+
records: []
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Gets the configuration for the current game mode.
|
|
1038
|
+
*/
|
|
1039
|
+
getCurrentGameMode() {
|
|
1040
|
+
return this.config.gameModes[this.state.currentGameMode];
|
|
1041
|
+
}
|
|
1042
|
+
resetState() {
|
|
1043
|
+
this.state.rng.setSeedIfDifferent(this.state.currentSimulationId);
|
|
1044
|
+
this.state.book = new Book({ id: this.state.currentSimulationId });
|
|
1045
|
+
this.state.currentSpinType = GameConfig.SPIN_TYPE.BASE_GAME;
|
|
1046
|
+
this.state.currentFreespinAmount = 0;
|
|
1047
|
+
this.state.totalFreespinAmount = 0;
|
|
1048
|
+
this.state.triggeredMaxWin = false;
|
|
1049
|
+
this.state.triggeredFreespins = false;
|
|
1050
|
+
this.wallet.resetCurrentWin();
|
|
1051
|
+
this.clearPendingRecords();
|
|
1052
|
+
this.state.userData = this.config.userState || {};
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Checks if a max win is reached by comparing `wallet.currentWin` to `config.maxWin`.
|
|
1056
|
+
*
|
|
1057
|
+
* Should be called after `wallet.confirmSpinWin()`.
|
|
1058
|
+
*/
|
|
1059
|
+
isMaxWinTriggered() {
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Empties the list of pending records in the recorder.
|
|
1063
|
+
*/
|
|
1064
|
+
clearPendingRecords() {
|
|
1065
|
+
this.recorder.pendingRecords = [];
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Confirms all pending records and adds them to the main records list.
|
|
1069
|
+
*/
|
|
1070
|
+
confirmRecords() {
|
|
1071
|
+
for (const pendingRecord of this.recorder.pendingRecords) {
|
|
1072
|
+
const search = Object.entries(pendingRecord.properties).map(([name, value]) => ({ name, value })).sort((a, b) => a.name.localeCompare(b.name));
|
|
1073
|
+
let record = this.recorder.records.find((r) => {
|
|
1074
|
+
if (r.search.length !== search.length) return false;
|
|
1075
|
+
for (let i = 0; i < r.search.length; i++) {
|
|
1076
|
+
if (r.search[i].name !== search[i].name) return false;
|
|
1077
|
+
if (r.search[i].value !== search[i].value) return false;
|
|
1078
|
+
}
|
|
1079
|
+
return true;
|
|
1080
|
+
});
|
|
1081
|
+
if (!record) {
|
|
1082
|
+
record = {
|
|
1083
|
+
search,
|
|
1084
|
+
timesTriggered: 0,
|
|
1085
|
+
bookIds: []
|
|
1086
|
+
};
|
|
1087
|
+
this.recorder.records.push(record);
|
|
1088
|
+
}
|
|
1089
|
+
record.timesTriggered++;
|
|
1090
|
+
if (!record.bookIds.includes(pendingRecord.bookId)) {
|
|
1091
|
+
record.bookIds.push(pendingRecord.bookId);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
this.clearPendingRecords();
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Record data for statistical analysis.
|
|
1098
|
+
*/
|
|
1099
|
+
record(data) {
|
|
1100
|
+
this.recorder.pendingRecords.push({
|
|
1101
|
+
bookId: this.state.currentSimulationId,
|
|
1102
|
+
properties: Object.fromEntries(
|
|
1103
|
+
Object.entries(data).map(([k, v]) => [k, String(v)])
|
|
1104
|
+
)
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Records a symbol occurrence for statistical analysis.
|
|
1109
|
+
*
|
|
1110
|
+
* Calls `this.record()` with the provided data.
|
|
1111
|
+
*/
|
|
1112
|
+
recordSymbolOccurrence(data) {
|
|
1113
|
+
this.record(data);
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Gets all confirmed records.
|
|
1117
|
+
*/
|
|
1118
|
+
getRecords() {
|
|
1119
|
+
return this.recorder.records;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Moves the current book to the library and resets the current book.
|
|
1123
|
+
*/
|
|
1124
|
+
moveBookToLibrary() {
|
|
1125
|
+
this.state.library.set(this.state.book.id.toString(), this.state.book);
|
|
1126
|
+
this.state.book = new Book({ id: 0 });
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Increases the freespin count by the specified amount.
|
|
1130
|
+
*
|
|
1131
|
+
* Also sets `state.triggeredFreespins` to true.
|
|
1132
|
+
*/
|
|
1133
|
+
awardFreespins(amount) {
|
|
1134
|
+
this.state.currentFreespinAmount += amount;
|
|
1135
|
+
this.state.totalFreespinAmount += amount;
|
|
1136
|
+
this.state.triggeredFreespins = true;
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Ensures the requested number of scatters is valid based on the game configuration.\
|
|
1140
|
+
* Returns a valid number of scatters.
|
|
1141
|
+
*/
|
|
1142
|
+
verifyScatterCount(numScatters) {
|
|
1143
|
+
const scatterCounts = this.config.scatterToFreespins[this.state.currentSpinType];
|
|
1144
|
+
if (!scatterCounts) {
|
|
1145
|
+
throw new Error(
|
|
1146
|
+
`No scatter counts defined for spin type "${this.state.currentSpinType}". Please check your game configuration.`
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
const validCounts = Object.keys(scatterCounts).map((key) => parseInt(key, 10));
|
|
1150
|
+
if (validCounts.length === 0) {
|
|
1151
|
+
throw new Error(
|
|
1152
|
+
`No scatter counts defined for spin type "${this.state.currentSpinType}". Please check your game configuration.`
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
if (numScatters < Math.min(...validCounts)) {
|
|
1156
|
+
return Math.min(...validCounts);
|
|
1157
|
+
}
|
|
1158
|
+
if (numScatters > Math.max(...validCounts)) {
|
|
1159
|
+
return Math.max(...validCounts);
|
|
1160
|
+
}
|
|
1161
|
+
return numScatters;
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
// src/Board.ts
|
|
1166
|
+
var StandaloneBoard = class {
|
|
1167
|
+
reels;
|
|
1168
|
+
paddingTop;
|
|
1169
|
+
paddingBottom;
|
|
1170
|
+
anticipation;
|
|
1171
|
+
ctx;
|
|
1172
|
+
constructor(opts) {
|
|
1173
|
+
this.reels = [];
|
|
1174
|
+
this.paddingTop = [];
|
|
1175
|
+
this.paddingBottom = [];
|
|
1176
|
+
this.anticipation = [];
|
|
1177
|
+
this.ctx = opts.ctx;
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Updates the context used by this board instance.
|
|
1181
|
+
*/
|
|
1182
|
+
context(ctx) {
|
|
1183
|
+
this.ctx = ctx;
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Resets the board to an empty state.\
|
|
1187
|
+
* This is called before drawing a new board.
|
|
1188
|
+
*/
|
|
1189
|
+
resetBoard() {
|
|
1190
|
+
this.resetReels();
|
|
1191
|
+
}
|
|
1192
|
+
makeEmptyReels() {
|
|
1193
|
+
return Array.from({ length: this.ctx.getCurrentGameMode().reelsAmount }, () => []);
|
|
1194
|
+
}
|
|
1195
|
+
resetReels() {
|
|
1196
|
+
this.reels = this.makeEmptyReels();
|
|
1197
|
+
this.anticipation = Array.from(
|
|
1198
|
+
{ length: this.ctx.getCurrentGameMode().reelsAmount },
|
|
1199
|
+
() => 0
|
|
1200
|
+
);
|
|
1201
|
+
if (this.ctx.config.padSymbols && this.ctx.config.padSymbols > 0) {
|
|
1202
|
+
this.paddingTop = this.makeEmptyReels();
|
|
1203
|
+
this.paddingBottom = this.makeEmptyReels();
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Counts how many symbols matching the criteria are on a specific reel.
|
|
1208
|
+
*/
|
|
1209
|
+
countSymbolsOnReel(symbolOrProperties, reelIndex) {
|
|
1210
|
+
let total = 0;
|
|
1211
|
+
for (const symbol of this.reels[reelIndex]) {
|
|
1212
|
+
let matches = true;
|
|
1213
|
+
if (symbolOrProperties instanceof GameSymbol) {
|
|
1214
|
+
if (symbol.id !== symbolOrProperties.id) matches = false;
|
|
1215
|
+
} else {
|
|
1216
|
+
for (const [key, value] of Object.entries(symbolOrProperties)) {
|
|
1217
|
+
if (!symbol.properties.has(key) || symbol.properties.get(key) !== value) {
|
|
1218
|
+
matches = false;
|
|
1219
|
+
break;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
if (matches) {
|
|
1224
|
+
total++;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return total;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Counts how many symbols matching the criteria are on the board.
|
|
1231
|
+
*
|
|
1232
|
+
* Passing a GameSymbol will compare by ID, passing a properties object will compare by properties.
|
|
1233
|
+
*
|
|
1234
|
+
* Returns a tuple where the first element is the total count, and the second element is a record of counts per reel index.
|
|
1235
|
+
*/
|
|
1236
|
+
countSymbolsOnBoard(symbolOrProperties) {
|
|
1237
|
+
let total = 0;
|
|
1238
|
+
const onReel = {};
|
|
1239
|
+
for (const [ridx, reel] of this.reels.entries()) {
|
|
1240
|
+
for (const symbol of reel) {
|
|
1241
|
+
let matches = true;
|
|
1242
|
+
if (symbolOrProperties instanceof GameSymbol) {
|
|
1243
|
+
if (symbol.id !== symbolOrProperties.id) matches = false;
|
|
1244
|
+
} else {
|
|
1245
|
+
for (const [key, value] of Object.entries(symbolOrProperties)) {
|
|
1246
|
+
if (!symbol.properties.has(key) || symbol.properties.get(key) !== value) {
|
|
1247
|
+
matches = false;
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
if (matches) {
|
|
1253
|
+
total++;
|
|
1254
|
+
if (onReel[ridx] === void 0) {
|
|
1255
|
+
onReel[ridx] = 1;
|
|
1256
|
+
} else {
|
|
1257
|
+
onReel[ridx]++;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return [total, onReel];
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Checks if a symbol appears more than once on any reel in the current reel set.
|
|
1266
|
+
*
|
|
1267
|
+
* Useful to check for "forbidden" generations, e.g. 2 scatters on one reel.
|
|
1268
|
+
*/
|
|
1269
|
+
isSymbolOnAnyReelMultipleTimes(symbol) {
|
|
1270
|
+
for (const reel of this.reels) {
|
|
1271
|
+
let count = 0;
|
|
1272
|
+
for (const sym of reel) {
|
|
1273
|
+
if (sym.id === symbol.id) {
|
|
1274
|
+
count++;
|
|
1275
|
+
}
|
|
1276
|
+
if (count > 1) {
|
|
1277
|
+
return true;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return false;
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Draws a board using specified reel stops.
|
|
1285
|
+
*/
|
|
1286
|
+
drawBoardWithForcedStops(reels, forcedStops) {
|
|
1287
|
+
this.drawBoardMixed(reels, forcedStops);
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Draws a board using random reel stops.
|
|
1291
|
+
*/
|
|
1292
|
+
drawBoardWithRandomStops(reels) {
|
|
1293
|
+
this.drawBoardMixed(reels);
|
|
1294
|
+
}
|
|
1295
|
+
drawBoardMixed(reels, forcedStops) {
|
|
1296
|
+
this.resetReels();
|
|
1297
|
+
const finalReelStops = Array.from(
|
|
1298
|
+
{ length: this.ctx.getCurrentGameMode().reelsAmount },
|
|
1299
|
+
() => null
|
|
1300
|
+
);
|
|
1301
|
+
if (forcedStops) {
|
|
1302
|
+
for (const [r, stopPos] of Object.entries(forcedStops)) {
|
|
1303
|
+
const reelIdx = Number(r);
|
|
1304
|
+
const symCount = this.ctx.getCurrentGameMode().symbolsPerReel[reelIdx];
|
|
1305
|
+
finalReelStops[reelIdx] = stopPos - Math.round(this.ctx.state.rng.randomFloat(0, symCount - 1));
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
for (let i = 0; i < finalReelStops.length; i++) {
|
|
1309
|
+
if (finalReelStops[i] === null) {
|
|
1310
|
+
finalReelStops[i] = Math.floor(
|
|
1311
|
+
this.ctx.state.rng.randomFloat(0, reels[i].length - 1)
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
for (let ridx = 0; ridx < this.ctx.getCurrentGameMode().reelsAmount; ridx++) {
|
|
1316
|
+
const reelPos = finalReelStops[ridx];
|
|
1317
|
+
if (this.ctx.config.padSymbols && this.ctx.config.padSymbols > 0) {
|
|
1318
|
+
for (let p = this.ctx.config.padSymbols - 1; p >= 0; p--) {
|
|
1319
|
+
const topPos = (reelPos - (p + 1)) % reels[ridx].length;
|
|
1320
|
+
this.paddingTop[ridx].push(reels[ridx][topPos]);
|
|
1321
|
+
const bottomPos = (reelPos + this.ctx.getCurrentGameMode().symbolsPerReel[ridx] + p) % reels[ridx].length;
|
|
1322
|
+
this.paddingBottom[ridx].unshift(reels[ridx][bottomPos]);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
for (let row = 0; row < this.ctx.getCurrentGameMode().symbolsPerReel[ridx]; row++) {
|
|
1326
|
+
const symbol = reels[ridx][(reelPos + row) % reels[ridx].length];
|
|
1327
|
+
if (!symbol) {
|
|
1328
|
+
throw new Error(`Failed to get symbol at pos ${reelPos + row} on reel ${ridx}`);
|
|
1329
|
+
}
|
|
1330
|
+
this.reels[ridx][row] = symbol;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
var Board = class extends GameState {
|
|
1336
|
+
board;
|
|
1337
|
+
constructor(opts) {
|
|
1338
|
+
super(opts);
|
|
1339
|
+
this.board = {
|
|
1340
|
+
reels: [],
|
|
1341
|
+
paddingTop: [],
|
|
1342
|
+
paddingBottom: [],
|
|
1343
|
+
anticipation: []
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Resets the board to an empty state.\
|
|
1348
|
+
* This is called before drawing a new board.
|
|
1349
|
+
*/
|
|
1350
|
+
resetBoard() {
|
|
1351
|
+
this.resetReels();
|
|
1352
|
+
}
|
|
1353
|
+
makeEmptyReels() {
|
|
1354
|
+
return Array.from({ length: this.getCurrentGameMode().reelsAmount }, () => []);
|
|
1355
|
+
}
|
|
1356
|
+
resetReels() {
|
|
1357
|
+
this.board.reels = this.makeEmptyReels();
|
|
1358
|
+
this.board.anticipation = Array.from(
|
|
1359
|
+
{ length: this.getCurrentGameMode().reelsAmount },
|
|
1360
|
+
() => 0
|
|
1361
|
+
);
|
|
1362
|
+
if (this.config.padSymbols && this.config.padSymbols > 0) {
|
|
1363
|
+
this.board.paddingTop = this.makeEmptyReels();
|
|
1364
|
+
this.board.paddingBottom = this.makeEmptyReels();
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Counts how many symbols matching the criteria are on a specific reel.
|
|
1369
|
+
*/
|
|
1370
|
+
countSymbolsOnReel(symbolOrProperties, reelIndex) {
|
|
1371
|
+
let total = 0;
|
|
1372
|
+
for (const symbol of this.board.reels[reelIndex]) {
|
|
1373
|
+
let matches = true;
|
|
1374
|
+
if (symbolOrProperties instanceof GameSymbol) {
|
|
1375
|
+
if (symbol.id !== symbolOrProperties.id) matches = false;
|
|
1376
|
+
} else {
|
|
1377
|
+
for (const [key, value] of Object.entries(symbolOrProperties)) {
|
|
1378
|
+
if (!symbol.properties.has(key) || symbol.properties.get(key) !== value) {
|
|
1379
|
+
matches = false;
|
|
1380
|
+
break;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
if (matches) {
|
|
1385
|
+
total++;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return total;
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Counts how many symbols matching the criteria are on the board.
|
|
1392
|
+
*
|
|
1393
|
+
* Passing a GameSymbol will compare by ID, passing a properties object will compare by properties.
|
|
1394
|
+
*
|
|
1395
|
+
* Returns a tuple where the first element is the total count, and the second element is a record of counts per reel index.
|
|
1396
|
+
*/
|
|
1397
|
+
countSymbolsOnBoard(symbolOrProperties) {
|
|
1398
|
+
let total = 0;
|
|
1399
|
+
const onReel = {};
|
|
1400
|
+
for (const [ridx, reel] of this.board.reels.entries()) {
|
|
1401
|
+
for (const symbol of reel) {
|
|
1402
|
+
let matches = true;
|
|
1403
|
+
if (symbolOrProperties instanceof GameSymbol) {
|
|
1404
|
+
if (symbol.id !== symbolOrProperties.id) matches = false;
|
|
1405
|
+
} else {
|
|
1406
|
+
for (const [key, value] of Object.entries(symbolOrProperties)) {
|
|
1407
|
+
if (!symbol.properties.has(key) || symbol.properties.get(key) !== value) {
|
|
1408
|
+
matches = false;
|
|
1409
|
+
break;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (matches) {
|
|
1414
|
+
total++;
|
|
1415
|
+
if (onReel[ridx] === void 0) {
|
|
1416
|
+
onReel[ridx] = 1;
|
|
1417
|
+
} else {
|
|
1418
|
+
onReel[ridx]++;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
return [total, onReel];
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Checks if a symbol appears more than once on any reel in the current reel set.
|
|
1427
|
+
*
|
|
1428
|
+
* Useful to check for "forbidden" generations, e.g. 2 scatters on one reel.
|
|
1429
|
+
*/
|
|
1430
|
+
isSymbolOnAnyReelMultipleTimes(symbol) {
|
|
1431
|
+
for (const reel of this.board.reels) {
|
|
1432
|
+
let count = 0;
|
|
1433
|
+
for (const sym of reel) {
|
|
1434
|
+
if (sym.id === symbol.id) {
|
|
1435
|
+
count++;
|
|
1436
|
+
}
|
|
1437
|
+
if (count > 1) {
|
|
1438
|
+
return true;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
return false;
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Gets all reel stops (positions) where the specified symbol appears in the current reel set.\
|
|
1446
|
+
* Returns an array of arrays, where each inner array contains the positions for the corresponding reel.
|
|
1447
|
+
*/
|
|
1448
|
+
getReelStopsForSymbol(reels, symbol) {
|
|
1449
|
+
const reelStops = [];
|
|
1450
|
+
for (let ridx = 0; ridx < reels.length; ridx++) {
|
|
1451
|
+
const reel = reels[ridx];
|
|
1452
|
+
const positions = [];
|
|
1453
|
+
for (let pos = 0; pos < reel.length; pos++) {
|
|
1454
|
+
if (reel[pos].id === symbol.id) {
|
|
1455
|
+
positions.push(pos);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
reelStops.push(positions);
|
|
1459
|
+
}
|
|
1460
|
+
return reelStops;
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Combines multiple arrays of reel stops into a single array of reel stops.\
|
|
1464
|
+
*/
|
|
1465
|
+
combineReelStops(...reelStops) {
|
|
1466
|
+
const combined = [];
|
|
1467
|
+
for (let ridx = 0; ridx < this.getCurrentGameMode().reelsAmount; ridx++) {
|
|
1468
|
+
combined[ridx] = [];
|
|
1469
|
+
for (const stops of reelStops) {
|
|
1470
|
+
combined[ridx] = combined[ridx].concat(stops[ridx]);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
return combined;
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* From a list of reel stops on reels, selects a random stop for a speficied number of random symbols.
|
|
1477
|
+
*
|
|
1478
|
+
* Mostly useful for placing scatter symbols on the board.
|
|
1479
|
+
*/
|
|
1480
|
+
getRandomReelStops(reels, reelStops, amount) {
|
|
1481
|
+
const reelsAmount = this.getCurrentGameMode().reelsAmount;
|
|
1482
|
+
const symProbsOnReels = [];
|
|
1483
|
+
const stopPositionsForReels = {};
|
|
1484
|
+
for (let ridx = 0; ridx < reelsAmount; ridx++) {
|
|
1485
|
+
symProbsOnReels.push(reelStops[ridx].length / reels[ridx].length);
|
|
1486
|
+
}
|
|
1487
|
+
while (Object.keys(stopPositionsForReels).length !== amount) {
|
|
1488
|
+
const possibleReels = [];
|
|
1489
|
+
for (let i = 0; i < reelsAmount; i++) {
|
|
1490
|
+
if (symProbsOnReels[i] > 0) {
|
|
1491
|
+
possibleReels.push(i);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
const possibleProbs = symProbsOnReels.filter((p) => p > 0);
|
|
1495
|
+
const weights = Object.fromEntries(
|
|
1496
|
+
possibleReels.map((ridx, idx) => [ridx, possibleProbs[idx]])
|
|
1497
|
+
);
|
|
1498
|
+
const chosenReel = weightedRandom(weights, this.state.rng);
|
|
1499
|
+
const chosenStop = randomItem(reelStops[Number(chosenReel)], this.state.rng);
|
|
1500
|
+
symProbsOnReels[Number(chosenReel)] = 0;
|
|
1501
|
+
stopPositionsForReels[chosenReel] = chosenStop;
|
|
1502
|
+
}
|
|
1503
|
+
return stopPositionsForReels;
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Selects a random reelset based on the configured weights for the current game mode.\
|
|
1507
|
+
* Returns the reels as arrays of GameSymbols.
|
|
1508
|
+
*/
|
|
1509
|
+
getRandomReelset() {
|
|
1510
|
+
const weights = this.state.currentResultSet.reelWeights;
|
|
1511
|
+
const evalWeights = this.state.currentResultSet.reelWeights.evaluate?.(
|
|
1512
|
+
this
|
|
1513
|
+
);
|
|
1514
|
+
let reelSetId = "";
|
|
1515
|
+
if (evalWeights) {
|
|
1516
|
+
reelSetId = weightedRandom(evalWeights, this.state.rng);
|
|
1517
|
+
} else {
|
|
1518
|
+
reelSetId = weightedRandom(weights[this.state.currentSpinType], this.state.rng);
|
|
1519
|
+
}
|
|
1520
|
+
const reelSet = this.getReelsetById(this.state.currentGameMode, reelSetId);
|
|
1521
|
+
return reelSet.reels;
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* Draws a board using specified reel stops.
|
|
1525
|
+
*/
|
|
1526
|
+
drawBoardWithForcedStops(reels, forcedStops) {
|
|
1527
|
+
this.drawBoardMixed(reels, forcedStops);
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Draws a board using random reel stops.
|
|
1531
|
+
*/
|
|
1532
|
+
drawBoardWithRandomStops(reels) {
|
|
1533
|
+
this.drawBoardMixed(reels);
|
|
1534
|
+
}
|
|
1535
|
+
drawBoardMixed(reels, forcedStops) {
|
|
1536
|
+
this.resetReels();
|
|
1537
|
+
const finalReelStops = Array.from(
|
|
1538
|
+
{ length: this.getCurrentGameMode().reelsAmount },
|
|
1539
|
+
() => null
|
|
1540
|
+
);
|
|
1541
|
+
if (forcedStops) {
|
|
1542
|
+
for (const [r, stopPos] of Object.entries(forcedStops)) {
|
|
1543
|
+
const reelIdx = Number(r);
|
|
1544
|
+
const symCount = this.getCurrentGameMode().symbolsPerReel[reelIdx];
|
|
1545
|
+
finalReelStops[reelIdx] = stopPos - Math.round(this.state.rng.randomFloat(0, symCount - 1));
|
|
1546
|
+
if (finalReelStops[reelIdx] < 0) {
|
|
1547
|
+
finalReelStops[reelIdx] = reels[reelIdx].length + finalReelStops[reelIdx];
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
for (let i = 0; i < finalReelStops.length; i++) {
|
|
1552
|
+
if (finalReelStops[i] === null) {
|
|
1553
|
+
finalReelStops[i] = Math.floor(
|
|
1554
|
+
this.state.rng.randomFloat(0, reels[i].length - 1)
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
for (let ridx = 0; ridx < this.getCurrentGameMode().reelsAmount; ridx++) {
|
|
1559
|
+
const reelPos = finalReelStops[ridx];
|
|
1560
|
+
if (this.config.padSymbols && this.config.padSymbols > 0) {
|
|
1561
|
+
for (let p = this.config.padSymbols - 1; p >= 0; p--) {
|
|
1562
|
+
const topPos = (reelPos - (p + 1)) % reels[ridx].length;
|
|
1563
|
+
this.board.paddingTop[ridx].push(reels[ridx][topPos]);
|
|
1564
|
+
const bottomPos = (reelPos + this.getCurrentGameMode().symbolsPerReel[ridx] + p) % reels[ridx].length;
|
|
1565
|
+
this.board.paddingBottom[ridx].unshift(reels[ridx][bottomPos]);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
for (let row = 0; row < this.getCurrentGameMode().symbolsPerReel[ridx]; row++) {
|
|
1569
|
+
const symbol = reels[ridx][(reelPos + row) % reels[ridx].length];
|
|
1570
|
+
if (!symbol) {
|
|
1571
|
+
throw new Error(`Failed to get symbol at pos ${reelPos + row} on reel ${ridx}`);
|
|
1572
|
+
}
|
|
1573
|
+
this.board.reels[ridx][row] = symbol;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
// src/WinType.ts
|
|
1580
|
+
var WinType = class {
|
|
1581
|
+
payout;
|
|
1582
|
+
winCombinations;
|
|
1583
|
+
ctx;
|
|
1584
|
+
wildSymbol;
|
|
1585
|
+
constructor(opts) {
|
|
1586
|
+
this.payout = 0;
|
|
1587
|
+
this.winCombinations = [];
|
|
1588
|
+
this.wildSymbol = opts?.wildSymbol;
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Sets the simulation context for this WinType instance.
|
|
1592
|
+
*
|
|
1593
|
+
* This gives the WinType access to the current board.
|
|
1594
|
+
*/
|
|
1595
|
+
context(ctx) {
|
|
1596
|
+
this.ctx = ctx;
|
|
1597
|
+
return this;
|
|
1598
|
+
}
|
|
1599
|
+
ensureContext() {
|
|
1600
|
+
if (!this.ctx) {
|
|
1601
|
+
throw new Error("WinType context is not set. Call context(ctx) first.");
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Implementation of win evaluation logic. Sets `this.payout` and `this.winCombinations`.
|
|
1606
|
+
*/
|
|
1607
|
+
evaluateWins() {
|
|
1608
|
+
this.ensureContext();
|
|
1609
|
+
return this;
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Custom post-processing of wins, e.g. for handling multipliers.
|
|
1613
|
+
*/
|
|
1614
|
+
postProcess(func) {
|
|
1615
|
+
this.ensureContext();
|
|
1616
|
+
const result = func(this, this.ctx);
|
|
1617
|
+
this.payout = result.payout;
|
|
1618
|
+
this.winCombinations = result.winCombinations;
|
|
1619
|
+
return this;
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Returns the total payout and detailed win combinations.
|
|
1623
|
+
*/
|
|
1624
|
+
getWins() {
|
|
1625
|
+
return {
|
|
1626
|
+
payout: this.payout,
|
|
1627
|
+
winCombinations: this.winCombinations
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
// src/winTypes/LinesWinType.ts
|
|
1633
|
+
var LinesWinType = class extends WinType {
|
|
1634
|
+
lines;
|
|
1635
|
+
constructor(opts) {
|
|
1636
|
+
super(opts);
|
|
1637
|
+
this.lines = opts.lines;
|
|
1638
|
+
if (Object.keys(this.lines).length === 0) {
|
|
1639
|
+
throw new Error("LinesWinType must have at least one line defined.");
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
validateConfig() {
|
|
1643
|
+
const reelsAmount = this.ctx.getCurrentGameMode().reelsAmount;
|
|
1644
|
+
const symsPerReel = this.ctx.getCurrentGameMode().symbolsPerReel;
|
|
1645
|
+
for (const [lineNum, positions] of Object.entries(this.lines)) {
|
|
1646
|
+
if (positions.length !== reelsAmount) {
|
|
1647
|
+
throw new Error(
|
|
1648
|
+
`Line ${lineNum} has ${positions.length} positions, but the current game mode has ${reelsAmount} reels.`
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
for (let i = 0; i < positions.length; i++) {
|
|
1652
|
+
if (positions[i] < 0 || positions[i] >= symsPerReel[i]) {
|
|
1653
|
+
throw new Error(
|
|
1654
|
+
`Line ${lineNum} has an invalid position ${positions[i]} on reel ${i}. Valid range is 0 to ${symsPerReel[i] - 1}.`
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
const firstLine = Math.min(...Object.keys(this.lines).map(Number));
|
|
1660
|
+
if (firstLine !== 1) {
|
|
1661
|
+
throw new Error(
|
|
1662
|
+
`Lines must start from 1. Found line ${firstLine} as the first line.`
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
isWild(symbol) {
|
|
1667
|
+
return !!this.wildSymbol && symbol.compare(this.wildSymbol);
|
|
1668
|
+
}
|
|
1669
|
+
evaluateWins() {
|
|
1670
|
+
this.ensureContext();
|
|
1671
|
+
this.validateConfig();
|
|
1672
|
+
const lineWins = [];
|
|
1673
|
+
let payout = 0;
|
|
1674
|
+
const reels = this.ctx.board.reels;
|
|
1675
|
+
const reelsAmount = this.ctx.getCurrentGameMode().reelsAmount;
|
|
1676
|
+
for (const [lineNumStr, lineDef] of Object.entries(this.lines)) {
|
|
1677
|
+
const lineNum = Number(lineNumStr);
|
|
1678
|
+
let baseSymbol = null;
|
|
1679
|
+
let leadingWilds = 0;
|
|
1680
|
+
const chain = [];
|
|
1681
|
+
const details = [];
|
|
1682
|
+
for (let ridx = 0; ridx < reelsAmount; ridx++) {
|
|
1683
|
+
const rowIdx = lineDef[ridx];
|
|
1684
|
+
const sym = reels[ridx][rowIdx];
|
|
1685
|
+
if (!sym) throw new Error("Encountered an invalid symbol while evaluating wins.");
|
|
1686
|
+
const wild = this.isWild(sym);
|
|
1687
|
+
if (ridx === 0) {
|
|
1688
|
+
chain.push(sym);
|
|
1689
|
+
details.push({ reelIndex: ridx, posIndex: rowIdx, symbol: sym, isWild: wild });
|
|
1690
|
+
if (wild) leadingWilds++;
|
|
1691
|
+
else baseSymbol = sym;
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
if (wild) {
|
|
1695
|
+
chain.push(sym);
|
|
1696
|
+
details.push({
|
|
1697
|
+
reelIndex: ridx,
|
|
1698
|
+
posIndex: rowIdx,
|
|
1699
|
+
symbol: sym,
|
|
1700
|
+
isWild: true,
|
|
1701
|
+
substitutedFor: baseSymbol || void 0
|
|
1702
|
+
});
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
1705
|
+
if (!baseSymbol) {
|
|
1706
|
+
baseSymbol = sym;
|
|
1707
|
+
chain.push(sym);
|
|
1708
|
+
details.push({ reelIndex: ridx, posIndex: rowIdx, symbol: sym, isWild: false });
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
if (sym.id === baseSymbol.id) {
|
|
1712
|
+
chain.push(sym);
|
|
1713
|
+
details.push({ reelIndex: ridx, posIndex: rowIdx, symbol: sym, isWild: false });
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1716
|
+
break;
|
|
1717
|
+
}
|
|
1718
|
+
if (chain.length === 0) continue;
|
|
1719
|
+
const allWild = chain.every((s) => this.isWild(s));
|
|
1720
|
+
const wildRepresentative = this.wildSymbol instanceof GameSymbol ? this.wildSymbol : null;
|
|
1721
|
+
const len = chain.length;
|
|
1722
|
+
let bestPayout = 0;
|
|
1723
|
+
let bestType = null;
|
|
1724
|
+
let payingSymbol = null;
|
|
1725
|
+
if (baseSymbol?.pays && baseSymbol.pays[len]) {
|
|
1726
|
+
bestPayout = baseSymbol.pays[len];
|
|
1727
|
+
bestType = "substituted";
|
|
1728
|
+
payingSymbol = baseSymbol;
|
|
1729
|
+
}
|
|
1730
|
+
if (allWild && wildRepresentative?.pays && wildRepresentative.pays[len]) {
|
|
1731
|
+
const wildPay = wildRepresentative.pays[len];
|
|
1732
|
+
if (wildPay > bestPayout) {
|
|
1733
|
+
bestPayout = wildPay;
|
|
1734
|
+
bestType = "pure-wild";
|
|
1735
|
+
payingSymbol = wildRepresentative;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
if (!bestPayout || !bestType || !payingSymbol) continue;
|
|
1739
|
+
const minLen = payingSymbol.pays ? Math.min(...Object.keys(payingSymbol.pays).map(Number)) : Infinity;
|
|
1740
|
+
if (len < minLen) continue;
|
|
1741
|
+
const wildCount = details.filter((d) => d.isWild).length;
|
|
1742
|
+
const nonWildCount = len - wildCount;
|
|
1743
|
+
lineWins.push({
|
|
1744
|
+
lineNumber: lineNum,
|
|
1745
|
+
kind: len,
|
|
1746
|
+
payout: bestPayout,
|
|
1747
|
+
symbol: payingSymbol,
|
|
1748
|
+
winType: bestType,
|
|
1749
|
+
substitutedBaseSymbol: bestType === "pure-wild" ? null : baseSymbol,
|
|
1750
|
+
symbols: details,
|
|
1751
|
+
stats: { wildCount, nonWildCount, leadingWilds }
|
|
1752
|
+
});
|
|
1753
|
+
payout += bestPayout;
|
|
1754
|
+
}
|
|
1755
|
+
for (const win of lineWins) {
|
|
1756
|
+
this.ctx.recordSymbolOccurrence({
|
|
1757
|
+
kind: win.kind,
|
|
1758
|
+
symbolId: win.symbol.id,
|
|
1759
|
+
spinType: this.ctx.state.currentSpinType
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
this.payout = payout;
|
|
1763
|
+
this.winCombinations = lineWins;
|
|
1764
|
+
return this;
|
|
1765
|
+
}
|
|
1766
|
+
};
|
|
1767
|
+
|
|
1768
|
+
// src/winTypes/ClusterWinType.ts
|
|
1769
|
+
var ClusterWinType = class extends WinType {
|
|
1770
|
+
};
|
|
1771
|
+
|
|
1772
|
+
// src/winTypes/ManywaysWinType.ts
|
|
1773
|
+
var ManywaysWinType = class extends WinType {
|
|
1774
|
+
};
|
|
1775
|
+
|
|
1776
|
+
// src/optimizer/OptimizationConditions.ts
|
|
1777
|
+
import assert from "assert";
|
|
1778
|
+
var OptimizationConditions = class {
|
|
1779
|
+
rtp;
|
|
1780
|
+
avgWin;
|
|
1781
|
+
hitRate;
|
|
1782
|
+
searchRange;
|
|
1783
|
+
forceSearch;
|
|
1784
|
+
priority;
|
|
1785
|
+
constructor(opts) {
|
|
1786
|
+
let { rtp, avgWin, hitRate, searchConditions, priority } = opts;
|
|
1787
|
+
if (rtp == void 0 || rtp === "x") {
|
|
1788
|
+
assert(avgWin !== void 0 && hitRate !== void 0, "If RTP is not specified, hit-rate (hr) and average win amount (av_win) must be given.");
|
|
1789
|
+
rtp = Math.round(avgWin / Number(hitRate) * 1e5) / 1e5;
|
|
1790
|
+
}
|
|
1791
|
+
let noneCount = 0;
|
|
1792
|
+
for (const val of [rtp, avgWin, hitRate]) {
|
|
1793
|
+
if (val === void 0) noneCount++;
|
|
1794
|
+
}
|
|
1795
|
+
assert(noneCount <= 1, "Invalid combination of optimization conditions.");
|
|
1796
|
+
this.searchRange = [-1, -1];
|
|
1797
|
+
this.forceSearch = {};
|
|
1798
|
+
if (typeof searchConditions === "number") {
|
|
1799
|
+
this.searchRange = [searchConditions, searchConditions];
|
|
1800
|
+
}
|
|
1801
|
+
if (Array.isArray(searchConditions)) {
|
|
1802
|
+
if (searchConditions[0] > searchConditions[1] || searchConditions.length !== 2) {
|
|
1803
|
+
throw new Error("Invalid searchConditions range.");
|
|
1804
|
+
}
|
|
1805
|
+
this.searchRange = searchConditions;
|
|
1806
|
+
}
|
|
1807
|
+
if (typeof searchConditions === "object" && !Array.isArray(searchConditions)) {
|
|
1808
|
+
this.searchRange = [-1, -1];
|
|
1809
|
+
this.forceSearch = searchConditions;
|
|
1810
|
+
}
|
|
1811
|
+
this.rtp = rtp;
|
|
1812
|
+
this.avgWin = avgWin;
|
|
1813
|
+
this.hitRate = hitRate;
|
|
1814
|
+
this.priority = priority;
|
|
1815
|
+
}
|
|
1816
|
+
getRtp() {
|
|
1817
|
+
return this.rtp;
|
|
1818
|
+
}
|
|
1819
|
+
getAvgWin() {
|
|
1820
|
+
return this.avgWin;
|
|
1821
|
+
}
|
|
1822
|
+
getHitRate() {
|
|
1823
|
+
return this.hitRate;
|
|
1824
|
+
}
|
|
1825
|
+
getSearchRange() {
|
|
1826
|
+
return this.searchRange;
|
|
1827
|
+
}
|
|
1828
|
+
getForceSearch() {
|
|
1829
|
+
return this.forceSearch;
|
|
1830
|
+
}
|
|
1831
|
+
};
|
|
1832
|
+
|
|
1833
|
+
// src/optimizer/OptimizationScaling.ts
|
|
1834
|
+
var OptimizationScaling = class {
|
|
1835
|
+
config;
|
|
1836
|
+
constructor(opts) {
|
|
1837
|
+
this.config = opts;
|
|
1838
|
+
}
|
|
1839
|
+
getConfig() {
|
|
1840
|
+
return this.config;
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
1843
|
+
|
|
1844
|
+
// src/optimizer/OptimizationParameters.ts
|
|
1845
|
+
var OptimizationParameters = class _OptimizationParameters {
|
|
1846
|
+
parameters;
|
|
1847
|
+
constructor(opts) {
|
|
1848
|
+
this.parameters = {
|
|
1849
|
+
..._OptimizationParameters.DEFAULT_PARAMETERS,
|
|
1850
|
+
...opts
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
static DEFAULT_PARAMETERS = {
|
|
1854
|
+
numShowPigs: 5e3,
|
|
1855
|
+
numPigsPerFence: 1e4,
|
|
1856
|
+
threadsFenceConstruction: 16,
|
|
1857
|
+
threadsShowConstruction: 16,
|
|
1858
|
+
testSpins: [50, 100, 200],
|
|
1859
|
+
testSpinsWeights: [0.3, 0.4, 0.3],
|
|
1860
|
+
simulationTrials: 5e3,
|
|
1861
|
+
graphIndexes: [],
|
|
1862
|
+
run1000Batch: false,
|
|
1863
|
+
minMeanToMedian: 4,
|
|
1864
|
+
maxMeanToMedian: 8,
|
|
1865
|
+
pmbRtp: 1,
|
|
1866
|
+
scoreType: "rtp"
|
|
1867
|
+
};
|
|
1868
|
+
getParameters() {
|
|
1869
|
+
return this.parameters;
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
// src/Simulation.ts
|
|
1874
|
+
import fs3 from "fs";
|
|
1875
|
+
import path3 from "path";
|
|
1876
|
+
import assert2 from "assert";
|
|
1877
|
+
import { buildSync } from "esbuild";
|
|
1878
|
+
import { Worker, isMainThread as isMainThread2, parentPort, workerData } from "worker_threads";
|
|
1879
|
+
|
|
1880
|
+
// src/utils/zstd.ts
|
|
1881
|
+
import path2 from "path";
|
|
1882
|
+
import { spawn } from "child_process";
|
|
1883
|
+
async function zstd(...args) {
|
|
1884
|
+
return new Promise((resolve, reject) => {
|
|
1885
|
+
const task = spawn(path2.join(__dirname, "./lib/zstd.exe"), args);
|
|
1886
|
+
task.on("error", (error) => {
|
|
1887
|
+
console.error("Error:", error);
|
|
1888
|
+
reject(error);
|
|
1889
|
+
});
|
|
1890
|
+
task.on("exit", () => {
|
|
1891
|
+
resolve(true);
|
|
1892
|
+
});
|
|
1893
|
+
task.on("close", () => {
|
|
1894
|
+
resolve(true);
|
|
1895
|
+
});
|
|
1896
|
+
task.stdout.on("data", (data) => {
|
|
1897
|
+
console.log(data.toString());
|
|
1898
|
+
});
|
|
1899
|
+
task.stderr.on("data", (data) => {
|
|
1900
|
+
console.log(data.toString());
|
|
1901
|
+
});
|
|
1902
|
+
task.stdout.on("error", (data) => {
|
|
1903
|
+
console.log(data.toString());
|
|
1904
|
+
reject(data.toString());
|
|
1905
|
+
});
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// src/Simulation.ts
|
|
1910
|
+
var completedSimulations = 0;
|
|
1911
|
+
var TEMP_FILENAME = "__temp_compiled_src_IGNORE.js";
|
|
1912
|
+
var Simulation = class _Simulation {
|
|
1913
|
+
gameConfigOpts;
|
|
1914
|
+
gameConfig;
|
|
1915
|
+
simRunsAmount;
|
|
1916
|
+
concurrency;
|
|
1917
|
+
wallet;
|
|
1918
|
+
library;
|
|
1919
|
+
records;
|
|
1920
|
+
debug = false;
|
|
1921
|
+
constructor(opts, gameConfigOpts) {
|
|
1922
|
+
this.gameConfig = new GameConfig(gameConfigOpts);
|
|
1923
|
+
this.gameConfigOpts = gameConfigOpts;
|
|
1924
|
+
this.simRunsAmount = opts.simRunsAmount || {};
|
|
1925
|
+
this.concurrency = (opts.concurrency || 6) >= 2 ? opts.concurrency || 6 : 2;
|
|
1926
|
+
this.wallet = new Wallet();
|
|
1927
|
+
this.library = /* @__PURE__ */ new Map();
|
|
1928
|
+
this.records = [];
|
|
1929
|
+
const gameModeKeys = Object.keys(this.gameConfig.config.gameModes);
|
|
1930
|
+
assert2(
|
|
1931
|
+
Object.values(this.gameConfig.config.gameModes).map((m) => gameModeKeys.includes(m.name)).every((v) => v === true),
|
|
1932
|
+
"Game mode name must match its key in the gameModes object."
|
|
1933
|
+
);
|
|
1934
|
+
if (isMainThread2) {
|
|
1935
|
+
this.preprocessFiles();
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
async runSimulation(opts) {
|
|
1939
|
+
const debug = opts.debug || false;
|
|
1940
|
+
this.debug = debug;
|
|
1941
|
+
const gameModesToSimulate = Object.keys(this.simRunsAmount);
|
|
1942
|
+
const configuredGameModes = Object.keys(this.gameConfig.config.gameModes);
|
|
1943
|
+
if (gameModesToSimulate.length === 0) {
|
|
1944
|
+
throw new Error("No game modes configured for simulation.");
|
|
1945
|
+
}
|
|
1946
|
+
this.gameConfig.generateReelsetFiles();
|
|
1947
|
+
if (isMainThread2) {
|
|
1948
|
+
const debugDetails = {};
|
|
1949
|
+
for (const mode of gameModesToSimulate) {
|
|
1950
|
+
completedSimulations = 0;
|
|
1951
|
+
this.wallet = new Wallet();
|
|
1952
|
+
this.library = /* @__PURE__ */ new Map();
|
|
1953
|
+
debugDetails[mode] = {};
|
|
1954
|
+
console.log(`
|
|
1955
|
+
Simulating game mode: ${mode}`);
|
|
1956
|
+
console.time(mode);
|
|
1957
|
+
const runs = this.simRunsAmount[mode] || 0;
|
|
1958
|
+
if (runs <= 0) continue;
|
|
1959
|
+
if (!configuredGameModes.includes(mode)) {
|
|
1960
|
+
throw new Error(
|
|
1961
|
+
`Tried to simulate game mode "${mode}", but it's not configured in the game config.`
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
|
|
1965
|
+
await this.spawnWorkersForGameMode({ mode, simNumsToCriteria });
|
|
1966
|
+
createDirIfNotExists(
|
|
1967
|
+
path3.join(
|
|
1968
|
+
process.cwd(),
|
|
1969
|
+
this.gameConfig.config.outputDir,
|
|
1970
|
+
"optimization_files"
|
|
1971
|
+
)
|
|
1972
|
+
);
|
|
1973
|
+
createDirIfNotExists(
|
|
1974
|
+
path3.join(process.cwd(), this.gameConfig.config.outputDir, "publish_files")
|
|
1975
|
+
);
|
|
1976
|
+
_Simulation.writeLookupTableCSV({
|
|
1977
|
+
gameMode: mode,
|
|
1978
|
+
library: this.library,
|
|
1979
|
+
gameConfig: this.gameConfig.config
|
|
1980
|
+
});
|
|
1981
|
+
_Simulation.writeLookupTableSegmentedCSV({
|
|
1982
|
+
gameMode: mode,
|
|
1983
|
+
library: this.library,
|
|
1984
|
+
gameConfig: this.gameConfig.config
|
|
1985
|
+
});
|
|
1986
|
+
_Simulation.writeRecords({
|
|
1987
|
+
gameMode: mode,
|
|
1988
|
+
records: this.records,
|
|
1989
|
+
gameConfig: this.gameConfig.config,
|
|
1990
|
+
debug: this.debug
|
|
1991
|
+
});
|
|
1992
|
+
await _Simulation.writeBooksJson({
|
|
1993
|
+
gameMode: mode,
|
|
1994
|
+
library: this.library,
|
|
1995
|
+
gameConfig: this.gameConfig.config
|
|
1996
|
+
});
|
|
1997
|
+
_Simulation.writeIndexJson({
|
|
1998
|
+
gameConfig: this.gameConfig.config
|
|
1999
|
+
});
|
|
2000
|
+
debugDetails[mode].rtp = this.wallet.getCumulativeWins() / (runs * this.gameConfig.config.gameModes[mode].cost);
|
|
2001
|
+
debugDetails[mode].wins = this.wallet.getCumulativeWins();
|
|
2002
|
+
debugDetails[mode].winsPerSpinType = this.wallet.getCumulativeWinsPerSpinType();
|
|
2003
|
+
console.timeEnd(mode);
|
|
2004
|
+
}
|
|
2005
|
+
console.log("\n=== SIMULATION SUMMARY ===");
|
|
2006
|
+
console.table(debugDetails);
|
|
2007
|
+
}
|
|
2008
|
+
let desiredSims = 0;
|
|
2009
|
+
let actualSims = 0;
|
|
2010
|
+
const criteriaToRetries = {};
|
|
2011
|
+
if (!isMainThread2) {
|
|
2012
|
+
const { mode, simStart, simEnd, index } = workerData;
|
|
2013
|
+
const simNumsToCriteria = ResultSet.assignCriteriaToSimulations(this, mode);
|
|
2014
|
+
for (let simId = simStart; simId <= simEnd; simId++) {
|
|
2015
|
+
if (this.debug) desiredSims++;
|
|
2016
|
+
const criteria = simNumsToCriteria[simId] || "N/A";
|
|
2017
|
+
const ctx = new SimulationContext(this.gameConfigOpts);
|
|
2018
|
+
if (!criteriaToRetries[criteria]) {
|
|
2019
|
+
criteriaToRetries[criteria] = 0;
|
|
2020
|
+
}
|
|
2021
|
+
ctx.runSingleSimulation({ simId, mode, criteria, index });
|
|
2022
|
+
if (this.debug) {
|
|
2023
|
+
criteriaToRetries[criteria] += ctx.actualSims - 1;
|
|
2024
|
+
actualSims += ctx.actualSims;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
if (this.debug) {
|
|
2028
|
+
console.log(`Desired ${desiredSims}, Actual ${actualSims}`);
|
|
2029
|
+
console.log(`Retries per criteria:`, criteriaToRetries);
|
|
2030
|
+
}
|
|
2031
|
+
parentPort?.postMessage({
|
|
2032
|
+
type: "done",
|
|
2033
|
+
workerNum: index
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Runs all simulations for a specific game mode.
|
|
2039
|
+
*/
|
|
2040
|
+
async spawnWorkersForGameMode(opts) {
|
|
2041
|
+
const { mode, simNumsToCriteria } = opts;
|
|
2042
|
+
const numSims = Object.keys(simNumsToCriteria).length;
|
|
2043
|
+
const simRangesPerChunk = this.getSimRangesForChunks(numSims, this.concurrency);
|
|
2044
|
+
await Promise.all(
|
|
2045
|
+
simRangesPerChunk.map(([simStart, simEnd], index) => {
|
|
2046
|
+
return this.callWorker({
|
|
2047
|
+
basePath: this.gameConfig.config.outputDir,
|
|
2048
|
+
mode,
|
|
2049
|
+
simStart,
|
|
2050
|
+
simEnd,
|
|
2051
|
+
index,
|
|
2052
|
+
totalSims: numSims
|
|
2053
|
+
});
|
|
2054
|
+
})
|
|
2055
|
+
);
|
|
2056
|
+
}
|
|
2057
|
+
async callWorker(opts) {
|
|
2058
|
+
const { mode, simEnd, simStart, basePath, index, totalSims } = opts;
|
|
2059
|
+
function logArrowProgress(current, total) {
|
|
2060
|
+
const percentage = current / total * 100;
|
|
2061
|
+
const progressBarLength = 50;
|
|
2062
|
+
const filledLength = Math.round(progressBarLength * current / total);
|
|
2063
|
+
const bar = "\u2588".repeat(filledLength) + "-".repeat(progressBarLength - filledLength);
|
|
2064
|
+
process.stdout.write(`\r[${bar}] ${percentage.toFixed(2)}% (${current}/${total})`);
|
|
2065
|
+
if (current === total) {
|
|
2066
|
+
process.stdout.write("\n");
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
return new Promise((resolve, reject) => {
|
|
2070
|
+
const scriptPath = path3.join(process.cwd(), basePath, TEMP_FILENAME);
|
|
2071
|
+
const worker = new Worker(scriptPath, {
|
|
2072
|
+
workerData: {
|
|
2073
|
+
mode,
|
|
2074
|
+
simStart,
|
|
2075
|
+
simEnd,
|
|
2076
|
+
index
|
|
2077
|
+
}
|
|
2078
|
+
});
|
|
2079
|
+
worker.on("message", (msg) => {
|
|
2080
|
+
if (msg.type === "log") {
|
|
2081
|
+
} else if (msg.type === "complete") {
|
|
2082
|
+
completedSimulations++;
|
|
2083
|
+
if (completedSimulations % 250 === 0) {
|
|
2084
|
+
logArrowProgress(completedSimulations, totalSims);
|
|
2085
|
+
}
|
|
2086
|
+
const book = Book.fromSerialized(msg.book);
|
|
2087
|
+
this.library.set(book.id.toString(), book);
|
|
2088
|
+
this.wallet.mergeSerialized(msg.wallet);
|
|
2089
|
+
this.mergeRecords(msg.records);
|
|
2090
|
+
} else if (msg.type === "done") {
|
|
2091
|
+
resolve(true);
|
|
2092
|
+
}
|
|
2093
|
+
});
|
|
2094
|
+
worker.on("error", (error) => {
|
|
2095
|
+
console.error("Error:", error);
|
|
2096
|
+
reject(error);
|
|
2097
|
+
});
|
|
2098
|
+
worker.on("exit", (code) => {
|
|
2099
|
+
if (code !== 0) {
|
|
2100
|
+
reject(new Error(`Worker stopped with exit code ${code}`));
|
|
2101
|
+
}
|
|
2102
|
+
});
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
/**
|
|
2106
|
+
* Creates a CSV file in the format "simulationId,weight,payout".
|
|
2107
|
+
*
|
|
2108
|
+
* `weight` defaults to 1.
|
|
2109
|
+
*/
|
|
2110
|
+
static writeLookupTableCSV(opts) {
|
|
2111
|
+
const { gameMode, fileNameWithoutExtension, library, gameConfig } = opts;
|
|
2112
|
+
const rows = [];
|
|
2113
|
+
for (const [bookId, book] of library.entries()) {
|
|
2114
|
+
rows.push(`${book.id},1,${Math.round(book.getPayout())}`);
|
|
2115
|
+
}
|
|
2116
|
+
rows.sort((a, b) => Number(a.split(",")[0]) - Number(b.split(",")[0]));
|
|
2117
|
+
const outputFileName = fileNameWithoutExtension ? `${fileNameWithoutExtension}.csv` : `lookUpTable_${gameMode}.csv`;
|
|
2118
|
+
const outputFilePath = path3.join(gameConfig.outputDir, outputFileName);
|
|
2119
|
+
writeFile(outputFilePath, rows.join("\n"));
|
|
2120
|
+
return outputFilePath;
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Creates a CSV file in the format "simulationId,criteria,payoutBase,payoutFreespins".
|
|
2124
|
+
*/
|
|
2125
|
+
static writeLookupTableSegmentedCSV(opts) {
|
|
2126
|
+
const { gameMode, fileNameWithoutExtension, library, gameConfig } = opts;
|
|
2127
|
+
const rows = [];
|
|
2128
|
+
for (const [bookId, book] of library.entries()) {
|
|
2129
|
+
rows.push(
|
|
2130
|
+
`${book.id},${book.criteria},${book.getBasegameWins()},${book.getFreespinsWins()}`
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
rows.sort((a, b) => Number(a.split(",")[0]) - Number(b.split(",")[0]));
|
|
2134
|
+
const outputFileName = fileNameWithoutExtension ? `${fileNameWithoutExtension}.csv` : `lookUpTableSegmented_${gameMode}.csv`;
|
|
2135
|
+
const outputFilePath = path3.join(gameConfig.outputDir, outputFileName);
|
|
2136
|
+
writeFile(outputFilePath, rows.join("\n"));
|
|
2137
|
+
return outputFilePath;
|
|
2138
|
+
}
|
|
2139
|
+
static writeRecords(opts) {
|
|
2140
|
+
const { gameMode, fileNameWithoutExtension, records, gameConfig, debug } = opts;
|
|
2141
|
+
const outputFileName = fileNameWithoutExtension ? `${fileNameWithoutExtension}.json` : `force_record_${gameMode}.json`;
|
|
2142
|
+
const outputFilePath = path3.join(gameConfig.outputDir, outputFileName);
|
|
2143
|
+
writeFile(outputFilePath, JSON.stringify(records, null, 2));
|
|
2144
|
+
if (debug) _Simulation.logSymbolOccurrences(records);
|
|
2145
|
+
return outputFilePath;
|
|
2146
|
+
}
|
|
2147
|
+
static writeIndexJson(opts) {
|
|
2148
|
+
const { gameConfig } = opts;
|
|
2149
|
+
const outputFilePath = path3.join(
|
|
2150
|
+
process.cwd(),
|
|
2151
|
+
gameConfig.outputDir,
|
|
2152
|
+
"publish_files",
|
|
2153
|
+
"index.json"
|
|
2154
|
+
);
|
|
2155
|
+
const modes = Object.entries(gameConfig.gameModes).map(([mode, modeConfig]) => ({
|
|
2156
|
+
name: mode,
|
|
2157
|
+
cost: modeConfig.cost,
|
|
2158
|
+
events: `books_${mode}.jsonl.zst`,
|
|
2159
|
+
weights: `lookUpTable_${mode}_0.csv`
|
|
2160
|
+
}));
|
|
2161
|
+
writeFile(outputFilePath, JSON.stringify({ modes }, null, 2));
|
|
2162
|
+
}
|
|
2163
|
+
static async writeBooksJson(opts) {
|
|
2164
|
+
const { gameMode, fileNameWithoutExtension, library, gameConfig } = opts;
|
|
2165
|
+
const outputFileName = fileNameWithoutExtension ? `${fileNameWithoutExtension}.jsonl` : `books_${gameMode}.jsonl`;
|
|
2166
|
+
const outputFilePath = path3.join(gameConfig.outputDir, outputFileName);
|
|
2167
|
+
const books = Array.from(library.values()).map((b) => b.serialize()).map((b) => ({
|
|
2168
|
+
id: b.id,
|
|
2169
|
+
payoutMultiplier: b.payout,
|
|
2170
|
+
events: b.events
|
|
2171
|
+
})).sort((a, b) => a.id - b.id);
|
|
2172
|
+
const contents = JSONL.stringify(books);
|
|
2173
|
+
writeFile(outputFilePath, contents);
|
|
2174
|
+
const compressedFileName = fileNameWithoutExtension ? `${fileNameWithoutExtension}.jsonl.zst` : `books_${gameMode}.jsonl.zst`;
|
|
2175
|
+
const compressedFilePath = path3.join(
|
|
2176
|
+
process.cwd(),
|
|
2177
|
+
gameConfig.outputDir,
|
|
2178
|
+
"publish_files",
|
|
2179
|
+
compressedFileName
|
|
2180
|
+
);
|
|
2181
|
+
fs3.rmSync(compressedFilePath, { force: true });
|
|
2182
|
+
await zstd("-f", outputFilePath, "-o", compressedFilePath);
|
|
2183
|
+
}
|
|
2184
|
+
static logSymbolOccurrences(records) {
|
|
2185
|
+
const validRecords = records.filter(
|
|
2186
|
+
(r) => r.search.some((s) => s.name === "symbolId") && r.search.some((s) => s.name === "kind")
|
|
2187
|
+
);
|
|
2188
|
+
const structuredRecords = validRecords.map((r) => {
|
|
2189
|
+
const symbolEntry = r.search.find((s) => s.name === "symbolId");
|
|
2190
|
+
const kindEntry = r.search.find((s) => s.name === "kind");
|
|
2191
|
+
const spinTypeEntry = r.search.find((s) => s.name === "spinType");
|
|
2192
|
+
return {
|
|
2193
|
+
symbol: symbolEntry ? symbolEntry.value : "unknown",
|
|
2194
|
+
kind: kindEntry ? kindEntry.value : "unknown",
|
|
2195
|
+
spinType: spinTypeEntry ? spinTypeEntry.value : "unknown",
|
|
2196
|
+
timesTriggered: r.timesTriggered
|
|
2197
|
+
};
|
|
2198
|
+
}).sort((a, b) => {
|
|
2199
|
+
if (a.symbol < b.symbol) return -1;
|
|
2200
|
+
if (a.symbol > b.symbol) return 1;
|
|
2201
|
+
if (a.kind < b.kind) return -1;
|
|
2202
|
+
if (a.kind > b.kind) return 1;
|
|
2203
|
+
if (a.spinType < b.spinType) return -1;
|
|
2204
|
+
if (a.spinType > b.spinType) return 1;
|
|
2205
|
+
return 0;
|
|
2206
|
+
});
|
|
2207
|
+
console.table(structuredRecords);
|
|
2208
|
+
}
|
|
2209
|
+
/**
|
|
2210
|
+
* Compiles user configured game to JS for use in different Node processes
|
|
2211
|
+
*/
|
|
2212
|
+
preprocessFiles() {
|
|
2213
|
+
const builtFilePath = path3.join(this.gameConfig.config.outputDir, TEMP_FILENAME);
|
|
2214
|
+
fs3.rmSync(builtFilePath, { force: true });
|
|
2215
|
+
buildSync({
|
|
2216
|
+
entryPoints: [process.cwd()],
|
|
2217
|
+
bundle: true,
|
|
2218
|
+
platform: "node",
|
|
2219
|
+
outfile: path3.join(this.gameConfig.config.outputDir, TEMP_FILENAME),
|
|
2220
|
+
external: ["esbuild", "@mongodb-js/zstd"]
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
getSimRangesForChunks(total, chunks) {
|
|
2224
|
+
const base = Math.floor(total / chunks);
|
|
2225
|
+
const remainder = total % chunks;
|
|
2226
|
+
const result = [];
|
|
2227
|
+
let current = 1;
|
|
2228
|
+
for (let i = 0; i < chunks; i++) {
|
|
2229
|
+
const size = base + (i < remainder ? 1 : 0);
|
|
2230
|
+
const start = current;
|
|
2231
|
+
const end = current + size - 1;
|
|
2232
|
+
result.push([start, end]);
|
|
2233
|
+
current = end + 1;
|
|
2234
|
+
}
|
|
2235
|
+
return result;
|
|
2236
|
+
}
|
|
2237
|
+
mergeRecords(otherRecords) {
|
|
2238
|
+
for (const otherRecord of otherRecords) {
|
|
2239
|
+
let record = this.records.find((r) => {
|
|
2240
|
+
if (r.search.length !== otherRecord.search.length) return false;
|
|
2241
|
+
for (let i = 0; i < r.search.length; i++) {
|
|
2242
|
+
if (r.search[i].name !== otherRecord.search[i].name) return false;
|
|
2243
|
+
if (r.search[i].value !== otherRecord.search[i].value) return false;
|
|
2244
|
+
}
|
|
2245
|
+
return true;
|
|
2246
|
+
});
|
|
2247
|
+
if (!record) {
|
|
2248
|
+
record = {
|
|
2249
|
+
search: otherRecord.search,
|
|
2250
|
+
timesTriggered: 0,
|
|
2251
|
+
bookIds: []
|
|
2252
|
+
};
|
|
2253
|
+
this.records.push(record);
|
|
2254
|
+
}
|
|
2255
|
+
record.timesTriggered += otherRecord.timesTriggered;
|
|
2256
|
+
for (const bookId of otherRecord.bookIds) {
|
|
2257
|
+
if (!record.bookIds.includes(bookId)) {
|
|
2258
|
+
record.bookIds.push(bookId);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
};
|
|
2264
|
+
var SimulationContext = class extends Board {
|
|
2265
|
+
constructor(opts) {
|
|
2266
|
+
super(opts);
|
|
2267
|
+
}
|
|
2268
|
+
actualSims = 0;
|
|
2269
|
+
/**
|
|
2270
|
+
* Will run a single simulation until the specified criteria is met.
|
|
2271
|
+
*/
|
|
2272
|
+
runSingleSimulation(opts) {
|
|
2273
|
+
const { simId, mode, criteria, index } = opts;
|
|
2274
|
+
this.state.currentGameMode = mode;
|
|
2275
|
+
this.state.currentSimulationId = simId;
|
|
2276
|
+
this.state.isCriteriaMet = false;
|
|
2277
|
+
while (!this.state.isCriteriaMet) {
|
|
2278
|
+
this.actualSims++;
|
|
2279
|
+
this.resetSimulation();
|
|
2280
|
+
const resultSet = this.getGameModeCriteria(this.state.currentGameMode, criteria);
|
|
2281
|
+
this.state.currentResultSet = resultSet;
|
|
2282
|
+
this.state.book.criteria = resultSet.criteria;
|
|
2283
|
+
this.handleGameFlow();
|
|
2284
|
+
if (resultSet.meetsCriteria(this)) {
|
|
2285
|
+
this.state.isCriteriaMet = true;
|
|
2286
|
+
this.config.hooks.onSimulationAccepted?.(this);
|
|
2287
|
+
this.record({
|
|
2288
|
+
criteria: resultSet.criteria
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
this.wallet.confirmWins(this);
|
|
2293
|
+
this.confirmRecords();
|
|
2294
|
+
parentPort?.postMessage({
|
|
2295
|
+
type: "complete",
|
|
2296
|
+
simId,
|
|
2297
|
+
book: this.state.book.serialize(),
|
|
2298
|
+
wallet: this.wallet.serialize(),
|
|
2299
|
+
records: this.getRecords()
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
/**
|
|
2303
|
+
* If a simulation does not meet the required criteria, reset the state to run it again.
|
|
2304
|
+
*
|
|
2305
|
+
* This also runs once before each simulation to ensure a clean state.
|
|
2306
|
+
*/
|
|
2307
|
+
resetSimulation() {
|
|
2308
|
+
this.resetState();
|
|
2309
|
+
this.resetBoard();
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Contains and executes the entire game logic:
|
|
2313
|
+
* - Drawing the board
|
|
2314
|
+
* - Evaluating wins
|
|
2315
|
+
* - Updating wallet
|
|
2316
|
+
* - Handling free spins
|
|
2317
|
+
* - Recording events
|
|
2318
|
+
*
|
|
2319
|
+
* You can customize the game flow by implementing the `onHandleGameFlow` hook in the game configuration.
|
|
2320
|
+
*/
|
|
2321
|
+
handleGameFlow() {
|
|
2322
|
+
this.config.hooks.onHandleGameFlow(this);
|
|
2323
|
+
}
|
|
2324
|
+
};
|
|
2325
|
+
|
|
2326
|
+
// src/analysis/index.ts
|
|
2327
|
+
import fs4 from "fs";
|
|
2328
|
+
import path4 from "path";
|
|
2329
|
+
import assert3 from "assert";
|
|
2330
|
+
|
|
2331
|
+
// src/analysis/utils.ts
|
|
2332
|
+
function parseLookupTable(content) {
|
|
2333
|
+
const lines = content.trim().split("\n");
|
|
2334
|
+
const lut = [];
|
|
2335
|
+
for (const line of lines) {
|
|
2336
|
+
const [indexStr, weightStr, payoutStr] = line.split(",");
|
|
2337
|
+
const index = parseInt(indexStr.trim());
|
|
2338
|
+
const weight = parseInt(weightStr.trim());
|
|
2339
|
+
const payout = parseFloat(payoutStr.trim());
|
|
2340
|
+
lut.push([index, weight, payout]);
|
|
2341
|
+
}
|
|
2342
|
+
return lut;
|
|
2343
|
+
}
|
|
2344
|
+
function getTotalLutWeight(lut) {
|
|
2345
|
+
return lut.reduce((sum, [, weight]) => sum + weight, 0);
|
|
2346
|
+
}
|
|
2347
|
+
function getTotalWeight(payoutWeights) {
|
|
2348
|
+
return Object.values(payoutWeights).reduce((sum, w) => sum + w, 0);
|
|
2349
|
+
}
|
|
2350
|
+
function getPayoutWeights(lut, opts = {}) {
|
|
2351
|
+
const { normalize = true } = opts;
|
|
2352
|
+
const totalWeight = getTotalLutWeight(lut);
|
|
2353
|
+
let payoutWeights = {};
|
|
2354
|
+
for (const [, weight, p] of lut) {
|
|
2355
|
+
const payout = p / 100;
|
|
2356
|
+
if (payoutWeights[payout] === void 0) {
|
|
2357
|
+
payoutWeights[payout] = 0;
|
|
2358
|
+
}
|
|
2359
|
+
payoutWeights[payout] += weight;
|
|
2360
|
+
}
|
|
2361
|
+
payoutWeights = Object.fromEntries(
|
|
2362
|
+
Object.entries(payoutWeights).sort(([a], [b]) => parseFloat(a) - parseFloat(b))
|
|
2363
|
+
);
|
|
2364
|
+
if (normalize) {
|
|
2365
|
+
for (const payout in payoutWeights) {
|
|
2366
|
+
payoutWeights[payout] /= totalWeight;
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
return payoutWeights;
|
|
2370
|
+
}
|
|
2371
|
+
function getNonZeroHitrate(payoutWeights) {
|
|
2372
|
+
const totalWeight = getTotalWeight(payoutWeights);
|
|
2373
|
+
const nonZeroWeight = totalWeight - (payoutWeights[0] ?? 0) * totalWeight;
|
|
2374
|
+
return nonZeroWeight / totalWeight;
|
|
2375
|
+
}
|
|
2376
|
+
function getNullHitrate(payoutWeights) {
|
|
2377
|
+
return payoutWeights[0] ?? 0;
|
|
2378
|
+
}
|
|
2379
|
+
function getMaxwinHitrate(payoutWeights) {
|
|
2380
|
+
const totalWeight = getTotalWeight(payoutWeights);
|
|
2381
|
+
const maxWin = Math.max(...Object.keys(payoutWeights).map(Number));
|
|
2382
|
+
const hitRate = (payoutWeights[maxWin] || 0) / totalWeight;
|
|
2383
|
+
return 1 / hitRate;
|
|
2384
|
+
}
|
|
2385
|
+
function getUniquePayouts(payoutWeights) {
|
|
2386
|
+
return Object.keys(payoutWeights).length;
|
|
2387
|
+
}
|
|
2388
|
+
function getMinWin(payoutWeights) {
|
|
2389
|
+
const payouts = Object.keys(payoutWeights).map(Number);
|
|
2390
|
+
return Math.min(...payouts);
|
|
2391
|
+
}
|
|
2392
|
+
function getMaxWin(payoutWeights) {
|
|
2393
|
+
const payouts = Object.keys(payoutWeights).map(Number);
|
|
2394
|
+
return Math.max(...payouts);
|
|
2395
|
+
}
|
|
2396
|
+
function getAvgWin(payoutWeights) {
|
|
2397
|
+
let avgWin = 0;
|
|
2398
|
+
for (const [payoutStr, weight] of Object.entries(payoutWeights)) {
|
|
2399
|
+
const payout = parseFloat(payoutStr);
|
|
2400
|
+
avgWin += payout * weight;
|
|
2401
|
+
}
|
|
2402
|
+
return avgWin;
|
|
2403
|
+
}
|
|
2404
|
+
function getRtp(payoutWeights, cost) {
|
|
2405
|
+
const avgWin = getAvgWin(payoutWeights);
|
|
2406
|
+
return avgWin / cost;
|
|
2407
|
+
}
|
|
2408
|
+
function getStandardDeviation(payoutWeights) {
|
|
2409
|
+
const variance = getVariance(payoutWeights);
|
|
2410
|
+
return Math.sqrt(variance);
|
|
2411
|
+
}
|
|
2412
|
+
function getVariance(payoutWeights) {
|
|
2413
|
+
const totalWeight = getTotalWeight(payoutWeights);
|
|
2414
|
+
const avgWin = getAvgWin(payoutWeights);
|
|
2415
|
+
let variance = 0;
|
|
2416
|
+
for (const [payoutStr, weight] of Object.entries(payoutWeights)) {
|
|
2417
|
+
const payout = parseFloat(payoutStr);
|
|
2418
|
+
variance += Math.pow(payout - avgWin, 2) * (weight / totalWeight);
|
|
2419
|
+
}
|
|
2420
|
+
return variance;
|
|
2421
|
+
}
|
|
2422
|
+
function getLessBetHitrate(payoutWeights, cost) {
|
|
2423
|
+
let lessBetWeight = 0;
|
|
2424
|
+
const totalWeight = getTotalWeight(payoutWeights);
|
|
2425
|
+
for (const [payoutStr, weight] of Object.entries(payoutWeights)) {
|
|
2426
|
+
const payout = parseFloat(payoutStr);
|
|
2427
|
+
if (payout < cost) {
|
|
2428
|
+
lessBetWeight += weight;
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
return lessBetWeight / totalWeight;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// src/analysis/index.ts
|
|
2435
|
+
var Analysis = class {
|
|
2436
|
+
gameConfig;
|
|
2437
|
+
optimizerConfig;
|
|
2438
|
+
filePaths;
|
|
2439
|
+
constructor(optimizer) {
|
|
2440
|
+
this.gameConfig = optimizer.getGameConfig();
|
|
2441
|
+
this.optimizerConfig = optimizer.getOptimizerGameModes();
|
|
2442
|
+
this.filePaths = {};
|
|
2443
|
+
}
|
|
2444
|
+
async runAnalysis(gameModes) {
|
|
2445
|
+
this.filePaths = this.getPathsForModes(gameModes);
|
|
2446
|
+
this.getNumberStats(gameModes);
|
|
2447
|
+
this.getWinRanges(gameModes);
|
|
2448
|
+
console.log("Analysis complete. Files written to build directory.");
|
|
2449
|
+
}
|
|
2450
|
+
getPathsForModes(gameModes) {
|
|
2451
|
+
const rootPath = process.cwd();
|
|
2452
|
+
const paths = {};
|
|
2453
|
+
for (const modeStr of gameModes) {
|
|
2454
|
+
const lut = path4.join(
|
|
2455
|
+
rootPath,
|
|
2456
|
+
this.gameConfig.outputDir,
|
|
2457
|
+
`lookUpTable_${modeStr}.csv`
|
|
2458
|
+
);
|
|
2459
|
+
const lutSegmented = path4.join(
|
|
2460
|
+
rootPath,
|
|
2461
|
+
this.gameConfig.outputDir,
|
|
2462
|
+
`lookUpTableSegmented_${modeStr}.csv`
|
|
2463
|
+
);
|
|
2464
|
+
const lutOptimized = path4.join(
|
|
2465
|
+
rootPath,
|
|
2466
|
+
this.gameConfig.outputDir,
|
|
2467
|
+
"publish_files",
|
|
2468
|
+
`lookUpTable_${modeStr}_0.csv`
|
|
2469
|
+
);
|
|
2470
|
+
const booksJsonl = path4.join(
|
|
2471
|
+
rootPath,
|
|
2472
|
+
this.gameConfig.outputDir,
|
|
2473
|
+
`books_${modeStr}.jsonl`
|
|
2474
|
+
);
|
|
2475
|
+
const booksJsonlCompressed = path4.join(
|
|
2476
|
+
rootPath,
|
|
2477
|
+
this.gameConfig.outputDir,
|
|
2478
|
+
"publish_files",
|
|
2479
|
+
`books_${modeStr}.jsonl.zst`
|
|
2480
|
+
);
|
|
2481
|
+
paths[modeStr] = {
|
|
2482
|
+
lut,
|
|
2483
|
+
lutSegmented,
|
|
2484
|
+
lutOptimized,
|
|
2485
|
+
booksJsonl,
|
|
2486
|
+
booksJsonlCompressed
|
|
2487
|
+
};
|
|
2488
|
+
for (const p of Object.values(paths[modeStr])) {
|
|
2489
|
+
assert3(
|
|
2490
|
+
fs4.existsSync(p),
|
|
2491
|
+
`File "${p}" does not exist. Run optimization to auto-create it.`
|
|
2492
|
+
);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
return paths;
|
|
2496
|
+
}
|
|
2497
|
+
getNumberStats(gameModes) {
|
|
2498
|
+
const stats = [];
|
|
2499
|
+
for (const modeStr of gameModes) {
|
|
2500
|
+
const mode = this.getGameModeConfig(modeStr);
|
|
2501
|
+
const lutOptimized = parseLookupTable(
|
|
2502
|
+
fs4.readFileSync(this.filePaths[modeStr].lutOptimized, "utf-8")
|
|
2503
|
+
);
|
|
2504
|
+
const totalWeight = getTotalLutWeight(lutOptimized);
|
|
2505
|
+
const payoutWeights = getPayoutWeights(lutOptimized);
|
|
2506
|
+
stats.push({
|
|
2507
|
+
gameMode: mode.name,
|
|
2508
|
+
totalWeight,
|
|
2509
|
+
avgWin: getAvgWin(payoutWeights),
|
|
2510
|
+
rtp: getRtp(payoutWeights, mode.cost),
|
|
2511
|
+
minWin: getMinWin(payoutWeights),
|
|
2512
|
+
maxWin: getMaxWin(payoutWeights),
|
|
2513
|
+
stdDev: getStandardDeviation(payoutWeights),
|
|
2514
|
+
variance: getVariance(payoutWeights),
|
|
2515
|
+
nonZeroHitRate: getNonZeroHitrate(payoutWeights),
|
|
2516
|
+
nullHitRate: getNullHitrate(payoutWeights),
|
|
2517
|
+
maxwinHitRate: getMaxwinHitrate(payoutWeights),
|
|
2518
|
+
lessBetHitRate: getLessBetHitrate(payoutWeights, mode.cost),
|
|
2519
|
+
uniquePayouts: getUniquePayouts(payoutWeights)
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
writeJsonFile(
|
|
2523
|
+
path4.join(process.cwd(), this.gameConfig.outputDir, "stats_summary.json"),
|
|
2524
|
+
stats
|
|
2525
|
+
);
|
|
2526
|
+
}
|
|
2527
|
+
getWinRanges(gameModes) {
|
|
2528
|
+
const winRanges = [
|
|
2529
|
+
[0, 0.1],
|
|
2530
|
+
[0, 0.99],
|
|
2531
|
+
[1, 1.99],
|
|
2532
|
+
[2, 2.99],
|
|
2533
|
+
[3, 4.99],
|
|
2534
|
+
[5, 9.99],
|
|
2535
|
+
[10, 19.99],
|
|
2536
|
+
[20, 49.99],
|
|
2537
|
+
[50, 99.99],
|
|
2538
|
+
[100, 199.99],
|
|
2539
|
+
[200, 499.99],
|
|
2540
|
+
[500, 999.99],
|
|
2541
|
+
[1e3, 1999.99],
|
|
2542
|
+
[2e3, 2999.99],
|
|
2543
|
+
[3e3, 4999.99],
|
|
2544
|
+
[5e3, 7499.99],
|
|
2545
|
+
[7500, 9999.99],
|
|
2546
|
+
[1e4, 14999.99],
|
|
2547
|
+
[15e3, 19999.99],
|
|
2548
|
+
[2e4, 24999.99]
|
|
2549
|
+
];
|
|
2550
|
+
for (const modeStr of gameModes) {
|
|
2551
|
+
const mode = this.getGameModeConfig(modeStr);
|
|
2552
|
+
const lutOptimized = parseLookupTable(
|
|
2553
|
+
fs4.readFileSync(this.filePaths[modeStr].lutOptimized, "utf-8")
|
|
2554
|
+
);
|
|
2555
|
+
const totalWeight = getTotalLutWeight(lutOptimized);
|
|
2556
|
+
const payoutWeights = getPayoutWeights(lutOptimized);
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
getGameModeConfig(mode) {
|
|
2560
|
+
const config = this.gameConfig.gameModes[mode];
|
|
2561
|
+
assert3(config, `Game mode "${mode}" not found in game config`);
|
|
2562
|
+
return config;
|
|
2563
|
+
}
|
|
2564
|
+
};
|
|
2565
|
+
|
|
2566
|
+
// src/utils/math-config.ts
|
|
2567
|
+
import path5 from "path";
|
|
2568
|
+
function makeMathConfig(optimizer, opts = {}) {
|
|
2569
|
+
const game = optimizer.getGameConfig();
|
|
2570
|
+
const gameModesCfg = optimizer.getOptimizerGameModes();
|
|
2571
|
+
const { writeToFile } = opts;
|
|
2572
|
+
const isDefined = (v) => v !== void 0;
|
|
2573
|
+
const config = {
|
|
2574
|
+
game_id: game.id,
|
|
2575
|
+
bet_modes: Object.entries(game.gameModes).map(([key, mode]) => ({
|
|
2576
|
+
bet_mode: mode.name,
|
|
2577
|
+
cost: mode.cost,
|
|
2578
|
+
rtp: mode.rtp,
|
|
2579
|
+
max_win: game.maxWinX
|
|
2580
|
+
})),
|
|
2581
|
+
fences: Object.entries(gameModesCfg).map(([gameModeName, modeCfg]) => ({
|
|
2582
|
+
bet_mode: gameModeName,
|
|
2583
|
+
fences: Object.entries(modeCfg.conditions).map(([fenceName, fence]) => ({
|
|
2584
|
+
name: fenceName,
|
|
2585
|
+
avg_win: isDefined(fence.getAvgWin()) ? fence.getAvgWin().toString() : void 0,
|
|
2586
|
+
hr: isDefined(fence.getHitRate()) ? fence.getHitRate().toString() : void 0,
|
|
2587
|
+
rtp: isDefined(fence.getRtp()) ? fence.getRtp().toString() : void 0,
|
|
2588
|
+
identity_condition: {
|
|
2589
|
+
search: Object.entries(fence.getForceSearch()).map(([k, v]) => ({
|
|
2590
|
+
name: k,
|
|
2591
|
+
value: v
|
|
2592
|
+
})),
|
|
2593
|
+
win_range_start: fence.getSearchRange()[0],
|
|
2594
|
+
win_range_end: fence.getSearchRange()[1],
|
|
2595
|
+
opposite: false
|
|
2596
|
+
},
|
|
2597
|
+
priority: fence.priority
|
|
2598
|
+
})).sort((a, b) => b.priority - a.priority)
|
|
2599
|
+
})),
|
|
2600
|
+
dresses: Object.entries(gameModesCfg).flatMap(([gameModeName, modeCfg]) => ({
|
|
2601
|
+
bet_mode: gameModeName,
|
|
2602
|
+
dresses: modeCfg.scaling.getConfig().map((s) => ({
|
|
2603
|
+
fence: s.criteria,
|
|
2604
|
+
scale_factor: s.scaleFactor.toString(),
|
|
2605
|
+
identity_condition_win_range: s.winRange,
|
|
2606
|
+
prob: s.probability
|
|
2607
|
+
}))
|
|
2608
|
+
}))
|
|
2609
|
+
};
|
|
2610
|
+
if (writeToFile) {
|
|
2611
|
+
const outPath = path5.join(process.cwd(), game.outputDir, "math_config.json");
|
|
2612
|
+
writeJsonFile(outPath, config);
|
|
2613
|
+
}
|
|
2614
|
+
return config;
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// src/utils/setup-file.ts
|
|
2618
|
+
import path6 from "path";
|
|
2619
|
+
function makeSetupFile(optimizer, gameMode) {
|
|
2620
|
+
const gameConfig = optimizer.getGameConfig();
|
|
2621
|
+
const optimizerGameModes = optimizer.getOptimizerGameModes();
|
|
2622
|
+
const modeConfig = optimizerGameModes[gameMode];
|
|
2623
|
+
if (!modeConfig) {
|
|
2624
|
+
throw new Error(`Game mode "${gameMode}" not found in optimizer configuration.`);
|
|
2625
|
+
}
|
|
2626
|
+
const params = modeConfig.parameters.getParameters();
|
|
2627
|
+
let content = "";
|
|
2628
|
+
content += `game_name;${gameConfig.id}
|
|
2629
|
+
`;
|
|
2630
|
+
content += `bet_type;${gameMode}
|
|
2631
|
+
`;
|
|
2632
|
+
content += `num_show_pigs;${params.numShowPigs}
|
|
2633
|
+
`;
|
|
2634
|
+
content += `num_pigs_per_fence;${params.numPigsPerFence}
|
|
2635
|
+
`;
|
|
2636
|
+
content += `threads_for_fence_construction;${params.threadsFenceConstruction}
|
|
2637
|
+
`;
|
|
2638
|
+
content += `threads_for_show_construction;${params.threadsShowConstruction}
|
|
2639
|
+
`;
|
|
2640
|
+
content += `score_type;${params.scoreType}
|
|
2641
|
+
`;
|
|
2642
|
+
content += `test_spins;${JSON.stringify(params.testSpins)}
|
|
2643
|
+
`;
|
|
2644
|
+
content += `test_spins_weights;${JSON.stringify(params.testSpinsWeights)}
|
|
2645
|
+
`;
|
|
2646
|
+
content += `simulation_trials;${params.simulationTrials}
|
|
2647
|
+
`;
|
|
2648
|
+
content += `graph_indexes;0
|
|
2649
|
+
`;
|
|
2650
|
+
content += `run_1000_batch;False
|
|
2651
|
+
`;
|
|
2652
|
+
content += `simulation_trials;${params.simulationTrials}
|
|
2653
|
+
`;
|
|
2654
|
+
content += `user_game_build_path;${path6.join(process.cwd(), gameConfig.outputDir)}
|
|
2655
|
+
`;
|
|
2656
|
+
content += `pmb_rtp;${params.pmbRtp}
|
|
2657
|
+
`;
|
|
2658
|
+
const outPath = path6.join(__dirname, "./optimizer-rust/src", "setup.txt");
|
|
2659
|
+
writeFile(outPath, content);
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// src/optimizer/index.ts
|
|
2663
|
+
import { spawn as spawn2 } from "child_process";
|
|
2664
|
+
import path7 from "path";
|
|
2665
|
+
import assert4 from "assert";
|
|
2666
|
+
import { isMainThread as isMainThread3 } from "worker_threads";
|
|
2667
|
+
var Optimizer = class {
|
|
2668
|
+
gameConfig;
|
|
2669
|
+
gameModes;
|
|
2670
|
+
constructor(opts) {
|
|
2671
|
+
this.gameConfig = opts.game.getConfig().config;
|
|
2672
|
+
this.gameModes = opts.gameModes;
|
|
2673
|
+
this.verifyConfig();
|
|
2674
|
+
}
|
|
2675
|
+
/**
|
|
2676
|
+
* Runs the optimization process, and runs analysis after.
|
|
2677
|
+
*/
|
|
2678
|
+
async runOptimization({ gameModes }) {
|
|
2679
|
+
if (!isMainThread3) return;
|
|
2680
|
+
const mathConfig = makeMathConfig(this, { writeToFile: true });
|
|
2681
|
+
for (const mode of gameModes) {
|
|
2682
|
+
const setupFile = makeSetupFile(this, mode);
|
|
2683
|
+
await this.runSingleOptimization();
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
async runSingleOptimization() {
|
|
2687
|
+
return await rustProgram();
|
|
2688
|
+
}
|
|
2689
|
+
verifyConfig() {
|
|
2690
|
+
for (const [k, mode] of Object.entries(this.gameModes)) {
|
|
2691
|
+
const configMode = this.gameConfig.gameModes[k];
|
|
2692
|
+
if (!configMode) {
|
|
2693
|
+
throw new Error(
|
|
2694
|
+
`Game mode "${mode}" defined in optimizer config does not exist in the game config.`
|
|
2695
|
+
);
|
|
2696
|
+
}
|
|
2697
|
+
const conditions = Object.keys(mode.conditions);
|
|
2698
|
+
const scalings = Object.keys(mode.scaling);
|
|
2699
|
+
const parameters = Object.keys(mode.parameters);
|
|
2700
|
+
for (const condition of conditions) {
|
|
2701
|
+
if (!configMode.resultSets.find((r) => r.criteria === condition)) {
|
|
2702
|
+
throw new Error(
|
|
2703
|
+
`Condition "${condition}" defined in optimizer config for game mode "${k}" does not exist as criteria in any ResultSet of the same game mode.`
|
|
2704
|
+
);
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
const criteria = configMode.resultSets.map((r) => r.criteria);
|
|
2708
|
+
assert4(
|
|
2709
|
+
conditions.every((c) => criteria.includes(c)),
|
|
2710
|
+
`Not all ResultSet criteria in game mode "${k}" are defined as optimization conditions.`
|
|
2711
|
+
);
|
|
2712
|
+
let gameModeRtp = configMode.rtp;
|
|
2713
|
+
let paramRtp = 0;
|
|
2714
|
+
for (const cond of conditions) {
|
|
2715
|
+
const paramConfig = mode.conditions[cond];
|
|
2716
|
+
paramRtp += Number(paramConfig.getRtp());
|
|
2717
|
+
}
|
|
2718
|
+
gameModeRtp = Math.round(gameModeRtp * 1e3) / 1e3;
|
|
2719
|
+
paramRtp = Math.round(paramRtp * 1e3) / 1e3;
|
|
2720
|
+
assert4(
|
|
2721
|
+
gameModeRtp === paramRtp,
|
|
2722
|
+
`Sum of all RTP conditions (${paramRtp}) does not match the game mode RTP (${gameModeRtp}) in game mode "${k}".`
|
|
2723
|
+
);
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
getGameConfig() {
|
|
2727
|
+
return this.gameConfig;
|
|
2728
|
+
}
|
|
2729
|
+
getOptimizerGameModes() {
|
|
2730
|
+
return this.gameModes;
|
|
2731
|
+
}
|
|
2732
|
+
};
|
|
2733
|
+
async function rustProgram(...args) {
|
|
2734
|
+
return new Promise((resolve, reject) => {
|
|
2735
|
+
const task = spawn2("cargo", ["run", "--release", ...args], {
|
|
2736
|
+
shell: true,
|
|
2737
|
+
cwd: path7.join(__dirname, "./optimizer-rust"),
|
|
2738
|
+
stdio: "pipe"
|
|
2739
|
+
});
|
|
2740
|
+
task.on("error", (error) => {
|
|
2741
|
+
console.error("Error:", error);
|
|
2742
|
+
reject(error);
|
|
2743
|
+
});
|
|
2744
|
+
task.on("exit", () => {
|
|
2745
|
+
resolve(true);
|
|
2746
|
+
});
|
|
2747
|
+
task.on("close", () => {
|
|
2748
|
+
resolve(true);
|
|
2749
|
+
});
|
|
2750
|
+
task.stdout.on("data", (data) => {
|
|
2751
|
+
console.log(data.toString());
|
|
2752
|
+
});
|
|
2753
|
+
task.stderr.on("data", (data) => {
|
|
2754
|
+
console.log(data.toString());
|
|
2755
|
+
});
|
|
2756
|
+
task.stdout.on("error", (data) => {
|
|
2757
|
+
console.log(data.toString());
|
|
2758
|
+
reject(data.toString());
|
|
2759
|
+
});
|
|
2760
|
+
});
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
// src/SlotGame.ts
|
|
2764
|
+
var SlotGame = class {
|
|
2765
|
+
gameConfigOpts;
|
|
2766
|
+
simulation;
|
|
2767
|
+
optimizer;
|
|
2768
|
+
analyzer;
|
|
2769
|
+
constructor(config) {
|
|
2770
|
+
this.gameConfigOpts = config;
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* Sets up the simulation configuration.\
|
|
2774
|
+
* Must be called before `runTasks()`.
|
|
2775
|
+
*/
|
|
2776
|
+
configureSimulation(opts) {
|
|
2777
|
+
this.simulation = new Simulation(opts, this.gameConfigOpts);
|
|
2778
|
+
}
|
|
2779
|
+
/**
|
|
2780
|
+
* Sets up the optimization configuration.\
|
|
2781
|
+
* Must be called before `runTasks()`.
|
|
2782
|
+
*/
|
|
2783
|
+
configureOptimization(opts) {
|
|
2784
|
+
this.optimizer = new Optimizer({
|
|
2785
|
+
game: this,
|
|
2786
|
+
gameModes: opts.gameModes
|
|
2787
|
+
});
|
|
2788
|
+
}
|
|
2789
|
+
/**
|
|
2790
|
+
* Runs the simulation based on the configured settings.
|
|
2791
|
+
*/
|
|
2792
|
+
async runSimulation(opts = {}) {
|
|
2793
|
+
if (!this.simulation) {
|
|
2794
|
+
throw new Error(
|
|
2795
|
+
"Simulation is not configured. Do so by calling configureSimulation() first."
|
|
2796
|
+
);
|
|
2797
|
+
}
|
|
2798
|
+
await this.simulation.runSimulation(opts);
|
|
2799
|
+
}
|
|
2800
|
+
/**
|
|
2801
|
+
* Runs the optimization based on the configured settings.
|
|
2802
|
+
*/
|
|
2803
|
+
async runOptimization(opts) {
|
|
2804
|
+
if (!this.optimizer) {
|
|
2805
|
+
throw new Error(
|
|
2806
|
+
"Optimization is not configured. Do so by calling configureOptimization() first."
|
|
2807
|
+
);
|
|
2808
|
+
}
|
|
2809
|
+
await this.optimizer.runOptimization(opts);
|
|
2810
|
+
}
|
|
2811
|
+
/**
|
|
2812
|
+
* Runs the analysis based on the configured settings.
|
|
2813
|
+
*/
|
|
2814
|
+
async runAnalysis(opts) {
|
|
2815
|
+
if (!this.optimizer) {
|
|
2816
|
+
throw new Error(
|
|
2817
|
+
"Optimization must be configured to run analysis. Do so by calling configureOptimization() first."
|
|
2818
|
+
);
|
|
2819
|
+
}
|
|
2820
|
+
this.analyzer = new Analysis(this.optimizer);
|
|
2821
|
+
await this.analyzer.runAnalysis(opts.gameModes);
|
|
2822
|
+
}
|
|
2823
|
+
async runTasks(opts = {}) {
|
|
2824
|
+
if (!opts.doSimulation && !opts.doOptimization && !opts.doAnalysis) {
|
|
2825
|
+
console.log("No tasks to run. Enable either simulation, optimization or analysis.");
|
|
2826
|
+
}
|
|
2827
|
+
if (opts.doSimulation) {
|
|
2828
|
+
await this.runSimulation({ debug: opts.debug });
|
|
2829
|
+
}
|
|
2830
|
+
if (opts.doOptimization) {
|
|
2831
|
+
await this.runOptimization(opts.optimizationOpts || { gameModes: [] });
|
|
2832
|
+
}
|
|
2833
|
+
if (opts.doAnalysis) {
|
|
2834
|
+
await this.runAnalysis(opts.analysisOpts || { gameModes: [] });
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2838
|
+
* Gets the game configuration.
|
|
2839
|
+
*/
|
|
2840
|
+
getConfig() {
|
|
2841
|
+
return new GameConfig(this.gameConfigOpts);
|
|
2842
|
+
}
|
|
2843
|
+
};
|
|
2844
|
+
|
|
2845
|
+
// index.ts
|
|
2846
|
+
function createSlotGame(opts) {
|
|
2847
|
+
return new SlotGame(opts);
|
|
2848
|
+
}
|
|
2849
|
+
var defineUserState = (data) => data;
|
|
2850
|
+
var defineSymbols = (symbols) => symbols;
|
|
2851
|
+
var defineGameModes = (gameModes) => gameModes;
|
|
2852
|
+
var defineReelSets = (reelSets) => reelSets;
|
|
2853
|
+
export {
|
|
2854
|
+
ClusterWinType,
|
|
2855
|
+
GameConfig,
|
|
2856
|
+
GameMode,
|
|
2857
|
+
GameSymbol,
|
|
2858
|
+
LinesWinType,
|
|
2859
|
+
ManywaysWinType,
|
|
2860
|
+
OptimizationConditions,
|
|
2861
|
+
OptimizationParameters,
|
|
2862
|
+
OptimizationScaling,
|
|
2863
|
+
ReelGenerator,
|
|
2864
|
+
ResultSet,
|
|
2865
|
+
StandaloneBoard,
|
|
2866
|
+
WinType,
|
|
2867
|
+
createSlotGame,
|
|
2868
|
+
defineGameModes,
|
|
2869
|
+
defineReelSets,
|
|
2870
|
+
defineSymbols,
|
|
2871
|
+
defineUserState,
|
|
2872
|
+
weightedRandom
|
|
2873
|
+
};
|
|
2874
|
+
//# sourceMappingURL=index.mjs.map
|