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