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