slot-server 1.0.0
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/README.md +949 -0
- package/dist/index.cjs +2944 -0
- package/dist/index.d.cts +2160 -0
- package/dist/index.d.ts +2160 -0
- package/dist/index.js +2897 -0
- package/package.json +34 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2897 @@
|
|
|
1
|
+
// src/ConfigBuilder.ts
|
|
2
|
+
var ConfigBuilder = class {
|
|
3
|
+
config = {};
|
|
4
|
+
setGameId(id) {
|
|
5
|
+
this.config.gameId = id;
|
|
6
|
+
return this;
|
|
7
|
+
}
|
|
8
|
+
setReelCount(count) {
|
|
9
|
+
this.config.reelCount = count;
|
|
10
|
+
return this;
|
|
11
|
+
}
|
|
12
|
+
setRowCount(count) {
|
|
13
|
+
this.config.rowCount = count;
|
|
14
|
+
return this;
|
|
15
|
+
}
|
|
16
|
+
setSymbolMap(map) {
|
|
17
|
+
this.config.symbolMap = map;
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
setPaytableData(data) {
|
|
21
|
+
this.config.paytableData = data;
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
setReelStrips(strips) {
|
|
25
|
+
this.config.reelStrips = strips;
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
setLinePatterns(patterns) {
|
|
29
|
+
this.config.linePatterns = patterns;
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
setCurrency(code, symbol, decimalSeparator = ".", thousandSeparator = ",", minDecimalPlaces = 2) {
|
|
33
|
+
this.config.currency = { code, symbol, decimalSeparator, thousandSeparator, minDecimalPlaces };
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
setFeatures(features) {
|
|
37
|
+
this.config.features = features;
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
setBetRange(min, max, step, defaultBet) {
|
|
41
|
+
this.config.minBet = min;
|
|
42
|
+
this.config.maxBet = max;
|
|
43
|
+
this.config.betStep = step;
|
|
44
|
+
this.config.defaultBet = defaultBet ?? min;
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
build() {
|
|
48
|
+
const required = [
|
|
49
|
+
"gameId",
|
|
50
|
+
"reelCount",
|
|
51
|
+
"rowCount",
|
|
52
|
+
"symbolMap",
|
|
53
|
+
"paytableData",
|
|
54
|
+
"reelStrips",
|
|
55
|
+
"linePatterns"
|
|
56
|
+
];
|
|
57
|
+
for (const key of required) {
|
|
58
|
+
if (this.config[key] === void 0) {
|
|
59
|
+
throw new Error(`ConfigBuilder: Missing required field "${key}"`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
...this.config,
|
|
64
|
+
gameId: this.config.gameId,
|
|
65
|
+
// Assert that gameId is definitely present after validation
|
|
66
|
+
reelCount: this.config.reelCount,
|
|
67
|
+
rowCount: this.config.rowCount,
|
|
68
|
+
symbolMap: this.config.symbolMap,
|
|
69
|
+
paytableData: this.config.paytableData,
|
|
70
|
+
reelStrips: this.config.reelStrips,
|
|
71
|
+
linePatterns: this.config.linePatterns,
|
|
72
|
+
currency: this.config.currency || { code: "USD", symbol: "$", decimalSeparator: ".", thousandSeparator: ",", minDecimalPlaces: 2 },
|
|
73
|
+
features: this.config.features || { autoplay: true, fastSpin: true, realityCheck: true, fullscreen: true },
|
|
74
|
+
defaultBet: this.config.defaultBet ?? 1,
|
|
75
|
+
minBet: this.config.minBet ?? 0.1,
|
|
76
|
+
maxBet: this.config.maxBet ?? 100,
|
|
77
|
+
betStep: this.config.betStep ?? 0.1,
|
|
78
|
+
defaultLines: this.config.defaultLines ?? this.config.linePatterns.length,
|
|
79
|
+
rtp: this.config.rtp ?? 96.5,
|
|
80
|
+
volatility: this.config.volatility ?? "medium"
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/Random.ts
|
|
86
|
+
var Random = class {
|
|
87
|
+
seed;
|
|
88
|
+
constructor(seed) {
|
|
89
|
+
this.seed = seed;
|
|
90
|
+
}
|
|
91
|
+
next() {
|
|
92
|
+
if (this.seed !== void 0) {
|
|
93
|
+
this.seed = (this.seed * 9301 + 49297) % 233280;
|
|
94
|
+
return this.seed / 233280;
|
|
95
|
+
}
|
|
96
|
+
return Math.random();
|
|
97
|
+
}
|
|
98
|
+
nextInt(min, max) {
|
|
99
|
+
return Math.floor(this.next() * (max - min)) + min;
|
|
100
|
+
}
|
|
101
|
+
pick(arr) {
|
|
102
|
+
return arr[this.nextInt(0, arr.length)];
|
|
103
|
+
}
|
|
104
|
+
shuffle(arr) {
|
|
105
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
106
|
+
const j = this.nextInt(0, i + 1);
|
|
107
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
108
|
+
}
|
|
109
|
+
return arr;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// src/SpinEvaluator.ts
|
|
114
|
+
var SpinEvaluator = class {
|
|
115
|
+
config;
|
|
116
|
+
rng;
|
|
117
|
+
constructor(config, rng) {
|
|
118
|
+
this.config = config;
|
|
119
|
+
this.rng = rng;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Generate a random spin result (visible symbols for each reel).
|
|
123
|
+
* Returns a 2D array: [reelIndex][rowIndex]
|
|
124
|
+
*/
|
|
125
|
+
generateResult() {
|
|
126
|
+
const { reelCount, rowCount, reelStrips } = this.config;
|
|
127
|
+
const result = [];
|
|
128
|
+
for (let r = 0; r < reelCount; r++) {
|
|
129
|
+
const strip = reelStrips[r];
|
|
130
|
+
const offset = this.rng.nextInt(0, strip.length - rowCount);
|
|
131
|
+
const row = [];
|
|
132
|
+
for (let i = 0; i < rowCount; i++) {
|
|
133
|
+
row.push(strip[offset + i]);
|
|
134
|
+
}
|
|
135
|
+
result.push(row);
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Evaluate the result against all paylines and the paytable.
|
|
141
|
+
* Returns win lines and total win amount.
|
|
142
|
+
*/
|
|
143
|
+
// FIXED: Added stakePerLine as a parameter since it belongs to the request payload, not the class instance
|
|
144
|
+
evaluateResult(result, stakePerLine) {
|
|
145
|
+
const { linePatterns, paytableData } = this.config;
|
|
146
|
+
const winLines = [];
|
|
147
|
+
let totalWin = 0;
|
|
148
|
+
for (let li = 0; li < linePatterns.length; li++) {
|
|
149
|
+
const pattern = linePatterns[li];
|
|
150
|
+
const symbols = pattern.map((pos) => result[pos.reel]?.[pos.row]);
|
|
151
|
+
if (symbols.some((s) => s === void 0)) continue;
|
|
152
|
+
const firstSymbol = symbols[0];
|
|
153
|
+
let count = 1;
|
|
154
|
+
for (let i = 1; i < symbols.length; i++) {
|
|
155
|
+
if (symbols[i] === firstSymbol) {
|
|
156
|
+
count++;
|
|
157
|
+
} else {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const symbolPaytable = paytableData[firstSymbol];
|
|
162
|
+
if (!symbolPaytable) continue;
|
|
163
|
+
const multiplierKey = `x${count}`;
|
|
164
|
+
const multiplier = symbolPaytable[multiplierKey];
|
|
165
|
+
if (!multiplier || multiplier === 0) continue;
|
|
166
|
+
const winAmount = (stakePerLine || 1) * multiplier;
|
|
167
|
+
totalWin += winAmount;
|
|
168
|
+
winLines.push({
|
|
169
|
+
lineIndex: li,
|
|
170
|
+
symbolId: firstSymbol,
|
|
171
|
+
multiplier,
|
|
172
|
+
winAmount,
|
|
173
|
+
positions: pattern.slice(0, count)
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return { winLines, totalWin };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Full spin processing: generate result, evaluate, build response.
|
|
180
|
+
*/
|
|
181
|
+
// FIXED: Swapped the messy inline type for the clean, updated ISpinRequestData interface
|
|
182
|
+
spin(request) {
|
|
183
|
+
const result = this.generateResult();
|
|
184
|
+
const stakePerLine = request.stakePerLine;
|
|
185
|
+
const { winLines, totalWin } = this.evaluateResult(result, stakePerLine);
|
|
186
|
+
return {
|
|
187
|
+
result,
|
|
188
|
+
totalWin,
|
|
189
|
+
stakePerLine: request.stakePerLine,
|
|
190
|
+
totalStake: request.totalStake,
|
|
191
|
+
gameMode: request.gameMode,
|
|
192
|
+
winLines: winLines.length > 0 ? winLines : void 0
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// src/BonusManager.ts
|
|
198
|
+
var BonusManager = class {
|
|
199
|
+
rng;
|
|
200
|
+
bonusState = null;
|
|
201
|
+
constructor(rng) {
|
|
202
|
+
this.rng = rng;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Check if a spin result should trigger a bonus.
|
|
206
|
+
* This is a simple example: trigger on 3+ scatter symbols (e.g., symbol ID 9).
|
|
207
|
+
*/
|
|
208
|
+
checkTrigger(result) {
|
|
209
|
+
const scatterId = 9;
|
|
210
|
+
let count = 0;
|
|
211
|
+
for (const row of result) {
|
|
212
|
+
for (const symbol of row) {
|
|
213
|
+
if (symbol === scatterId) count++;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (count >= 3) {
|
|
217
|
+
return {
|
|
218
|
+
triggered: true,
|
|
219
|
+
data: { scatterCount: count, freeSpins: count * 2 }
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return { triggered: false };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Start a free spin bonus.
|
|
226
|
+
*/
|
|
227
|
+
startFreeSpins(spins, multiplier = 1, bet) {
|
|
228
|
+
this.bonusState = {
|
|
229
|
+
type: "freespin",
|
|
230
|
+
spinsRemaining: spins,
|
|
231
|
+
multiplier,
|
|
232
|
+
totalWin: 0,
|
|
233
|
+
bet
|
|
234
|
+
};
|
|
235
|
+
return this.bonusState;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Start a pick bonus (e.g., pick items for prizes).
|
|
239
|
+
*/
|
|
240
|
+
startPickBonus(items) {
|
|
241
|
+
this.bonusState = {
|
|
242
|
+
type: "pick",
|
|
243
|
+
items,
|
|
244
|
+
pickedIds: [],
|
|
245
|
+
totalWin: 0
|
|
246
|
+
};
|
|
247
|
+
return this.bonusState;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Process a pick action.
|
|
251
|
+
*/
|
|
252
|
+
pickItem(itemId) {
|
|
253
|
+
const state = this.bonusState;
|
|
254
|
+
if (!state || state.type !== "pick") {
|
|
255
|
+
throw new Error("No active pick bonus");
|
|
256
|
+
}
|
|
257
|
+
const item = state.items.find((i) => i.id === itemId);
|
|
258
|
+
if (!item) throw new Error("Invalid item");
|
|
259
|
+
if (state.pickedIds.includes(itemId)) throw new Error("Item already picked");
|
|
260
|
+
state.pickedIds.push(itemId);
|
|
261
|
+
state.totalWin += item.value;
|
|
262
|
+
const remaining = state.items.filter((i) => !state.pickedIds.includes(i.id));
|
|
263
|
+
return { win: item.value, remaining };
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Process a free spin: deduct one spin, return the spin result.
|
|
267
|
+
* The spin result is generated by the SpinEvaluator, but we need to apply the multiplier.
|
|
268
|
+
*/
|
|
269
|
+
processFreeSpin(spinResult) {
|
|
270
|
+
const state = this.bonusState;
|
|
271
|
+
if (!state || state.type !== "freespin") {
|
|
272
|
+
throw new Error("No active free spin bonus");
|
|
273
|
+
}
|
|
274
|
+
if (state.spinsRemaining <= 0) {
|
|
275
|
+
throw new Error("No free spins remaining");
|
|
276
|
+
}
|
|
277
|
+
state.spinsRemaining--;
|
|
278
|
+
const multiplier = state.multiplier || 1;
|
|
279
|
+
const totalWin = (spinResult.totalWin || 0) * multiplier;
|
|
280
|
+
state.totalWin += totalWin;
|
|
281
|
+
return {
|
|
282
|
+
...spinResult,
|
|
283
|
+
totalWin,
|
|
284
|
+
spinsRemaining: state.spinsRemaining
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Check if a bonus is active.
|
|
289
|
+
*/
|
|
290
|
+
isActive() {
|
|
291
|
+
return this.bonusState !== null && (this.bonusState.type === "freespin" && this.bonusState.spinsRemaining > 0 || this.bonusState.type === "pick" && this.bonusState.items.length > this.bonusState.pickedIds.length);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Get the current bonus state.
|
|
295
|
+
*/
|
|
296
|
+
getState() {
|
|
297
|
+
return this.bonusState;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* End the current bonus.
|
|
301
|
+
*/
|
|
302
|
+
endBonus() {
|
|
303
|
+
this.bonusState = null;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// src/HistoryManager.ts
|
|
308
|
+
var HistoryManager = class {
|
|
309
|
+
history = [];
|
|
310
|
+
maxEntries;
|
|
311
|
+
constructor(maxEntries = 100) {
|
|
312
|
+
this.maxEntries = maxEntries;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Add a spin result to history.
|
|
316
|
+
*/
|
|
317
|
+
addEntry(result, bet, win, balance, gameMode) {
|
|
318
|
+
const entry = {
|
|
319
|
+
time: Date.now(),
|
|
320
|
+
bet,
|
|
321
|
+
win,
|
|
322
|
+
balance,
|
|
323
|
+
result: result.map((row) => [...row]),
|
|
324
|
+
// deep copy
|
|
325
|
+
gameMode
|
|
326
|
+
};
|
|
327
|
+
this.history.push(entry);
|
|
328
|
+
if (this.history.length > this.maxEntries) {
|
|
329
|
+
this.history.shift();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Get all history entries.
|
|
334
|
+
*/
|
|
335
|
+
getEntries() {
|
|
336
|
+
return this.history.slice();
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Get the last N entries.
|
|
340
|
+
*/
|
|
341
|
+
getLast(n) {
|
|
342
|
+
return this.history.slice(-n);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Clear history.
|
|
346
|
+
*/
|
|
347
|
+
clear() {
|
|
348
|
+
this.history = [];
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// src/GameSession.ts
|
|
353
|
+
var GameSession = class {
|
|
354
|
+
config;
|
|
355
|
+
playerId;
|
|
356
|
+
balanceProvider;
|
|
357
|
+
state;
|
|
358
|
+
spinEvaluator;
|
|
359
|
+
bonusManager;
|
|
360
|
+
historyManager;
|
|
361
|
+
rng;
|
|
362
|
+
constructor(config, playerId, balanceProvider, seed) {
|
|
363
|
+
this.config = config;
|
|
364
|
+
this.playerId = playerId;
|
|
365
|
+
this.balanceProvider = balanceProvider;
|
|
366
|
+
this.rng = new Random(seed);
|
|
367
|
+
this.spinEvaluator = new SpinEvaluator(config, this.rng);
|
|
368
|
+
this.bonusManager = new BonusManager(this.rng);
|
|
369
|
+
this.historyManager = new HistoryManager(100);
|
|
370
|
+
const initialBalance = balanceProvider.getBalance(playerId);
|
|
371
|
+
this.state = {
|
|
372
|
+
balance: initialBalance,
|
|
373
|
+
bet: config.defaultBet || 1,
|
|
374
|
+
gameMode: 0,
|
|
375
|
+
freeSpinsRemaining: 0,
|
|
376
|
+
bonusActive: false,
|
|
377
|
+
history: []
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Get the current full player state.
|
|
382
|
+
*/
|
|
383
|
+
getState() {
|
|
384
|
+
return { ...this.state };
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Build the INIT response for the client.
|
|
388
|
+
*/
|
|
389
|
+
getInitResponse() {
|
|
390
|
+
return {
|
|
391
|
+
balance: this.state.balance,
|
|
392
|
+
bet: this.state.bet,
|
|
393
|
+
config: this.config,
|
|
394
|
+
bonusOffers: null,
|
|
395
|
+
// you can add offers here
|
|
396
|
+
state: {
|
|
397
|
+
gameMode: this.state.gameMode,
|
|
398
|
+
freeSpinsRemaining: this.state.freeSpinsRemaining,
|
|
399
|
+
bonusActive: this.state.bonusActive
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Set the player's bet.
|
|
405
|
+
*/
|
|
406
|
+
setBet(bet) {
|
|
407
|
+
if (bet < this.config.minBet || bet > this.config.maxBet) {
|
|
408
|
+
throw new Error(`Bet must be between ${this.config.minBet} and ${this.config.maxBet}`);
|
|
409
|
+
}
|
|
410
|
+
this.state.bet = bet;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Process a spin request.
|
|
414
|
+
*/
|
|
415
|
+
spin(request) {
|
|
416
|
+
const totalStake = request.totalStake;
|
|
417
|
+
const stakePerLine = request.stakePerLine;
|
|
418
|
+
if (this.state.balance < totalStake) {
|
|
419
|
+
return { ...this.createErrorResponse("Insufficient balance"), balanceAfter: this.state.balance };
|
|
420
|
+
}
|
|
421
|
+
this.state.balance -= totalStake;
|
|
422
|
+
this.balanceProvider.setBalance(this.playerId, this.state.balance);
|
|
423
|
+
let spinResult;
|
|
424
|
+
let isBonusSpin = false;
|
|
425
|
+
if (this.bonusManager.isActive() && this.bonusManager.getState()?.type === "freespin") {
|
|
426
|
+
const rawResult = this.spinEvaluator.generateResult();
|
|
427
|
+
const evaluated = this.spinEvaluator.spin({
|
|
428
|
+
action: request.action,
|
|
429
|
+
stakePerLine: request.stakePerLine,
|
|
430
|
+
selectedLines: request.selectedLines,
|
|
431
|
+
totalStake: request.totalStake,
|
|
432
|
+
gameMode: 1
|
|
433
|
+
});
|
|
434
|
+
const bonusSpinResult = this.bonusManager.processFreeSpin(evaluated);
|
|
435
|
+
spinResult = bonusSpinResult;
|
|
436
|
+
isBonusSpin = true;
|
|
437
|
+
} else {
|
|
438
|
+
spinResult = this.spinEvaluator.spin(request);
|
|
439
|
+
}
|
|
440
|
+
let bonusTriggered = false;
|
|
441
|
+
let bonusData = null;
|
|
442
|
+
if (!isBonusSpin) {
|
|
443
|
+
const trigger = this.bonusManager.checkTrigger(spinResult.result);
|
|
444
|
+
if (trigger.triggered) {
|
|
445
|
+
bonusTriggered = true;
|
|
446
|
+
bonusData = trigger.data;
|
|
447
|
+
const freeSpins = bonusData.freeSpins || 5;
|
|
448
|
+
const multiplier = bonusData.multiplier || 1;
|
|
449
|
+
this.bonusManager.startFreeSpins(freeSpins, multiplier, request.stakePerLine);
|
|
450
|
+
this.state.bonusActive = true;
|
|
451
|
+
this.state.freeSpinsRemaining = freeSpins;
|
|
452
|
+
spinResult.freeSpinsRemaining = freeSpins;
|
|
453
|
+
spinResult.bonusTriggered = true;
|
|
454
|
+
spinResult.bonusData = bonusData;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const winAmount = spinResult.totalWin || 0;
|
|
458
|
+
if (winAmount > 0) {
|
|
459
|
+
this.state.balance += winAmount;
|
|
460
|
+
this.balanceProvider.setBalance(this.playerId, this.state.balance);
|
|
461
|
+
}
|
|
462
|
+
if (spinResult.freeSpinsRemaining !== void 0) {
|
|
463
|
+
this.state.freeSpinsRemaining = spinResult.freeSpinsRemaining;
|
|
464
|
+
if (this.state.freeSpinsRemaining <= 0) {
|
|
465
|
+
this.state.bonusActive = false;
|
|
466
|
+
this.bonusManager.endBonus();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
this.historyManager.addEntry(
|
|
470
|
+
spinResult.result,
|
|
471
|
+
request.totalStake,
|
|
472
|
+
winAmount,
|
|
473
|
+
this.state.balance,
|
|
474
|
+
request.gameMode
|
|
475
|
+
);
|
|
476
|
+
const response = {
|
|
477
|
+
...spinResult,
|
|
478
|
+
balanceAfter: this.state.balance
|
|
479
|
+
};
|
|
480
|
+
if (spinResult.winLines) {
|
|
481
|
+
response.winLines = spinResult.winLines;
|
|
482
|
+
}
|
|
483
|
+
return response;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Get history.
|
|
487
|
+
*/
|
|
488
|
+
getHistory(limit = 20) {
|
|
489
|
+
const entries = this.historyManager.getLast(limit);
|
|
490
|
+
return {
|
|
491
|
+
entries,
|
|
492
|
+
total: this.historyManager.getEntries().length
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Process a bonus action (e.g., pick).
|
|
497
|
+
*/
|
|
498
|
+
bonusAction(action, payload) {
|
|
499
|
+
if (action === "pick" && payload?.itemId) {
|
|
500
|
+
const result = this.bonusManager.pickItem(payload.itemId);
|
|
501
|
+
const win = result.win;
|
|
502
|
+
if (win > 0) {
|
|
503
|
+
this.state.balance += win;
|
|
504
|
+
this.balanceProvider.setBalance(this.playerId, this.state.balance);
|
|
505
|
+
}
|
|
506
|
+
const remaining = result.remaining;
|
|
507
|
+
if (remaining.length === 0) {
|
|
508
|
+
this.bonusManager.endBonus();
|
|
509
|
+
this.state.bonusActive = false;
|
|
510
|
+
return {
|
|
511
|
+
state: "COMPLETE",
|
|
512
|
+
data: { totalWin: this.bonusManager.getState()?.totalWin || 0 }
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
state: "ACTIVE",
|
|
517
|
+
data: { remaining, lastWin: win }
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
throw new Error(`Unknown bonus action: ${action}`);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Helper to create an error response.
|
|
524
|
+
*/
|
|
525
|
+
createErrorResponse(error) {
|
|
526
|
+
return {
|
|
527
|
+
result: [],
|
|
528
|
+
totalWin: 0,
|
|
529
|
+
stakePerLine: 0,
|
|
530
|
+
totalStake: 0,
|
|
531
|
+
gameMode: 0,
|
|
532
|
+
error
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
// src/JackpotManager.ts
|
|
538
|
+
var JackpotManager = class {
|
|
539
|
+
config;
|
|
540
|
+
rng;
|
|
541
|
+
tiers;
|
|
542
|
+
constructor(config, rng) {
|
|
543
|
+
this.config = config;
|
|
544
|
+
this.rng = rng;
|
|
545
|
+
this.tiers = /* @__PURE__ */ new Map();
|
|
546
|
+
if (config.enabled && config.tiers) {
|
|
547
|
+
for (const tier of config.tiers) {
|
|
548
|
+
this.tiers.set(tier.id, { ...tier });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Calculate jackpot contribution from a bet.
|
|
554
|
+
* Returns contributions for each tier.
|
|
555
|
+
*/
|
|
556
|
+
calculateContribution(totalStake) {
|
|
557
|
+
const contributions = [];
|
|
558
|
+
if (!this.config.enabled) {
|
|
559
|
+
return contributions;
|
|
560
|
+
}
|
|
561
|
+
for (const [tierId, tier] of this.tiers.entries()) {
|
|
562
|
+
const contributedAmount = totalStake * (tier.contributionRate / 100);
|
|
563
|
+
tier.currentAmount += contributedAmount;
|
|
564
|
+
contributions.push({
|
|
565
|
+
tierId,
|
|
566
|
+
contributedAmount,
|
|
567
|
+
newJackpotAmount: tier.currentAmount
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return contributions;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Check if a jackpot was triggered.
|
|
574
|
+
* Uses probability-based triggering.
|
|
575
|
+
*/
|
|
576
|
+
checkTrigger() {
|
|
577
|
+
if (!this.config.enabled) {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
for (const [tierId, tier] of this.tiers.entries()) {
|
|
581
|
+
const roll = this.rng.next();
|
|
582
|
+
if (roll < tier.triggerProbability) {
|
|
583
|
+
const winAmount = tier.currentAmount;
|
|
584
|
+
tier.currentAmount = tier.seedAmount;
|
|
585
|
+
return {
|
|
586
|
+
tierId,
|
|
587
|
+
tierName: tier.name,
|
|
588
|
+
winAmount,
|
|
589
|
+
timestamp: Date.now()
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Get current jackpot amounts for all tiers.
|
|
597
|
+
*/
|
|
598
|
+
getCurrentAmounts() {
|
|
599
|
+
const amounts = {};
|
|
600
|
+
for (const [tierId, tier] of this.tiers.entries()) {
|
|
601
|
+
amounts[tierId] = tier.currentAmount;
|
|
602
|
+
}
|
|
603
|
+
return amounts;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Get a specific tier's current amount.
|
|
607
|
+
*/
|
|
608
|
+
getTierAmount(tierId) {
|
|
609
|
+
const tier = this.tiers.get(tierId);
|
|
610
|
+
return tier?.currentAmount;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Update a tier's current amount (for external sync).
|
|
614
|
+
*/
|
|
615
|
+
updateTierAmount(tierId, amount) {
|
|
616
|
+
const tier = this.tiers.get(tierId);
|
|
617
|
+
if (tier) {
|
|
618
|
+
tier.currentAmount = amount;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Check if a bet is eligible for jackpot (based on minBetRequired).
|
|
623
|
+
*/
|
|
624
|
+
isBetEligible(totalStake) {
|
|
625
|
+
if (!this.config.enabled) {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
for (const tier of this.tiers.values()) {
|
|
629
|
+
if (tier.minBetRequired !== void 0 && totalStake < tier.minBetRequired) {
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Get configuration for a specific tier.
|
|
637
|
+
*/
|
|
638
|
+
getTierConfig(tierId) {
|
|
639
|
+
return this.tiers.get(tierId);
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Get all tier configurations.
|
|
643
|
+
*/
|
|
644
|
+
getAllTiers() {
|
|
645
|
+
return Array.from(this.tiers.values());
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Reset all tiers to seed amounts.
|
|
649
|
+
*/
|
|
650
|
+
resetAll() {
|
|
651
|
+
for (const tier of this.tiers.values()) {
|
|
652
|
+
tier.currentAmount = tier.seedAmount;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
// src/WaysEvaluator.ts
|
|
658
|
+
var WaysEvaluator = class {
|
|
659
|
+
config;
|
|
660
|
+
paytableData;
|
|
661
|
+
reelCount;
|
|
662
|
+
rowCount;
|
|
663
|
+
constructor(config, paytableData, reelCount, rowCount) {
|
|
664
|
+
this.config = config;
|
|
665
|
+
this.paytableData = paytableData;
|
|
666
|
+
this.reelCount = reelCount;
|
|
667
|
+
this.rowCount = rowCount;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Evaluate a spin result for ways-to-win.
|
|
671
|
+
* Returns win lines and total win amount.
|
|
672
|
+
*/
|
|
673
|
+
evaluate(result, stakePerLine) {
|
|
674
|
+
const { minMatches, direction } = this.config;
|
|
675
|
+
const winLines = [];
|
|
676
|
+
let totalWin = 0;
|
|
677
|
+
let totalWays = 0;
|
|
678
|
+
const symbolCountsPerReel = /* @__PURE__ */ new Map();
|
|
679
|
+
for (let r = 0; r < this.reelCount; r++) {
|
|
680
|
+
const reelSymbols = result[r] || [];
|
|
681
|
+
for (const symbolId of reelSymbols) {
|
|
682
|
+
if (!symbolCountsPerReel.has(symbolId)) {
|
|
683
|
+
symbolCountsPerReel.set(symbolId, new Array(this.reelCount).fill(0));
|
|
684
|
+
}
|
|
685
|
+
const counts = symbolCountsPerReel.get(symbolId);
|
|
686
|
+
counts[r]++;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
for (const [symbolId, counts] of symbolCountsPerReel.entries()) {
|
|
690
|
+
const symbolPaytable = this.paytableData[symbolId];
|
|
691
|
+
if (!symbolPaytable) continue;
|
|
692
|
+
let consecutiveReels = 0;
|
|
693
|
+
const positions = [];
|
|
694
|
+
for (let r = 0; r < this.reelCount; r++) {
|
|
695
|
+
if (counts[r] > 0) {
|
|
696
|
+
consecutiveReels++;
|
|
697
|
+
const reelSymbols = result[r] || [];
|
|
698
|
+
for (let row = 0; row < reelSymbols.length; row++) {
|
|
699
|
+
if (reelSymbols[row] === symbolId) {
|
|
700
|
+
positions.push({ reel: r, row });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
} else {
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (consecutiveReels >= minMatches) {
|
|
708
|
+
const multiplierKey = `x${consecutiveReels}`;
|
|
709
|
+
const multiplier = symbolPaytable[multiplierKey];
|
|
710
|
+
if (multiplier && multiplier > 0) {
|
|
711
|
+
let ways = 1;
|
|
712
|
+
for (let r = 0; r < consecutiveReels; r++) {
|
|
713
|
+
ways *= counts[r];
|
|
714
|
+
}
|
|
715
|
+
if (direction === "bothDirections") {
|
|
716
|
+
let consecutiveFromRight = 0;
|
|
717
|
+
for (let r = this.reelCount - 1; r >= 0; r--) {
|
|
718
|
+
if (counts[r] > 0) {
|
|
719
|
+
consecutiveFromRight++;
|
|
720
|
+
} else {
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (consecutiveFromRight >= minMatches && consecutiveFromRight > consecutiveReels) {
|
|
725
|
+
ways = 1;
|
|
726
|
+
for (let r = this.reelCount - consecutiveFromRight; r < this.reelCount; r++) {
|
|
727
|
+
ways *= counts[r];
|
|
728
|
+
}
|
|
729
|
+
consecutiveReels = consecutiveFromRight;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const winAmount = stakePerLine * multiplier * ways;
|
|
733
|
+
totalWin += winAmount;
|
|
734
|
+
totalWays += ways;
|
|
735
|
+
winLines.push({
|
|
736
|
+
lineIndex: symbolId,
|
|
737
|
+
// Use symbol ID as line index for ways
|
|
738
|
+
symbolId,
|
|
739
|
+
multiplier,
|
|
740
|
+
winAmount,
|
|
741
|
+
positions: positions.slice(0, consecutiveReels * this.rowCount),
|
|
742
|
+
wayIndex: totalWays
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return { winLines, totalWin, ways: totalWays };
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Get the maximum possible ways for this configuration.
|
|
751
|
+
*/
|
|
752
|
+
getMaxWays() {
|
|
753
|
+
return Math.pow(this.rowCount, this.reelCount);
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Check if ways-to-win is enabled.
|
|
757
|
+
*/
|
|
758
|
+
isEnabled() {
|
|
759
|
+
return this.config.enabled;
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
// src/GambleManager.ts
|
|
764
|
+
var GambleManager = class {
|
|
765
|
+
config;
|
|
766
|
+
rng;
|
|
767
|
+
consecutiveGambles = 0;
|
|
768
|
+
currentGambleWin = 0;
|
|
769
|
+
constructor(config, rng) {
|
|
770
|
+
this.config = config;
|
|
771
|
+
this.rng = rng;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Check if gamble feature is enabled and available.
|
|
775
|
+
*/
|
|
776
|
+
canGamble(currentWin) {
|
|
777
|
+
if (!this.config.enabled) {
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
if (currentWin <= 0) {
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
if (this.config.maxGambleAmount !== void 0 && currentWin > this.config.maxGambleAmount) {
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
if (this.consecutiveGambles >= this.config.maxConsecutiveGambles) {
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
if (this.config.autoCollectAt !== void 0 && currentWin >= this.config.autoCollectAt) {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
return true;
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Process a gamble request.
|
|
796
|
+
*/
|
|
797
|
+
processGamble(request) {
|
|
798
|
+
const { gambleType, currentWin, choice } = request;
|
|
799
|
+
if (!this.canGamble(currentWin)) {
|
|
800
|
+
return {
|
|
801
|
+
result: "lose",
|
|
802
|
+
winAmount: 0,
|
|
803
|
+
canGambleAgain: false,
|
|
804
|
+
consecutiveGambles: this.consecutiveGambles
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
let result;
|
|
808
|
+
let winAmount = 0;
|
|
809
|
+
switch (gambleType) {
|
|
810
|
+
case "card":
|
|
811
|
+
result = this.playCardGame(choice);
|
|
812
|
+
winAmount = result === "win" ? currentWin * 2 : 0;
|
|
813
|
+
break;
|
|
814
|
+
case "ladder":
|
|
815
|
+
result = this.playLadderGame(choice);
|
|
816
|
+
winAmount = result === "win" ? currentWin * 1.5 : 0;
|
|
817
|
+
break;
|
|
818
|
+
case "wheel":
|
|
819
|
+
result = this.playWheelGame(choice);
|
|
820
|
+
winAmount = result === "win" ? currentWin * this.rng.nextInt(2, 5) : 0;
|
|
821
|
+
break;
|
|
822
|
+
default:
|
|
823
|
+
result = "lose";
|
|
824
|
+
winAmount = 0;
|
|
825
|
+
}
|
|
826
|
+
if (result === "win") {
|
|
827
|
+
this.consecutiveGambles++;
|
|
828
|
+
this.currentGambleWin = winAmount;
|
|
829
|
+
} else if (result === "lose") {
|
|
830
|
+
this.consecutiveGambles = 0;
|
|
831
|
+
this.currentGambleWin = 0;
|
|
832
|
+
}
|
|
833
|
+
return {
|
|
834
|
+
result,
|
|
835
|
+
winAmount,
|
|
836
|
+
canGambleAgain: this.canGamble(winAmount),
|
|
837
|
+
consecutiveGambles: this.consecutiveGambles
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Card game: guess if card is red or black (50/50 chance).
|
|
842
|
+
*/
|
|
843
|
+
playCardGame(choice) {
|
|
844
|
+
const isRed = this.rng.next() < 0.5;
|
|
845
|
+
const playerChoosesRed = choice === "red" || choice === 0 || choice === void 0;
|
|
846
|
+
if (playerChoosesRed && isRed || !playerChoosesRed && !isRed) {
|
|
847
|
+
return "win";
|
|
848
|
+
}
|
|
849
|
+
return "lose";
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Ladder game: 60% chance to win, 40% to lose.
|
|
853
|
+
*/
|
|
854
|
+
playLadderGame(choice) {
|
|
855
|
+
const winChance = 0.6;
|
|
856
|
+
return this.rng.next() < winChance ? "win" : "lose";
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Wheel game: multiple outcomes based on wheel segments.
|
|
860
|
+
*/
|
|
861
|
+
playWheelGame(choice) {
|
|
862
|
+
const segments = [
|
|
863
|
+
{ value: 0, weight: 20 },
|
|
864
|
+
// Lose
|
|
865
|
+
{ value: 1, weight: 30 },
|
|
866
|
+
// Push (get bet back)
|
|
867
|
+
{ value: 2, weight: 30 },
|
|
868
|
+
// 2x win
|
|
869
|
+
{ value: 3, weight: 15 },
|
|
870
|
+
// 3x win
|
|
871
|
+
{ value: 5, weight: 5 }
|
|
872
|
+
// 5x win (jackpot)
|
|
873
|
+
];
|
|
874
|
+
const totalWeight = segments.reduce((sum, s) => sum + s.weight, 0);
|
|
875
|
+
let roll = this.rng.next() * totalWeight;
|
|
876
|
+
for (const segment of segments) {
|
|
877
|
+
roll -= segment.weight;
|
|
878
|
+
if (roll <= 0) {
|
|
879
|
+
if (segment.value === 0) return "lose";
|
|
880
|
+
if (segment.value === 1) return "push";
|
|
881
|
+
return "win";
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return "lose";
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Get current consecutive gamble count.
|
|
888
|
+
*/
|
|
889
|
+
getConsecutiveGambles() {
|
|
890
|
+
return this.consecutiveGambles;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Reset gamble state (e.g., after collecting winnings).
|
|
894
|
+
*/
|
|
895
|
+
reset() {
|
|
896
|
+
this.consecutiveGambles = 0;
|
|
897
|
+
this.currentGambleWin = 0;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Get the current gamble win amount.
|
|
901
|
+
*/
|
|
902
|
+
getCurrentGambleWin() {
|
|
903
|
+
return this.currentGambleWin;
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Get gamble configuration.
|
|
907
|
+
*/
|
|
908
|
+
getConfig() {
|
|
909
|
+
return this.config;
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
// src/BuyBonusManager.ts
|
|
914
|
+
var BuyBonusManager = class {
|
|
915
|
+
config;
|
|
916
|
+
purchasesThisSession = 0;
|
|
917
|
+
lastPurchaseTime = 0;
|
|
918
|
+
constructor(config) {
|
|
919
|
+
this.config = config;
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Check if buying bonus is enabled and available.
|
|
923
|
+
*/
|
|
924
|
+
canBuyBonus(currentBet) {
|
|
925
|
+
if (!this.config.enabled) {
|
|
926
|
+
return { canBuy: false, reason: "Buy bonus feature is disabled" };
|
|
927
|
+
}
|
|
928
|
+
if (this.config.minBetRequired !== void 0 && currentBet < this.config.minBetRequired) {
|
|
929
|
+
return {
|
|
930
|
+
canBuy: false,
|
|
931
|
+
reason: `Minimum bet of ${this.config.minBetRequired} required`
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
if (this.config.maxPurchasesPerSession !== void 0 && this.purchasesThisSession >= this.config.maxPurchasesPerSession) {
|
|
935
|
+
return {
|
|
936
|
+
canBuy: false,
|
|
937
|
+
reason: `Maximum purchases (${this.config.maxPurchasesPerSession}) reached for this session`
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
if (this.config.cooldownMs !== void 0) {
|
|
941
|
+
const timeSinceLastPurchase = Date.now() - this.lastPurchaseTime;
|
|
942
|
+
if (timeSinceLastPurchase < this.config.cooldownMs) {
|
|
943
|
+
const remainingCooldown = this.config.cooldownMs - timeSinceLastPurchase;
|
|
944
|
+
return {
|
|
945
|
+
canBuy: false,
|
|
946
|
+
reason: `Please wait ${Math.ceil(remainingCooldown / 1e3)} seconds before purchasing again`
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const cost = currentBet * this.config.costMultiplier;
|
|
951
|
+
return { canBuy: true, cost };
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Calculate the cost to buy bonus.
|
|
955
|
+
*/
|
|
956
|
+
calculateCost(currentBet) {
|
|
957
|
+
return currentBet * this.config.costMultiplier;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Record a bonus purchase.
|
|
961
|
+
*/
|
|
962
|
+
recordPurchase() {
|
|
963
|
+
this.purchasesThisSession++;
|
|
964
|
+
this.lastPurchaseTime = Date.now();
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Get the number of purchases made this session.
|
|
968
|
+
*/
|
|
969
|
+
getPurchasesThisSession() {
|
|
970
|
+
return this.purchasesThisSession;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Reset session tracking.
|
|
974
|
+
*/
|
|
975
|
+
resetSession() {
|
|
976
|
+
this.purchasesThisSession = 0;
|
|
977
|
+
this.lastPurchaseTime = 0;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Get configuration.
|
|
981
|
+
*/
|
|
982
|
+
getConfig() {
|
|
983
|
+
return this.config;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Create a spin response that triggers bonus (for buy bonus feature).
|
|
987
|
+
* This marks the spin as having triggered the bonus.
|
|
988
|
+
*/
|
|
989
|
+
createBonusTriggerSpin(baseSpinResult, bonusData) {
|
|
990
|
+
return {
|
|
991
|
+
...baseSpinResult,
|
|
992
|
+
bonusTriggered: true,
|
|
993
|
+
bonusData,
|
|
994
|
+
freeSpinsRemaining: bonusData?.freeSpins || 0
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
// src/CascadingReelsManager.ts
|
|
1000
|
+
var CascadingReelsManager = class {
|
|
1001
|
+
config;
|
|
1002
|
+
constructor(config) {
|
|
1003
|
+
this.config = config;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Execute a full cascade sequence until no more wins occur
|
|
1007
|
+
*/
|
|
1008
|
+
executeCascade(gameState, initialWins) {
|
|
1009
|
+
const cascadeResults = [];
|
|
1010
|
+
let currentGameState = { ...gameState };
|
|
1011
|
+
let remainingWins = [...initialWins];
|
|
1012
|
+
let totalMultiplier = this.config.initialMultiplier || 1;
|
|
1013
|
+
let cascadeCount = 0;
|
|
1014
|
+
const maxCascades = this.config.maxCascades || Infinity;
|
|
1015
|
+
while (remainingWins.length > 0 && cascadeCount < maxCascades) {
|
|
1016
|
+
cascadeCount++;
|
|
1017
|
+
const removedPositions = this.removeWinningSymbols(
|
|
1018
|
+
currentGameState.grid,
|
|
1019
|
+
remainingWins
|
|
1020
|
+
);
|
|
1021
|
+
const dropResult = this.applyGravity(currentGameState.grid, removedPositions);
|
|
1022
|
+
const newSymbols = this.fillEmptyPositions(dropResult.grid);
|
|
1023
|
+
if (this.config.multiplierIncrement) {
|
|
1024
|
+
totalMultiplier += this.config.multiplierIncrement;
|
|
1025
|
+
}
|
|
1026
|
+
const newWins = this.evaluateWins(newSymbols.grid);
|
|
1027
|
+
cascadeResults.push({
|
|
1028
|
+
cascadeNumber: cascadeCount,
|
|
1029
|
+
removedPositions,
|
|
1030
|
+
droppedSymbols: dropResult.droppedSymbols,
|
|
1031
|
+
newSymbols: newSymbols.addedSymbols,
|
|
1032
|
+
resultingGrid: newSymbols.grid,
|
|
1033
|
+
winsInStep: newWins,
|
|
1034
|
+
multiplier: totalMultiplier
|
|
1035
|
+
});
|
|
1036
|
+
currentGameState.grid = newSymbols.grid;
|
|
1037
|
+
remainingWins = newWins;
|
|
1038
|
+
if (this.config.stopOnNoWinIncrease && newWins.length === 0) {
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return {
|
|
1043
|
+
success: cascadeCount > 0,
|
|
1044
|
+
totalCascades: cascadeCount,
|
|
1045
|
+
steps: cascadeResults,
|
|
1046
|
+
finalGrid: currentGameState.grid,
|
|
1047
|
+
finalMultiplier: totalMultiplier,
|
|
1048
|
+
totalWinsFromCascades: cascadeResults.reduce(
|
|
1049
|
+
(sum, step) => sum + step.winsInStep.length,
|
|
1050
|
+
0
|
|
1051
|
+
)
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Remove symbols that are part of winning combinations
|
|
1056
|
+
*/
|
|
1057
|
+
removeWinningSymbols(grid, wins) {
|
|
1058
|
+
const removedPositions = [];
|
|
1059
|
+
const positionsToRemove = /* @__PURE__ */ new Set();
|
|
1060
|
+
wins.forEach((win) => {
|
|
1061
|
+
win.positions.forEach((pos) => {
|
|
1062
|
+
const key = `${pos.reel}-${pos.row}`;
|
|
1063
|
+
if (!positionsToRemove.has(key)) {
|
|
1064
|
+
positionsToRemove.add(key);
|
|
1065
|
+
removedPositions.push({
|
|
1066
|
+
reel: pos.reel,
|
|
1067
|
+
row: pos.row,
|
|
1068
|
+
symbol: grid[pos.reel][pos.row]
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
positionsToRemove.forEach((key) => {
|
|
1074
|
+
const [reelStr, rowStr] = key.split("-");
|
|
1075
|
+
const reel = parseInt(reelStr, 10);
|
|
1076
|
+
const row = parseInt(rowStr, 10);
|
|
1077
|
+
grid[reel][row] = {
|
|
1078
|
+
id: "EMPTY",
|
|
1079
|
+
value: 0,
|
|
1080
|
+
isWild: false,
|
|
1081
|
+
isScatter: false
|
|
1082
|
+
};
|
|
1083
|
+
});
|
|
1084
|
+
return removedPositions;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Apply gravity to make symbols fall down into empty positions
|
|
1088
|
+
*/
|
|
1089
|
+
applyGravity(grid, removedPositions) {
|
|
1090
|
+
const droppedSymbols = [];
|
|
1091
|
+
const numRows = grid.length > 0 ? grid[0].length : 0;
|
|
1092
|
+
for (let reel = 0; reel < grid.length; reel++) {
|
|
1093
|
+
const column = [];
|
|
1094
|
+
for (let row = numRows - 1; row >= 0; row--) {
|
|
1095
|
+
if (grid[reel][row].id !== "EMPTY") {
|
|
1096
|
+
column.push(grid[reel][row]);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
while (column.length < numRows) {
|
|
1100
|
+
column.push({
|
|
1101
|
+
id: "EMPTY",
|
|
1102
|
+
value: 0,
|
|
1103
|
+
isWild: false,
|
|
1104
|
+
isScatter: false
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
column.reverse();
|
|
1108
|
+
for (let row = 0; row < numRows; row++) {
|
|
1109
|
+
const originalSymbol = grid[reel][row];
|
|
1110
|
+
const newSymbol = column[row];
|
|
1111
|
+
if (originalSymbol.id !== "EMPTY" && newSymbol.id !== "EMPTY" && originalSymbol.id !== newSymbol.id) {
|
|
1112
|
+
for (let origRow = row + 1; origRow < numRows; origRow++) {
|
|
1113
|
+
if (grid[reel][origRow].id === newSymbol.id) {
|
|
1114
|
+
droppedSymbols.push({
|
|
1115
|
+
symbol: newSymbol,
|
|
1116
|
+
fromRow: origRow,
|
|
1117
|
+
toRow: row,
|
|
1118
|
+
reel
|
|
1119
|
+
});
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
grid[reel] = column;
|
|
1126
|
+
}
|
|
1127
|
+
return { grid, droppedSymbols };
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Fill empty positions at the top with new random symbols
|
|
1131
|
+
*/
|
|
1132
|
+
fillEmptyPositions(grid) {
|
|
1133
|
+
const addedSymbols = [];
|
|
1134
|
+
const symbolPool = this.config.symbolPool || [];
|
|
1135
|
+
if (symbolPool.length === 0) {
|
|
1136
|
+
throw new Error("Symbol pool must be provided for cascade filling");
|
|
1137
|
+
}
|
|
1138
|
+
for (let reel = 0; reel < grid.length; reel++) {
|
|
1139
|
+
for (let row = 0; row < grid[reel].length; row++) {
|
|
1140
|
+
if (grid[reel][row].id === "EMPTY") {
|
|
1141
|
+
const newSymbol = this.generateWeightedSymbol(symbolPool);
|
|
1142
|
+
grid[reel][row] = newSymbol;
|
|
1143
|
+
addedSymbols.push({
|
|
1144
|
+
symbol: newSymbol,
|
|
1145
|
+
position: { reel, row }
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return { grid, addedSymbols };
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Generate a symbol based on weighted probabilities
|
|
1154
|
+
*/
|
|
1155
|
+
generateWeightedSymbol(symbolPool) {
|
|
1156
|
+
const totalWeight = symbolPool.reduce((sum, sym) => sum + (sym.weight || 1), 0);
|
|
1157
|
+
let random = Math.random() * totalWeight;
|
|
1158
|
+
for (const symbol of symbolPool) {
|
|
1159
|
+
random -= symbol.weight || 1;
|
|
1160
|
+
if (random <= 0) {
|
|
1161
|
+
return { ...symbol };
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return { ...symbolPool[symbolPool.length - 1] };
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Evaluate wins on the current grid state
|
|
1168
|
+
* This is a simplified version - in production you'd integrate with your WinEvaluator
|
|
1169
|
+
*/
|
|
1170
|
+
evaluateWins(grid) {
|
|
1171
|
+
return [];
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Check if a cascade sequence should trigger special features
|
|
1175
|
+
*/
|
|
1176
|
+
checkCascadeTriggers(cascadeResult) {
|
|
1177
|
+
const triggers = [];
|
|
1178
|
+
if (cascadeResult.totalCascades >= (this.config.triggerOnCascadeCount || Infinity)) {
|
|
1179
|
+
triggers.push("CASCADE_COUNT_ACHIEVED");
|
|
1180
|
+
}
|
|
1181
|
+
if (cascadeResult.finalMultiplier >= (this.config.triggerOnMultiplier || Infinity)) {
|
|
1182
|
+
triggers.push("MULTIPLIER_THRESHOLD");
|
|
1183
|
+
}
|
|
1184
|
+
const consecutiveWins = cascadeResult.steps.filter((step) => step.winsInStep.length > 0).length;
|
|
1185
|
+
if (consecutiveWins >= (this.config.triggerOnConsecutiveWins || Infinity)) {
|
|
1186
|
+
triggers.push("CONSECUTIVE_WINS");
|
|
1187
|
+
}
|
|
1188
|
+
return { triggers };
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
// src/ExpandingWildsManager.ts
|
|
1193
|
+
var ExpandingWildsManager = class {
|
|
1194
|
+
expandConfig;
|
|
1195
|
+
stickyConfig;
|
|
1196
|
+
constructor(expandConfig, stickyConfig) {
|
|
1197
|
+
this.expandConfig = expandConfig;
|
|
1198
|
+
this.stickyConfig = stickyConfig || {
|
|
1199
|
+
enabled: false,
|
|
1200
|
+
persistSpins: 0,
|
|
1201
|
+
maxStickyWilds: Infinity
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Process expanding wilds on the current grid
|
|
1206
|
+
*/
|
|
1207
|
+
processExpandingWilds(gameState) {
|
|
1208
|
+
const expandedReels = [];
|
|
1209
|
+
const expandedPositions = [];
|
|
1210
|
+
let modifiedGrid = gameState.grid.map((reel) => [...reel]);
|
|
1211
|
+
for (let reel = 0; reel < modifiedGrid.length; reel++) {
|
|
1212
|
+
const shouldExpand = this.shouldReelExpand(modifiedGrid[reel], reel);
|
|
1213
|
+
if (shouldExpand) {
|
|
1214
|
+
expandedReels.push(reel);
|
|
1215
|
+
const rowsToExpand = this.expandConfig.expandToRows || modifiedGrid[reel].length;
|
|
1216
|
+
for (let row = 0; row < rowsToExpand; row++) {
|
|
1217
|
+
const originalSymbol = modifiedGrid[reel][row];
|
|
1218
|
+
if (!originalSymbol.isWild) {
|
|
1219
|
+
expandedPositions.push({ reel, row });
|
|
1220
|
+
modifiedGrid[reel][row] = {
|
|
1221
|
+
...originalSymbol,
|
|
1222
|
+
isWild: true,
|
|
1223
|
+
wildMultiplier: this.expandConfig.wildMultiplier || 1,
|
|
1224
|
+
expandedFrom: originalSymbol.id
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return {
|
|
1231
|
+
success: expandedReels.length > 0,
|
|
1232
|
+
expandedReels,
|
|
1233
|
+
expandedPositions,
|
|
1234
|
+
modifiedGrid,
|
|
1235
|
+
totalExpanded: expandedPositions.length,
|
|
1236
|
+
appliedMultiplier: this.expandConfig.wildMultiplier || 1
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Determine if a reel should expand based on configuration
|
|
1241
|
+
*/
|
|
1242
|
+
shouldReelExpand(reel, reelIndex) {
|
|
1243
|
+
const wildCount = reel.filter((sym) => sym.isWild).length;
|
|
1244
|
+
if (wildCount === 0) {
|
|
1245
|
+
return false;
|
|
1246
|
+
}
|
|
1247
|
+
switch (this.expandConfig.triggerCondition) {
|
|
1248
|
+
case "any_wild":
|
|
1249
|
+
return true;
|
|
1250
|
+
case "multiple_wilds":
|
|
1251
|
+
return wildCount >= (this.expandConfig.minWildsForExpand || 2);
|
|
1252
|
+
case "specific_positions":
|
|
1253
|
+
return this.hasWildInSpecificPositions(reel);
|
|
1254
|
+
case "random":
|
|
1255
|
+
const probability = this.expandConfig.expansionProbability || 0.5;
|
|
1256
|
+
return Math.random() < probability;
|
|
1257
|
+
default:
|
|
1258
|
+
return wildCount > 0;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Check if wilds exist in specific configured positions
|
|
1263
|
+
*/
|
|
1264
|
+
hasWildInSpecificPositions(reel) {
|
|
1265
|
+
const positions = this.expandConfig.specificPositions || [];
|
|
1266
|
+
return positions.some((pos) => reel[pos] && reel[pos].isWild);
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Apply sticky wilds logic for persistent wilds across spins
|
|
1270
|
+
*/
|
|
1271
|
+
processStickyWilds(gameState, existingStickyWilds) {
|
|
1272
|
+
const updatedStickyWilds = [];
|
|
1273
|
+
const removedStickyWilds = [];
|
|
1274
|
+
let modifiedGrid = gameState.grid.map((reel) => [...reel]);
|
|
1275
|
+
existingStickyWilds.forEach((sticky) => {
|
|
1276
|
+
const remainingSpins = sticky.remainingSpins - 1;
|
|
1277
|
+
if (remainingSpins <= 0) {
|
|
1278
|
+
removedStickyWilds.push({ reel: sticky.reel, row: sticky.row });
|
|
1279
|
+
modifiedGrid[sticky.reel][sticky.row] = {
|
|
1280
|
+
...modifiedGrid[sticky.reel][sticky.row],
|
|
1281
|
+
isSticky: false,
|
|
1282
|
+
isWild: false
|
|
1283
|
+
};
|
|
1284
|
+
} else {
|
|
1285
|
+
updatedStickyWilds.push({
|
|
1286
|
+
reel: sticky.reel,
|
|
1287
|
+
row: sticky.row,
|
|
1288
|
+
remainingSpins
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
const currentStickyCount = modifiedGrid.reduce(
|
|
1293
|
+
(sum, reel) => sum + reel.filter((sym) => sym.isSticky).length,
|
|
1294
|
+
0
|
|
1295
|
+
);
|
|
1296
|
+
return {
|
|
1297
|
+
success: updatedStickyWilds.length > 0 || removedStickyWilds.length > 0,
|
|
1298
|
+
activeStickyWilds: updatedStickyWilds,
|
|
1299
|
+
removedStickyWilds,
|
|
1300
|
+
modifiedGrid,
|
|
1301
|
+
totalActive: updatedStickyWilds.length,
|
|
1302
|
+
totalRemoved: removedStickyWilds.length,
|
|
1303
|
+
canAddMore: currentStickyCount < (this.stickyConfig.maxStickyWilds || Infinity)
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Add new sticky wilds to the grid
|
|
1308
|
+
*/
|
|
1309
|
+
addStickyWilds(gameState, positions, persistSpins) {
|
|
1310
|
+
const newStickyWilds = [];
|
|
1311
|
+
let modifiedGrid = gameState.grid.map((reel) => [...reel]);
|
|
1312
|
+
const spinsToPersist = persistSpins || this.stickyConfig.persistSpins || 3;
|
|
1313
|
+
positions.forEach((pos) => {
|
|
1314
|
+
if (pos.reel >= 0 && pos.reel < modifiedGrid.length && pos.row >= 0 && pos.row < modifiedGrid[pos.reel].length) {
|
|
1315
|
+
const currentSymbol = modifiedGrid[pos.reel][pos.row];
|
|
1316
|
+
modifiedGrid[pos.reel][pos.row] = {
|
|
1317
|
+
...currentSymbol,
|
|
1318
|
+
isWild: true,
|
|
1319
|
+
isSticky: true,
|
|
1320
|
+
remainingSpins: spinsToPersist,
|
|
1321
|
+
stickyId: `sticky_${pos.reel}_${pos.row}_${Date.now()}`
|
|
1322
|
+
};
|
|
1323
|
+
newStickyWilds.push({
|
|
1324
|
+
reel: pos.reel,
|
|
1325
|
+
row: pos.row,
|
|
1326
|
+
remainingSpins: spinsToPersist
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
});
|
|
1330
|
+
return {
|
|
1331
|
+
success: newStickyWilds.length > 0,
|
|
1332
|
+
activeStickyWilds: newStickyWilds,
|
|
1333
|
+
removedStickyWilds: [],
|
|
1334
|
+
modifiedGrid,
|
|
1335
|
+
totalActive: newStickyWilds.length,
|
|
1336
|
+
totalRemoved: 0,
|
|
1337
|
+
canAddMore: newStickyWilds.length < (this.stickyConfig.maxStickyWilds || Infinity)
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Calculate multiplier effect from wilds in a win line
|
|
1342
|
+
*/
|
|
1343
|
+
calculateWildMultiplier(winPositions, grid) {
|
|
1344
|
+
let totalMultiplier = 1;
|
|
1345
|
+
winPositions.forEach((pos) => {
|
|
1346
|
+
const symbol = grid[pos.reel][pos.row];
|
|
1347
|
+
if (symbol.isWild) {
|
|
1348
|
+
const wildMult = symbol.wildMultiplier || this.expandConfig.wildMultiplier || 1;
|
|
1349
|
+
if (this.expandConfig.multiplierStacking === "multiply") {
|
|
1350
|
+
totalMultiplier *= wildMult;
|
|
1351
|
+
} else if (this.expandConfig.multiplierStacking === "add") {
|
|
1352
|
+
totalMultiplier += wildMult - 1;
|
|
1353
|
+
} else {
|
|
1354
|
+
totalMultiplier = Math.max(totalMultiplier, wildMult);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
return totalMultiplier;
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Check for special wild interactions (e.g., expanding wild meeting sticky wild)
|
|
1362
|
+
*/
|
|
1363
|
+
checkWildInteractions(grid) {
|
|
1364
|
+
const interactions = [];
|
|
1365
|
+
const expandedReels = [];
|
|
1366
|
+
const stickyPositions = [];
|
|
1367
|
+
grid.forEach((reel, reelIndex) => {
|
|
1368
|
+
if (reel.some((sym) => sym.expandedFrom)) {
|
|
1369
|
+
expandedReels.push(reelIndex);
|
|
1370
|
+
}
|
|
1371
|
+
});
|
|
1372
|
+
grid.forEach((reel, reelIndex) => {
|
|
1373
|
+
reel.forEach((sym, rowIndex) => {
|
|
1374
|
+
if (sym.isSticky) {
|
|
1375
|
+
stickyPositions.push({ reel: reelIndex, row: rowIndex });
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1378
|
+
});
|
|
1379
|
+
if (expandedReels.length > 0 && stickyPositions.length > 0) {
|
|
1380
|
+
const overlappingReels = expandedReels.filter(
|
|
1381
|
+
(reel) => stickyPositions.some((pos) => pos.reel === reel)
|
|
1382
|
+
);
|
|
1383
|
+
if (overlappingReels.length > 0) {
|
|
1384
|
+
interactions.push("EXPANDING_STICKY_OVERLAP");
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
const fullScreenWild = grid.every((reel) => reel.every((sym) => sym.isWild));
|
|
1388
|
+
if (fullScreenWild) {
|
|
1389
|
+
interactions.push("FULL_SCREEN_WILDS");
|
|
1390
|
+
}
|
|
1391
|
+
return { interactions };
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
// src/ScatterEnhancementsManager.ts
|
|
1396
|
+
var ScatterEnhancementsManager = class {
|
|
1397
|
+
config;
|
|
1398
|
+
constructor(config) {
|
|
1399
|
+
this.config = config;
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Evaluate scatter symbols on the current grid
|
|
1403
|
+
*/
|
|
1404
|
+
evaluateScatters(gameState) {
|
|
1405
|
+
const scatterPositions = [];
|
|
1406
|
+
let totalScatterCount = 0;
|
|
1407
|
+
let totalScatterValue = 0;
|
|
1408
|
+
gameState.grid.forEach((reel, reelIndex) => {
|
|
1409
|
+
reel.forEach((symbol, rowIndex) => {
|
|
1410
|
+
if (symbol.isScatter) {
|
|
1411
|
+
scatterPositions.push({
|
|
1412
|
+
reel: reelIndex,
|
|
1413
|
+
row: rowIndex,
|
|
1414
|
+
symbol
|
|
1415
|
+
});
|
|
1416
|
+
totalScatterCount++;
|
|
1417
|
+
totalScatterValue += symbol.value || 0;
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
});
|
|
1421
|
+
const triggers = this.checkTriggerConditions(totalScatterCount, scatterPositions);
|
|
1422
|
+
const scatterPays = this.calculateScatterPays(totalScatterCount, totalScatterValue);
|
|
1423
|
+
return {
|
|
1424
|
+
success: scatterPositions.length > 0,
|
|
1425
|
+
scatterCount: totalScatterCount,
|
|
1426
|
+
scatterValue: totalScatterValue,
|
|
1427
|
+
positions: scatterPositions,
|
|
1428
|
+
triggers,
|
|
1429
|
+
scatterPays,
|
|
1430
|
+
collectedScatters: 0
|
|
1431
|
+
// Updated by collection system
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Check if scatter conditions trigger bonus features
|
|
1436
|
+
*/
|
|
1437
|
+
checkTriggerConditions(scatterCount, positions) {
|
|
1438
|
+
const triggers = [];
|
|
1439
|
+
if (scatterCount >= (this.config.minScattersForTrigger || 3)) {
|
|
1440
|
+
triggers.push({
|
|
1441
|
+
type: "BONUS_TRIGGER",
|
|
1442
|
+
triggered: true,
|
|
1443
|
+
scatterCount,
|
|
1444
|
+
featureType: this.config.triggeredFeature || "FREE_SPINS",
|
|
1445
|
+
awardedSpins: this.calculateAwardedSpins(scatterCount)
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
if (this.config.requireSpecificReels) {
|
|
1449
|
+
const reelSet = new Set(positions.map((p) => p.reel));
|
|
1450
|
+
const requiredReels = this.config.requiredReels || [];
|
|
1451
|
+
const hasAllRequired = requiredReels.every((reel) => reelSet.has(reel));
|
|
1452
|
+
if (hasAllRequired) {
|
|
1453
|
+
triggers.push({
|
|
1454
|
+
type: "REEL_COMBINATION_TRIGGER",
|
|
1455
|
+
triggered: true,
|
|
1456
|
+
scatterCount,
|
|
1457
|
+
featureType: "ENHANCED_BONUS",
|
|
1458
|
+
awardedSpins: this.calculateAwardedSpins(scatterCount) * 2
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
if (this.config.enableClusterTriggers) {
|
|
1463
|
+
const clusters = this.findScatterClusters(positions);
|
|
1464
|
+
clusters.forEach((cluster) => {
|
|
1465
|
+
if (cluster.size >= (this.config.clusterSizeForTrigger || 4)) {
|
|
1466
|
+
triggers.push({
|
|
1467
|
+
type: "CLUSTER_TRIGGER",
|
|
1468
|
+
triggered: true,
|
|
1469
|
+
scatterCount: cluster.size,
|
|
1470
|
+
featureType: "CLUSTER_BONUS",
|
|
1471
|
+
clusterPositions: cluster.positions
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
if (this.config.enableProgressiveTrigger) {
|
|
1477
|
+
}
|
|
1478
|
+
return triggers;
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Calculate number of spins awarded based on scatter count
|
|
1482
|
+
*/
|
|
1483
|
+
calculateAwardedSpins(scatterCount) {
|
|
1484
|
+
const spinTable = this.config.spinAwardTable || {
|
|
1485
|
+
3: 10,
|
|
1486
|
+
4: 15,
|
|
1487
|
+
5: 20,
|
|
1488
|
+
6: 25
|
|
1489
|
+
};
|
|
1490
|
+
const matchingKey = Object.keys(spinTable).map(Number).filter((key) => scatterCount >= key).sort((a, b) => b - a)[0];
|
|
1491
|
+
return matchingKey ? spinTable[matchingKey] : 0;
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Calculate immediate scatter payouts
|
|
1495
|
+
*/
|
|
1496
|
+
calculateScatterPays(count, totalValue) {
|
|
1497
|
+
if (!this.config.enableScatterPays) {
|
|
1498
|
+
return 0;
|
|
1499
|
+
}
|
|
1500
|
+
const payTable = this.config.scatterPayTable || {};
|
|
1501
|
+
const basePay = payTable[count] || 0;
|
|
1502
|
+
const multiplier = this.config.scatterMultiplier || 1;
|
|
1503
|
+
return (basePay + totalValue) * multiplier;
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Find clusters of adjacent scatter symbols
|
|
1507
|
+
*/
|
|
1508
|
+
findScatterClusters(positions) {
|
|
1509
|
+
const clusters = [];
|
|
1510
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1511
|
+
positions.forEach((pos) => {
|
|
1512
|
+
const key = `${pos.reel}-${pos.row}`;
|
|
1513
|
+
if (!visited.has(key)) {
|
|
1514
|
+
const cluster = this.floodFillCluster(pos, positions, visited);
|
|
1515
|
+
if (cluster.length > 1) {
|
|
1516
|
+
clusters.push({
|
|
1517
|
+
size: cluster.length,
|
|
1518
|
+
positions: cluster
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
return clusters;
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Flood fill algorithm to find connected scatter positions
|
|
1527
|
+
*/
|
|
1528
|
+
floodFillCluster(start, allPositions, visited) {
|
|
1529
|
+
const cluster = [];
|
|
1530
|
+
const queue = [start];
|
|
1531
|
+
const positionSet = new Set(allPositions.map((p) => `${p.reel}-${p.row}`));
|
|
1532
|
+
while (queue.length > 0) {
|
|
1533
|
+
const current = queue.shift();
|
|
1534
|
+
const key = `${current.reel}-${current.row}`;
|
|
1535
|
+
if (visited.has(key)) {
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
visited.add(key);
|
|
1539
|
+
cluster.push(current);
|
|
1540
|
+
const adjacentOffsets = [
|
|
1541
|
+
[-1, -1],
|
|
1542
|
+
[-1, 0],
|
|
1543
|
+
[-1, 1],
|
|
1544
|
+
[0, -1],
|
|
1545
|
+
[0, 1],
|
|
1546
|
+
[1, -1],
|
|
1547
|
+
[1, 0],
|
|
1548
|
+
[1, 1]
|
|
1549
|
+
];
|
|
1550
|
+
adjacentOffsets.forEach(([dReel, dRow]) => {
|
|
1551
|
+
const neighbor = {
|
|
1552
|
+
reel: current.reel + dReel,
|
|
1553
|
+
row: current.row + dRow
|
|
1554
|
+
};
|
|
1555
|
+
const neighborKey = `${neighbor.reel}-${neighbor.row}`;
|
|
1556
|
+
if (positionSet.has(neighborKey) && !visited.has(neighborKey)) {
|
|
1557
|
+
queue.push(neighbor);
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
return cluster;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Manage scatter collection system (collect scatters over multiple spins)
|
|
1565
|
+
*/
|
|
1566
|
+
manageScatterCollection(currentScatters, collectedData) {
|
|
1567
|
+
const updated = { ...collectedData };
|
|
1568
|
+
updated.count += currentScatters.scatterCount;
|
|
1569
|
+
currentScatters.positions.forEach((pos) => {
|
|
1570
|
+
updated.values.push(pos.symbol.value || 0);
|
|
1571
|
+
});
|
|
1572
|
+
let collectionTrigger;
|
|
1573
|
+
const targetCount = this.config.collectionTarget || 10;
|
|
1574
|
+
if (updated.count >= targetCount) {
|
|
1575
|
+
collectionTrigger = {
|
|
1576
|
+
type: "COLLECTION_COMPLETE",
|
|
1577
|
+
triggered: true,
|
|
1578
|
+
scatterCount: updated.count,
|
|
1579
|
+
featureType: "COLLECTION_BONUS",
|
|
1580
|
+
awardedSpins: this.config.collectionBonusSpins || 10,
|
|
1581
|
+
collectedValue: updated.values.reduce((sum, val) => sum + val, 0)
|
|
1582
|
+
};
|
|
1583
|
+
updated.count = 0;
|
|
1584
|
+
updated.values = [];
|
|
1585
|
+
}
|
|
1586
|
+
updated.spinsRemaining--;
|
|
1587
|
+
if (updated.spinsRemaining <= 0 && updated.count > 0 && this.config.autoTriggerOnExpiry) {
|
|
1588
|
+
collectionTrigger = {
|
|
1589
|
+
type: "COLLECTION_EXPIRY_TRIGGER",
|
|
1590
|
+
triggered: true,
|
|
1591
|
+
scatterCount: updated.count,
|
|
1592
|
+
featureType: "MINI_BONUS",
|
|
1593
|
+
awardedSpins: Math.floor(updated.count / 2)
|
|
1594
|
+
};
|
|
1595
|
+
updated.count = 0;
|
|
1596
|
+
updated.values = [];
|
|
1597
|
+
}
|
|
1598
|
+
return { updated, collectionTrigger };
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Apply scatter transformation mechanics (e.g., scatters becoming wilds)
|
|
1602
|
+
*/
|
|
1603
|
+
transformScatters(gameState, scatterPositions) {
|
|
1604
|
+
const transformed = [];
|
|
1605
|
+
const modifiedGrid = gameState.grid.map((reel) => [...reel]);
|
|
1606
|
+
if (!this.config.enableTransformation) {
|
|
1607
|
+
return { modifiedGrid, transformed };
|
|
1608
|
+
}
|
|
1609
|
+
scatterPositions.forEach((pos) => {
|
|
1610
|
+
if (pos.reel >= 0 && pos.reel < modifiedGrid.length && pos.row >= 0 && pos.row < modifiedGrid[pos.reel].length) {
|
|
1611
|
+
const transformType = this.config.transformationType || "to_wild";
|
|
1612
|
+
if (transformType === "to_wild") {
|
|
1613
|
+
modifiedGrid[pos.reel][pos.row] = {
|
|
1614
|
+
...modifiedGrid[pos.reel][pos.row],
|
|
1615
|
+
isWild: true,
|
|
1616
|
+
isScatter: false,
|
|
1617
|
+
wildMultiplier: this.config.transformedWildMultiplier || 1
|
|
1618
|
+
};
|
|
1619
|
+
transformed.push(pos);
|
|
1620
|
+
} else if (transformType === "to_multiplier") {
|
|
1621
|
+
modifiedGrid[pos.reel][pos.row] = {
|
|
1622
|
+
...modifiedGrid[pos.reel][pos.row],
|
|
1623
|
+
isScatter: false,
|
|
1624
|
+
value: (modifiedGrid[pos.reel][pos.row].value || 1) * 2
|
|
1625
|
+
};
|
|
1626
|
+
transformed.push(pos);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1630
|
+
return { modifiedGrid, transformed };
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Check for scatter in multiplier zones (special grid areas)
|
|
1634
|
+
*/
|
|
1635
|
+
checkMultiplierZones(scatterPositions) {
|
|
1636
|
+
const zoneHits = [];
|
|
1637
|
+
let totalMultiplier = 1;
|
|
1638
|
+
const zones = this.config.multiplierZones || [];
|
|
1639
|
+
scatterPositions.forEach((pos) => {
|
|
1640
|
+
zones.forEach((zone) => {
|
|
1641
|
+
if (pos.reel >= zone.reelStart && pos.reel <= zone.reelEnd && pos.row >= zone.rowStart && pos.row <= zone.rowEnd) {
|
|
1642
|
+
zoneHits.push({
|
|
1643
|
+
reel: pos.reel,
|
|
1644
|
+
row: pos.row,
|
|
1645
|
+
multiplier: zone.multiplier
|
|
1646
|
+
});
|
|
1647
|
+
if (this.config.zoneMultiplierStacking === "multiply") {
|
|
1648
|
+
totalMultiplier *= zone.multiplier;
|
|
1649
|
+
} else {
|
|
1650
|
+
totalMultiplier += zone.multiplier - 1;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
});
|
|
1655
|
+
return { totalMultiplier, zoneHits };
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Implement progressive scatter trigger (accumulates across game session)
|
|
1659
|
+
*/
|
|
1660
|
+
updateProgressiveScatter(currentCount, progressiveState) {
|
|
1661
|
+
const updated = { ...progressiveState };
|
|
1662
|
+
updated.accumulated += currentCount;
|
|
1663
|
+
let triggered = false;
|
|
1664
|
+
let reward;
|
|
1665
|
+
if (updated.accumulated >= updated.threshold) {
|
|
1666
|
+
triggered = true;
|
|
1667
|
+
reward = {
|
|
1668
|
+
type: "PROGRESSIVE_SCATTER_BONUS",
|
|
1669
|
+
level: updated.level,
|
|
1670
|
+
accumulatedCount: updated.accumulated,
|
|
1671
|
+
awardedSpins: this.config.progressiveBonusSpins || updated.level * 10,
|
|
1672
|
+
multiplier: this.config.progressiveMultiplier || updated.level
|
|
1673
|
+
};
|
|
1674
|
+
if (this.config.progressiveResetOnTrigger) {
|
|
1675
|
+
updated.accumulated = 0;
|
|
1676
|
+
updated.level = 1;
|
|
1677
|
+
updated.threshold = this.config.baseProgressiveThreshold || 20;
|
|
1678
|
+
} else {
|
|
1679
|
+
updated.level++;
|
|
1680
|
+
updated.threshold = Math.floor(updated.threshold * 1.5);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
return { updated, triggered, reward };
|
|
1684
|
+
}
|
|
1685
|
+
};
|
|
1686
|
+
|
|
1687
|
+
// src/RespinsManager.ts
|
|
1688
|
+
var RespinsManager = class {
|
|
1689
|
+
config;
|
|
1690
|
+
constructor(config) {
|
|
1691
|
+
this.config = config;
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Check if respin feature should be triggered
|
|
1695
|
+
*/
|
|
1696
|
+
checkTrigger(gameState) {
|
|
1697
|
+
const { triggerType, minTriggerSymbols } = this.config;
|
|
1698
|
+
if (triggerType === "symbol_count") {
|
|
1699
|
+
const specialSymbols = this.countSpecialSymbols(gameState);
|
|
1700
|
+
return specialSymbols >= minTriggerSymbols;
|
|
1701
|
+
}
|
|
1702
|
+
if (triggerType === "specific_positions") {
|
|
1703
|
+
return this.checkSpecificPositions(gameState);
|
|
1704
|
+
}
|
|
1705
|
+
return false;
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Count special symbols (money, reset, jackpot) on the grid
|
|
1709
|
+
*/
|
|
1710
|
+
countSpecialSymbols(gameState) {
|
|
1711
|
+
let count = 0;
|
|
1712
|
+
const rows = gameState.grid.length;
|
|
1713
|
+
const cols = rows > 0 ? gameState.grid[0].length : 0;
|
|
1714
|
+
for (let row = 0; row < rows; row++) {
|
|
1715
|
+
for (let col = 0; col < cols; col++) {
|
|
1716
|
+
const symbol = gameState.grid[row][col];
|
|
1717
|
+
if (this.isSpecialSymbol(symbol)) {
|
|
1718
|
+
count++;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
return count;
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Check if special symbols are in required positions
|
|
1726
|
+
*/
|
|
1727
|
+
checkSpecificPositions(gameState) {
|
|
1728
|
+
const { triggerPositions } = this.config;
|
|
1729
|
+
if (!triggerPositions) return false;
|
|
1730
|
+
for (const pos of triggerPositions) {
|
|
1731
|
+
const { row, col, symbolId } = pos;
|
|
1732
|
+
if (row >= gameState.grid.length || col >= gameState.grid[0].length) {
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
const symbol = gameState.grid[row][col];
|
|
1736
|
+
if (symbol.id === symbolId || this.isSpecialSymbol(symbol)) {
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
return false;
|
|
1740
|
+
}
|
|
1741
|
+
return true;
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Check if a symbol is a special respin symbol
|
|
1745
|
+
*/
|
|
1746
|
+
isSpecialSymbol(symbol) {
|
|
1747
|
+
const moneySymbol = symbol;
|
|
1748
|
+
return moneySymbol.isMoneySymbol === true || moneySymbol.isResetSymbol === true || moneySymbol.isJackpotSymbol === true;
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Execute the respins feature
|
|
1752
|
+
*/
|
|
1753
|
+
execute(gameState) {
|
|
1754
|
+
const steps = [];
|
|
1755
|
+
let remainingRespins = this.config.initialRespins || 3;
|
|
1756
|
+
const lockedPositions = [];
|
|
1757
|
+
let totalValue = 0;
|
|
1758
|
+
let jackpotsCollected = [];
|
|
1759
|
+
const rows = gameState.grid.length;
|
|
1760
|
+
const cols = rows > 0 ? gameState.grid[0].length : 0;
|
|
1761
|
+
for (let row = 0; row < rows; row++) {
|
|
1762
|
+
for (let col = 0; col < cols; col++) {
|
|
1763
|
+
const symbol = gameState.grid[row][col];
|
|
1764
|
+
if (this.isSpecialSymbol(symbol)) {
|
|
1765
|
+
lockedPositions.push({ row, col });
|
|
1766
|
+
const moneySymbol = symbol;
|
|
1767
|
+
if (moneySymbol.value) {
|
|
1768
|
+
totalValue += moneySymbol.value;
|
|
1769
|
+
}
|
|
1770
|
+
if (moneySymbol.jackpotType) {
|
|
1771
|
+
jackpotsCollected.push(moneySymbol.jackpotType);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
steps.push({
|
|
1777
|
+
respinsRemaining: remainingRespins,
|
|
1778
|
+
lockedPositions: [...lockedPositions],
|
|
1779
|
+
newSymbols: [],
|
|
1780
|
+
valuesAdded: totalValue,
|
|
1781
|
+
jackpotsAwarded: [...jackpotsCollected],
|
|
1782
|
+
reason: "initial_trigger"
|
|
1783
|
+
});
|
|
1784
|
+
while (remainingRespins > 0 && lockedPositions.length < rows * cols) {
|
|
1785
|
+
remainingRespins--;
|
|
1786
|
+
const newSymbols = [];
|
|
1787
|
+
let stepValueAdded = 0;
|
|
1788
|
+
let stepJackpots = [];
|
|
1789
|
+
for (let row = 0; row < rows; row++) {
|
|
1790
|
+
for (let col = 0; col < cols; col++) {
|
|
1791
|
+
const isLocked = lockedPositions.some(
|
|
1792
|
+
(pos) => pos.row === row && pos.col === col
|
|
1793
|
+
);
|
|
1794
|
+
if (!isLocked) {
|
|
1795
|
+
const newSymbol = this.generateRespinsSymbol();
|
|
1796
|
+
newSymbols.push({ row, col, symbol: newSymbol });
|
|
1797
|
+
if (this.isSpecialSymbol(newSymbol)) {
|
|
1798
|
+
lockedPositions.push({ row, col });
|
|
1799
|
+
const moneySymbol = newSymbol;
|
|
1800
|
+
if (moneySymbol.value) {
|
|
1801
|
+
stepValueAdded += moneySymbol.value;
|
|
1802
|
+
totalValue += moneySymbol.value;
|
|
1803
|
+
}
|
|
1804
|
+
if (moneySymbol.jackpotType) {
|
|
1805
|
+
stepJackpots.push(moneySymbol.jackpotType);
|
|
1806
|
+
jackpotsCollected.push(moneySymbol.jackpotType);
|
|
1807
|
+
}
|
|
1808
|
+
if (moneySymbol.isResetSymbol === true) {
|
|
1809
|
+
remainingRespins = this.config.initialRespins || 3;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
steps.push({
|
|
1816
|
+
respinsRemaining: remainingRespins,
|
|
1817
|
+
lockedPositions: [...lockedPositions],
|
|
1818
|
+
newSymbols: newSymbols.map((ns) => ({
|
|
1819
|
+
row: ns.row,
|
|
1820
|
+
col: ns.col,
|
|
1821
|
+
symbolId: ns.symbol.id
|
|
1822
|
+
})),
|
|
1823
|
+
valuesAdded: stepValueAdded,
|
|
1824
|
+
jackpotsAwarded: stepJackpots,
|
|
1825
|
+
reason: stepJackpots.length > 0 ? "jackpot_collected" : newSymbols.length > 0 ? "respins" : "no_new_symbols"
|
|
1826
|
+
});
|
|
1827
|
+
if (newSymbols.every((ns) => !this.isSpecialSymbol(ns.symbol))) {
|
|
1828
|
+
if (this.config.endOnNoSpecial) {
|
|
1829
|
+
break;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
if (lockedPositions.length === rows * cols && this.config.fullScreenBonus) {
|
|
1833
|
+
totalValue *= this.config.fullScreenBonus.multiplier;
|
|
1834
|
+
steps[steps.length - 1].reason = "full_screen_bonus";
|
|
1835
|
+
steps[steps.length - 1].valuesAdded = totalValue;
|
|
1836
|
+
break;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
return {
|
|
1840
|
+
triggered: true,
|
|
1841
|
+
steps,
|
|
1842
|
+
finalValue: totalValue,
|
|
1843
|
+
jackpotsCollected,
|
|
1844
|
+
lockedPositions,
|
|
1845
|
+
totalRespinsUsed: (this.config.initialRespins || 3) - remainingRespins
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Generate a special symbol for respins (weighted random)
|
|
1850
|
+
* In production, this should use a certified RNG with configurable weights
|
|
1851
|
+
*/
|
|
1852
|
+
generateRespinsSymbol() {
|
|
1853
|
+
const { symbolWeights } = this.config;
|
|
1854
|
+
const rand = Math.random();
|
|
1855
|
+
let cumulative = 0;
|
|
1856
|
+
if (symbolWeights) {
|
|
1857
|
+
for (const weight of symbolWeights) {
|
|
1858
|
+
cumulative += weight.probability;
|
|
1859
|
+
if (rand <= cumulative) {
|
|
1860
|
+
const symbol = {
|
|
1861
|
+
id: weight.symbolId,
|
|
1862
|
+
value: weight.baseValue || 0,
|
|
1863
|
+
isWild: false,
|
|
1864
|
+
isScatter: false,
|
|
1865
|
+
isMoneySymbol: weight.isMoneySymbol,
|
|
1866
|
+
isResetSymbol: weight.isResetSymbol,
|
|
1867
|
+
isJackpotSymbol: weight.isJackpotSymbol,
|
|
1868
|
+
jackpotType: weight.jackpotType
|
|
1869
|
+
};
|
|
1870
|
+
return symbol;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
return {
|
|
1875
|
+
id: "regular",
|
|
1876
|
+
value: 0,
|
|
1877
|
+
isWild: false,
|
|
1878
|
+
isScatter: false
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Calculate potential payout for respins feature
|
|
1883
|
+
*/
|
|
1884
|
+
calculatePotentialPayout(betAmount) {
|
|
1885
|
+
const { initialRespins, symbolWeights, fullScreenBonus } = this.config;
|
|
1886
|
+
if (!symbolWeights) return 0;
|
|
1887
|
+
let expectedValue = 0;
|
|
1888
|
+
const totalProbability = symbolWeights.reduce((sum, w) => sum + w.probability, 0);
|
|
1889
|
+
for (const weight of symbolWeights) {
|
|
1890
|
+
const symbolValue = weight.baseValue || 0;
|
|
1891
|
+
const probability = weight.probability / totalProbability;
|
|
1892
|
+
expectedValue += symbolValue * probability;
|
|
1893
|
+
}
|
|
1894
|
+
let expectedTotal = expectedValue * (initialRespins || 3);
|
|
1895
|
+
if (fullScreenBonus) {
|
|
1896
|
+
expectedTotal *= 1 + (fullScreenBonus.multiplier - 1) * 0.01;
|
|
1897
|
+
}
|
|
1898
|
+
return expectedTotal * betAmount;
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Get configuration for client-side preview
|
|
1902
|
+
*/
|
|
1903
|
+
getConfig() {
|
|
1904
|
+
return { ...this.config };
|
|
1905
|
+
}
|
|
1906
|
+
};
|
|
1907
|
+
|
|
1908
|
+
// src/WalletManager.ts
|
|
1909
|
+
var InMemoryWalletStorage = class {
|
|
1910
|
+
balances = /* @__PURE__ */ new Map();
|
|
1911
|
+
transactions = /* @__PURE__ */ new Map();
|
|
1912
|
+
async getBalance(userId) {
|
|
1913
|
+
return this.balances.get(userId) || null;
|
|
1914
|
+
}
|
|
1915
|
+
async updateBalance(userId, amount, version) {
|
|
1916
|
+
const current = this.balances.get(userId);
|
|
1917
|
+
if (current && current.version !== version) {
|
|
1918
|
+
return null;
|
|
1919
|
+
}
|
|
1920
|
+
const newBalance = current ? parseFloat(current.amount) + parseFloat(amount) : parseFloat(amount);
|
|
1921
|
+
const updatedBalance = {
|
|
1922
|
+
userId,
|
|
1923
|
+
amount: newBalance.toFixed(4),
|
|
1924
|
+
// Adjust precision as needed
|
|
1925
|
+
version: (current?.version || 0) + 1,
|
|
1926
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1927
|
+
};
|
|
1928
|
+
this.balances.set(userId, updatedBalance);
|
|
1929
|
+
return updatedBalance;
|
|
1930
|
+
}
|
|
1931
|
+
async recordTransaction(transaction) {
|
|
1932
|
+
const userTxns = this.transactions.get(transaction.userId) || [];
|
|
1933
|
+
userTxns.push(transaction);
|
|
1934
|
+
this.transactions.set(transaction.userId, userTxns);
|
|
1935
|
+
}
|
|
1936
|
+
async getTransactionHistory(userId, limit = 50) {
|
|
1937
|
+
const userTxns = this.transactions.get(userId) || [];
|
|
1938
|
+
return userTxns.slice(-limit);
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
var WalletManager = class {
|
|
1942
|
+
storage;
|
|
1943
|
+
config;
|
|
1944
|
+
constructor(storage, config) {
|
|
1945
|
+
this.storage = storage;
|
|
1946
|
+
this.config = config;
|
|
1947
|
+
}
|
|
1948
|
+
/**
|
|
1949
|
+
* Place a bet (deduct funds)
|
|
1950
|
+
*/
|
|
1951
|
+
async placeBet(userId, amount, gameId, roundId) {
|
|
1952
|
+
return this.executeTransaction(userId, -amount, "bet", { gameId, roundId });
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Credit winnings
|
|
1956
|
+
*/
|
|
1957
|
+
async creditWin(userId, amount, gameId, roundId, winType = "normal") {
|
|
1958
|
+
let type = "payout";
|
|
1959
|
+
if (winType === "bonus") type = "bonus_payout";
|
|
1960
|
+
if (winType === "jackpot") type = "jackpot_win";
|
|
1961
|
+
return this.executeTransaction(userId, amount, type, { gameId, roundId });
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Core transaction logic with optimistic locking
|
|
1965
|
+
*/
|
|
1966
|
+
async executeTransaction(userId, amount, type, metadata) {
|
|
1967
|
+
const maxRetries = 3;
|
|
1968
|
+
let attempt = 0;
|
|
1969
|
+
while (attempt < maxRetries) {
|
|
1970
|
+
try {
|
|
1971
|
+
const currentBalance = await this.storage.getBalance(userId);
|
|
1972
|
+
const currentAmount = currentBalance ? parseFloat(currentBalance.amount) : 0;
|
|
1973
|
+
const version = currentBalance?.version || 0;
|
|
1974
|
+
if (amount < 0 && currentAmount + amount < 0) {
|
|
1975
|
+
return { success: false, error: "Insufficient funds", code: "INSUFFICIENT_FUNDS" };
|
|
1976
|
+
}
|
|
1977
|
+
const newAmount = currentAmount + amount;
|
|
1978
|
+
const updatedBalance = await this.storage.updateBalance(userId, amount.toString(), version);
|
|
1979
|
+
if (!updatedBalance) {
|
|
1980
|
+
attempt++;
|
|
1981
|
+
continue;
|
|
1982
|
+
}
|
|
1983
|
+
const transaction = {
|
|
1984
|
+
id: crypto.randomUUID(),
|
|
1985
|
+
userId,
|
|
1986
|
+
type,
|
|
1987
|
+
amount: amount.toString(),
|
|
1988
|
+
balanceBefore: currentAmount.toString(),
|
|
1989
|
+
balanceAfter: newAmount.toString(),
|
|
1990
|
+
referenceId: metadata.roundId,
|
|
1991
|
+
metadata: { gameId: metadata.gameId },
|
|
1992
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1993
|
+
};
|
|
1994
|
+
await this.storage.recordTransaction(transaction);
|
|
1995
|
+
return {
|
|
1996
|
+
success: true,
|
|
1997
|
+
balance: updatedBalance.amount,
|
|
1998
|
+
transactionId: transaction.id
|
|
1999
|
+
};
|
|
2000
|
+
} catch (error) {
|
|
2001
|
+
return { success: false, error: error.message, code: "INTERNAL_ERROR" };
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
return { success: false, error: "Transaction failed after retries", code: "CONFLICT" };
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* Transfer funds between users (e.g., P2P features)
|
|
2008
|
+
*/
|
|
2009
|
+
async transfer(fromId, toId, amount) {
|
|
2010
|
+
const debit = await this.placeBet(fromId, amount, "transfer", `tx-${Date.now()}`);
|
|
2011
|
+
if (!debit.success) return debit;
|
|
2012
|
+
const credit = await this.creditWin(toId, amount, "transfer", `tx-${Date.now()}`, "normal");
|
|
2013
|
+
if (!credit.success) {
|
|
2014
|
+
return { success: false, error: "Transfer partial failure", code: "ROLLBACK_REQUIRED" };
|
|
2015
|
+
}
|
|
2016
|
+
return { success: true, message: "Transfer complete" };
|
|
2017
|
+
}
|
|
2018
|
+
};
|
|
2019
|
+
|
|
2020
|
+
// src/AuditManager.ts
|
|
2021
|
+
var InMemoryAuditStorage = class {
|
|
2022
|
+
records = /* @__PURE__ */ new Map();
|
|
2023
|
+
playerIndex = /* @__PURE__ */ new Map();
|
|
2024
|
+
async save(record) {
|
|
2025
|
+
this.records.set(record.spinId, record);
|
|
2026
|
+
if (!this.playerIndex.has(record.playerId)) {
|
|
2027
|
+
this.playerIndex.set(record.playerId, /* @__PURE__ */ new Set());
|
|
2028
|
+
}
|
|
2029
|
+
this.playerIndex.get(record.playerId).add(record.spinId);
|
|
2030
|
+
}
|
|
2031
|
+
async findByPlayer(playerId, startTime, endTime) {
|
|
2032
|
+
const spinIds = this.playerIndex.get(playerId) || /* @__PURE__ */ new Set();
|
|
2033
|
+
const results = [];
|
|
2034
|
+
for (const spinId of spinIds) {
|
|
2035
|
+
const record = this.records.get(spinId);
|
|
2036
|
+
if (record && record.timestamp >= startTime && record.timestamp <= endTime) {
|
|
2037
|
+
results.push(record);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
return results.sort((a, b) => a.timestamp - b.timestamp);
|
|
2041
|
+
}
|
|
2042
|
+
async findById(spinId) {
|
|
2043
|
+
return this.records.get(spinId) || null;
|
|
2044
|
+
}
|
|
2045
|
+
async getSummary(playerId, startTime, endTime) {
|
|
2046
|
+
const records = await this.findByPlayer(playerId, startTime, endTime);
|
|
2047
|
+
const summary = {
|
|
2048
|
+
playerId,
|
|
2049
|
+
periodStart: startTime,
|
|
2050
|
+
periodEnd: endTime,
|
|
2051
|
+
totalSpins: records.length,
|
|
2052
|
+
totalStaked: 0,
|
|
2053
|
+
totalWon: 0,
|
|
2054
|
+
netResult: 0,
|
|
2055
|
+
biggestWin: 0,
|
|
2056
|
+
bonusTriggers: 0,
|
|
2057
|
+
jackpotWins: 0,
|
|
2058
|
+
sessionCount: 0,
|
|
2059
|
+
averageSessionDuration: 0
|
|
2060
|
+
};
|
|
2061
|
+
const sessions = /* @__PURE__ */ new Set();
|
|
2062
|
+
for (const record of records) {
|
|
2063
|
+
summary.totalStaked += record.stake;
|
|
2064
|
+
summary.totalWon += record.win;
|
|
2065
|
+
if (record.win > summary.biggestWin) {
|
|
2066
|
+
summary.biggestWin = record.win;
|
|
2067
|
+
}
|
|
2068
|
+
if (record.bonusTriggered) {
|
|
2069
|
+
summary.bonusTriggers++;
|
|
2070
|
+
}
|
|
2071
|
+
if (record.jackpotWins.length > 0) {
|
|
2072
|
+
summary.jackpotWins += record.jackpotWins.length;
|
|
2073
|
+
}
|
|
2074
|
+
sessions.add(record.sessionId);
|
|
2075
|
+
}
|
|
2076
|
+
summary.netResult = summary.totalWon - summary.totalStaked;
|
|
2077
|
+
summary.sessionCount = sessions.size;
|
|
2078
|
+
if (summary.sessionCount > 0) {
|
|
2079
|
+
summary.averageSessionDuration = (endTime - startTime) / summary.sessionCount / 6e4;
|
|
2080
|
+
}
|
|
2081
|
+
return summary;
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Clear all records (for testing).
|
|
2085
|
+
*/
|
|
2086
|
+
clear() {
|
|
2087
|
+
this.records.clear();
|
|
2088
|
+
this.playerIndex.clear();
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* Get total record count.
|
|
2092
|
+
*/
|
|
2093
|
+
getCount() {
|
|
2094
|
+
return this.records.size;
|
|
2095
|
+
}
|
|
2096
|
+
};
|
|
2097
|
+
var AuditManager = class {
|
|
2098
|
+
storage;
|
|
2099
|
+
gameId;
|
|
2100
|
+
operatorId;
|
|
2101
|
+
constructor(gameId, storage, operatorId) {
|
|
2102
|
+
this.gameId = gameId;
|
|
2103
|
+
this.storage = storage || new InMemoryAuditStorage();
|
|
2104
|
+
this.operatorId = operatorId;
|
|
2105
|
+
}
|
|
2106
|
+
/**
|
|
2107
|
+
* Record a spin for audit purposes.
|
|
2108
|
+
*/
|
|
2109
|
+
async recordSpin(params) {
|
|
2110
|
+
const record = {
|
|
2111
|
+
spinId: params.spinId,
|
|
2112
|
+
playerId: params.playerId,
|
|
2113
|
+
gameId: this.gameId,
|
|
2114
|
+
timestamp: Date.now(),
|
|
2115
|
+
stake: params.stake,
|
|
2116
|
+
win: params.win,
|
|
2117
|
+
balanceBefore: params.balanceBefore,
|
|
2118
|
+
balanceAfter: params.balanceAfter,
|
|
2119
|
+
rngSeed: params.rngSeed,
|
|
2120
|
+
result: params.result,
|
|
2121
|
+
gameMode: params.gameMode,
|
|
2122
|
+
bonusTriggered: params.bonusTriggered,
|
|
2123
|
+
jackpotContributions: params.jackpotContributions,
|
|
2124
|
+
jackpotWins: params.jackpotWins,
|
|
2125
|
+
ipAddress: params.ipAddress,
|
|
2126
|
+
sessionId: params.sessionId,
|
|
2127
|
+
deviceInfo: params.deviceInfo,
|
|
2128
|
+
location: params.location,
|
|
2129
|
+
operatorId: this.operatorId,
|
|
2130
|
+
platform: params.platform
|
|
2131
|
+
};
|
|
2132
|
+
await this.storage.save(record);
|
|
2133
|
+
return record;
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* Get audit records for a player within a time range.
|
|
2137
|
+
*/
|
|
2138
|
+
async getPlayerRecords(playerId, startTime, endTime) {
|
|
2139
|
+
return this.storage.findByPlayer(playerId, startTime, endTime);
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Get a specific spin record by ID.
|
|
2143
|
+
*/
|
|
2144
|
+
async getSpinRecord(spinId) {
|
|
2145
|
+
return this.storage.findById(spinId);
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* Get player activity summary for a period.
|
|
2149
|
+
*/
|
|
2150
|
+
async getPlayerSummary(playerId, startTime, endTime) {
|
|
2151
|
+
return this.storage.getSummary(playerId, startTime, endTime);
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Export records for regulatory reporting.
|
|
2155
|
+
*/
|
|
2156
|
+
async exportRecords(playerId, startTime, endTime, format = "json") {
|
|
2157
|
+
const records = await this.getPlayerRecords(playerId, startTime, endTime);
|
|
2158
|
+
if (format === "json") {
|
|
2159
|
+
return JSON.stringify(records, null, 2);
|
|
2160
|
+
}
|
|
2161
|
+
const headers = [
|
|
2162
|
+
"spinId",
|
|
2163
|
+
"playerId",
|
|
2164
|
+
"timestamp",
|
|
2165
|
+
"stake",
|
|
2166
|
+
"win",
|
|
2167
|
+
"balanceBefore",
|
|
2168
|
+
"balanceAfter",
|
|
2169
|
+
"gameMode",
|
|
2170
|
+
"bonusTriggered"
|
|
2171
|
+
].join(",");
|
|
2172
|
+
const rows = records.map((r) => [
|
|
2173
|
+
r.spinId,
|
|
2174
|
+
r.playerId,
|
|
2175
|
+
r.timestamp,
|
|
2176
|
+
r.stake,
|
|
2177
|
+
r.win,
|
|
2178
|
+
r.balanceBefore,
|
|
2179
|
+
r.balanceAfter,
|
|
2180
|
+
r.gameMode,
|
|
2181
|
+
r.bonusTriggered
|
|
2182
|
+
].join(","));
|
|
2183
|
+
return [headers, ...rows].join("\n");
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Verify game integrity by checking RNG seed chain.
|
|
2187
|
+
*/
|
|
2188
|
+
async verifyRngChain(spinIds) {
|
|
2189
|
+
const errors = [];
|
|
2190
|
+
for (let i = 1; i < spinIds.length; i++) {
|
|
2191
|
+
const prevRecord = await this.getSpinRecord(spinIds[i - 1]);
|
|
2192
|
+
const currRecord = await this.getSpinRecord(spinIds[i]);
|
|
2193
|
+
if (!prevRecord || !currRecord) {
|
|
2194
|
+
errors.push(`Missing record for spin ${spinIds[i]}`);
|
|
2195
|
+
continue;
|
|
2196
|
+
}
|
|
2197
|
+
if (currRecord.timestamp <= prevRecord.timestamp) {
|
|
2198
|
+
errors.push(`Timestamp order violation: ${spinIds[i]} <= ${spinIds[i - 1]}`);
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
return {
|
|
2202
|
+
valid: errors.length === 0,
|
|
2203
|
+
errors
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
};
|
|
2207
|
+
|
|
2208
|
+
// src/DebugManager.ts
|
|
2209
|
+
var DebugManager = class {
|
|
2210
|
+
config;
|
|
2211
|
+
spinCount = 0;
|
|
2212
|
+
lastRngSeed = "";
|
|
2213
|
+
forcedOutcomesApplied = 0;
|
|
2214
|
+
logs = {
|
|
2215
|
+
errors: [],
|
|
2216
|
+
warnings: [],
|
|
2217
|
+
infoLogs: [],
|
|
2218
|
+
debugLogs: []
|
|
2219
|
+
};
|
|
2220
|
+
constructor(config) {
|
|
2221
|
+
this.config = {
|
|
2222
|
+
enabled: config?.enabled ?? false,
|
|
2223
|
+
logSpins: config?.logSpins ?? false,
|
|
2224
|
+
logRngSeeds: config?.logRngSeeds ?? false,
|
|
2225
|
+
forcedOutcomes: config?.forcedOutcomes ?? [],
|
|
2226
|
+
skipBalanceChecks: config?.skipBalanceChecks ?? false,
|
|
2227
|
+
verboseLevel: config?.verboseLevel ?? "warnings",
|
|
2228
|
+
outputFormat: config?.outputFormat ?? "console",
|
|
2229
|
+
logFilePath: config?.logFilePath
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Update debug configuration.
|
|
2234
|
+
*/
|
|
2235
|
+
configure(config) {
|
|
2236
|
+
this.config = { ...this.config, ...config };
|
|
2237
|
+
this.log("info", "config", "Debug configuration updated", { config });
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Check if debug mode is enabled.
|
|
2241
|
+
*/
|
|
2242
|
+
isEnabled() {
|
|
2243
|
+
return this.config.enabled;
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Get current debug state.
|
|
2247
|
+
*/
|
|
2248
|
+
getState() {
|
|
2249
|
+
return {
|
|
2250
|
+
enabled: this.config.enabled,
|
|
2251
|
+
spinCount: this.spinCount,
|
|
2252
|
+
lastRngSeed: this.lastRngSeed,
|
|
2253
|
+
forcedOutcomesApplied: this.forcedOutcomesApplied,
|
|
2254
|
+
errors: this.logs.errors,
|
|
2255
|
+
warnings: this.logs.warnings,
|
|
2256
|
+
infoLogs: this.logs.infoLogs
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Reset debug state and logs.
|
|
2261
|
+
*/
|
|
2262
|
+
reset() {
|
|
2263
|
+
this.spinCount = 0;
|
|
2264
|
+
this.lastRngSeed = "";
|
|
2265
|
+
this.forcedOutcomesApplied = 0;
|
|
2266
|
+
this.logs = {
|
|
2267
|
+
errors: [],
|
|
2268
|
+
warnings: [],
|
|
2269
|
+
infoLogs: [],
|
|
2270
|
+
debugLogs: []
|
|
2271
|
+
};
|
|
2272
|
+
this.log("info", "system", "Debug state reset");
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* Log a message at specified level.
|
|
2276
|
+
*/
|
|
2277
|
+
log(level, category, message, data) {
|
|
2278
|
+
if (!this.config.enabled) return;
|
|
2279
|
+
const shouldLog = this.shouldLogLevel(level);
|
|
2280
|
+
if (!shouldLog) return;
|
|
2281
|
+
const entry = {
|
|
2282
|
+
timestamp: Date.now(),
|
|
2283
|
+
level,
|
|
2284
|
+
category,
|
|
2285
|
+
message,
|
|
2286
|
+
data
|
|
2287
|
+
};
|
|
2288
|
+
switch (level) {
|
|
2289
|
+
case "error":
|
|
2290
|
+
this.logs.errors.push(entry);
|
|
2291
|
+
break;
|
|
2292
|
+
case "warning":
|
|
2293
|
+
this.logs.warnings.push(entry);
|
|
2294
|
+
break;
|
|
2295
|
+
case "info":
|
|
2296
|
+
this.logs.infoLogs.push(entry);
|
|
2297
|
+
break;
|
|
2298
|
+
case "debug":
|
|
2299
|
+
this.logs.debugLogs.push(entry);
|
|
2300
|
+
break;
|
|
2301
|
+
}
|
|
2302
|
+
this.outputLog(entry);
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Log a spin result.
|
|
2306
|
+
*/
|
|
2307
|
+
logSpin(spinId, result, rngSeed) {
|
|
2308
|
+
if (!this.config.enabled || !this.config.logSpins) return;
|
|
2309
|
+
this.spinCount++;
|
|
2310
|
+
this.lastRngSeed = rngSeed;
|
|
2311
|
+
this.log("debug", "spin", `Spin #${this.spinCount}`, {
|
|
2312
|
+
spinId,
|
|
2313
|
+
totalWin: result.totalWin,
|
|
2314
|
+
bonusTriggered: result.bonusTriggered,
|
|
2315
|
+
cascadeLevel: result.cascadeLevel,
|
|
2316
|
+
rngSeed
|
|
2317
|
+
});
|
|
2318
|
+
if (this.config.logRngSeeds) {
|
|
2319
|
+
this.log("debug", "rng", `RNG Seed for spin #${this.spinCount}`, { seed: rngSeed });
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
/**
|
|
2323
|
+
* Check and apply forced outcome if conditions match.
|
|
2324
|
+
*/
|
|
2325
|
+
checkForcedOutcome(params) {
|
|
2326
|
+
if (!this.config.enabled || !this.config.forcedOutcomes) return null;
|
|
2327
|
+
for (const forced of this.config.forcedOutcomes) {
|
|
2328
|
+
let matches = false;
|
|
2329
|
+
switch (forced.condition.type) {
|
|
2330
|
+
case "spinNumber":
|
|
2331
|
+
matches = forced.condition.value === params.spinNumber;
|
|
2332
|
+
break;
|
|
2333
|
+
case "randomSeed":
|
|
2334
|
+
matches = forced.condition.value === params.rngSeed;
|
|
2335
|
+
break;
|
|
2336
|
+
case "betAmount":
|
|
2337
|
+
matches = forced.condition.value === params.betAmount;
|
|
2338
|
+
break;
|
|
2339
|
+
case "playerId":
|
|
2340
|
+
matches = forced.condition.value === params.playerId;
|
|
2341
|
+
break;
|
|
2342
|
+
}
|
|
2343
|
+
if (matches && forced.result) {
|
|
2344
|
+
this.forcedOutcomesApplied++;
|
|
2345
|
+
this.log("warning", "forced", `Forced outcome applied for spin #${params.spinNumber}`, {
|
|
2346
|
+
condition: forced.condition,
|
|
2347
|
+
result: forced.result
|
|
2348
|
+
});
|
|
2349
|
+
return {
|
|
2350
|
+
result: forced.result.symbols || [],
|
|
2351
|
+
totalWin: forced.result.winAmount || 0,
|
|
2352
|
+
stakePerLine: 0,
|
|
2353
|
+
totalStake: 0,
|
|
2354
|
+
gameMode: 0,
|
|
2355
|
+
balanceAfter: 0,
|
|
2356
|
+
bonusTriggered: forced.result.bonusTriggered ?? false
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
return null;
|
|
2361
|
+
}
|
|
2362
|
+
/**
|
|
2363
|
+
* Check if balance checks should be skipped.
|
|
2364
|
+
*/
|
|
2365
|
+
shouldSkipBalanceCheck() {
|
|
2366
|
+
return this.config.enabled && this.config.skipBalanceChecks;
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Get all logs (optionally filtered by level).
|
|
2370
|
+
*/
|
|
2371
|
+
getLogs(level) {
|
|
2372
|
+
if (!level) {
|
|
2373
|
+
return [
|
|
2374
|
+
...this.logs.errors,
|
|
2375
|
+
...this.logs.warnings,
|
|
2376
|
+
...this.logs.infoLogs,
|
|
2377
|
+
...this.logs.debugLogs
|
|
2378
|
+
].sort((a, b) => a.timestamp - b.timestamp);
|
|
2379
|
+
}
|
|
2380
|
+
switch (level) {
|
|
2381
|
+
case "error":
|
|
2382
|
+
return this.logs.errors;
|
|
2383
|
+
case "warning":
|
|
2384
|
+
return this.logs.warnings;
|
|
2385
|
+
case "info":
|
|
2386
|
+
return this.logs.infoLogs;
|
|
2387
|
+
case "debug":
|
|
2388
|
+
return this.logs.debugLogs;
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Export logs as JSON.
|
|
2393
|
+
*/
|
|
2394
|
+
exportLogs() {
|
|
2395
|
+
return JSON.stringify(this.getLogs(), null, 2);
|
|
2396
|
+
}
|
|
2397
|
+
/**
|
|
2398
|
+
* Clear all logs.
|
|
2399
|
+
*/
|
|
2400
|
+
clearLogs() {
|
|
2401
|
+
this.logs = {
|
|
2402
|
+
errors: [],
|
|
2403
|
+
warnings: [],
|
|
2404
|
+
infoLogs: [],
|
|
2405
|
+
debugLogs: []
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
/**
|
|
2409
|
+
* Determine if a log level should be output based on verboseLevel config.
|
|
2410
|
+
*/
|
|
2411
|
+
shouldLogLevel(level) {
|
|
2412
|
+
const levels = ["none", "errors", "warnings", "info", "debug"];
|
|
2413
|
+
const currentIndex = levels.indexOf(this.config.verboseLevel);
|
|
2414
|
+
const levelIndex = levels.indexOf(level);
|
|
2415
|
+
return levelIndex <= currentIndex && levelIndex > 0;
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* Output log entry based on outputFormat config.
|
|
2419
|
+
*/
|
|
2420
|
+
outputLog(entry) {
|
|
2421
|
+
if (this.config.outputFormat === "console") {
|
|
2422
|
+
const prefix = `[${new Date(entry.timestamp).toISOString()}] [${entry.level.toUpperCase()}] ${entry.category}:`;
|
|
2423
|
+
switch (entry.level) {
|
|
2424
|
+
case "error":
|
|
2425
|
+
console.error(prefix, entry.message, entry.data || "");
|
|
2426
|
+
break;
|
|
2427
|
+
case "warning":
|
|
2428
|
+
console.warn(prefix, entry.message, entry.data || "");
|
|
2429
|
+
break;
|
|
2430
|
+
case "info":
|
|
2431
|
+
console.info(prefix, entry.message, entry.data || "");
|
|
2432
|
+
break;
|
|
2433
|
+
case "debug":
|
|
2434
|
+
console.log(prefix, entry.message, entry.data || "");
|
|
2435
|
+
break;
|
|
2436
|
+
}
|
|
2437
|
+
} else if (this.config.outputFormat === "json") {
|
|
2438
|
+
console.log(JSON.stringify(entry));
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* Get statistics about logged events.
|
|
2443
|
+
*/
|
|
2444
|
+
getStatistics() {
|
|
2445
|
+
return {
|
|
2446
|
+
totalLogs: this.logs.errors.length + this.logs.warnings.length + this.logs.infoLogs.length + this.logs.debugLogs.length,
|
|
2447
|
+
errorCount: this.logs.errors.length,
|
|
2448
|
+
warningCount: this.logs.warnings.length,
|
|
2449
|
+
infoCount: this.logs.infoLogs.length,
|
|
2450
|
+
debugCount: this.logs.debugLogs.length,
|
|
2451
|
+
spinsTracked: this.spinCount,
|
|
2452
|
+
forcedOutcomesApplied: this.forcedOutcomesApplied
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
};
|
|
2456
|
+
|
|
2457
|
+
// src/ResponsibleGamingManager.ts
|
|
2458
|
+
var ResponsibleGamingManager = class {
|
|
2459
|
+
limits;
|
|
2460
|
+
playerState;
|
|
2461
|
+
alerts = [];
|
|
2462
|
+
realityCheckCounter = 0;
|
|
2463
|
+
lastRealityCheckTime = 0;
|
|
2464
|
+
constructor(limits, playerState) {
|
|
2465
|
+
this.limits = limits;
|
|
2466
|
+
this.playerState = playerState;
|
|
2467
|
+
if (!playerState.sessionStartTime && limits.sessionTimeLimit) {
|
|
2468
|
+
playerState.sessionStartTime = Date.now();
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
/**
|
|
2472
|
+
* Update player state (call after each spin).
|
|
2473
|
+
*/
|
|
2474
|
+
updatePlayerState(state) {
|
|
2475
|
+
this.playerState = { ...this.playerState, ...state };
|
|
2476
|
+
this.checkAllLimits();
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Update responsible gaming limits (e.g., player changed their limits).
|
|
2480
|
+
*/
|
|
2481
|
+
updateLimits(newLimits) {
|
|
2482
|
+
this.limits = { ...this.limits, ...newLimits };
|
|
2483
|
+
this.validateLimits();
|
|
2484
|
+
this.checkAllLimits();
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* Check if player can place a bet of the given amount.
|
|
2488
|
+
*/
|
|
2489
|
+
canPlaceBet(betAmount) {
|
|
2490
|
+
const selfExclusion = this.getSelfExclusionStatus();
|
|
2491
|
+
if (selfExclusion.isExcluded) {
|
|
2492
|
+
return {
|
|
2493
|
+
success: false,
|
|
2494
|
+
error: `Player is self-excluded until ${selfExclusion.exclusionUntil?.toISOString()}`
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
if (this.limits.maxBetLimit && betAmount > this.limits.maxBetLimit) {
|
|
2498
|
+
return {
|
|
2499
|
+
success: false,
|
|
2500
|
+
error: `Bet amount ${betAmount} exceeds maximum allowed bet of ${this.limits.maxBetLimit}`
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
const lossInfo = this.getLossTrackingInfo();
|
|
2504
|
+
if (lossInfo.isLimitReached) {
|
|
2505
|
+
return {
|
|
2506
|
+
success: false,
|
|
2507
|
+
error: `Session loss limit of ${this.limits.lossLimit} has been reached`
|
|
2508
|
+
};
|
|
2509
|
+
}
|
|
2510
|
+
const timeInfo = this.getSessionTimeInfo();
|
|
2511
|
+
if (timeInfo.isLimitReached) {
|
|
2512
|
+
return {
|
|
2513
|
+
success: false,
|
|
2514
|
+
error: `Session time limit of ${this.limits.sessionTimeLimit} minutes has been reached`
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
return { success: true, data: true };
|
|
2518
|
+
}
|
|
2519
|
+
/**
|
|
2520
|
+
* Check if player can continue playing (general eligibility).
|
|
2521
|
+
*/
|
|
2522
|
+
canContinuePlaying() {
|
|
2523
|
+
return this.canPlaceBet(0);
|
|
2524
|
+
}
|
|
2525
|
+
/**
|
|
2526
|
+
* Get session time tracking information.
|
|
2527
|
+
*/
|
|
2528
|
+
getSessionTimeInfo() {
|
|
2529
|
+
const startTime = this.playerState.sessionStartTime || Date.now();
|
|
2530
|
+
const elapsedMs = Date.now() - startTime;
|
|
2531
|
+
const elapsedMinutes = Math.floor(elapsedMs / 6e4);
|
|
2532
|
+
const limitMinutes = this.limits.sessionTimeLimit;
|
|
2533
|
+
const isLimitReached = limitMinutes ? elapsedMinutes >= limitMinutes : false;
|
|
2534
|
+
const warningThreshold = limitMinutes ? limitMinutes * 0.8 : 0;
|
|
2535
|
+
const warningThresholdReached = limitMinutes ? elapsedMinutes >= warningThreshold : false;
|
|
2536
|
+
return {
|
|
2537
|
+
sessionStartTime: startTime,
|
|
2538
|
+
elapsedMinutes,
|
|
2539
|
+
limitMinutes,
|
|
2540
|
+
isLimitReached,
|
|
2541
|
+
warningThresholdReached
|
|
2542
|
+
};
|
|
2543
|
+
}
|
|
2544
|
+
/**
|
|
2545
|
+
* Get loss tracking information.
|
|
2546
|
+
*/
|
|
2547
|
+
getLossTrackingInfo() {
|
|
2548
|
+
const sessionLoss = this.playerState.sessionLoss || 0;
|
|
2549
|
+
const lossLimit = this.limits.lossLimit;
|
|
2550
|
+
const isLimitReached = lossLimit ? sessionLoss >= lossLimit : false;
|
|
2551
|
+
const warningThreshold = lossLimit ? lossLimit * 0.8 : 0;
|
|
2552
|
+
const warningThresholdReached = lossLimit ? sessionLoss >= warningThreshold : false;
|
|
2553
|
+
return {
|
|
2554
|
+
sessionLoss,
|
|
2555
|
+
lossLimit,
|
|
2556
|
+
isLimitReached,
|
|
2557
|
+
warningThresholdReached
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
/**
|
|
2561
|
+
* Get win tracking information.
|
|
2562
|
+
*/
|
|
2563
|
+
getWinTrackingInfo() {
|
|
2564
|
+
const sessionWin = this.playerState.sessionWin || 0;
|
|
2565
|
+
const winGoal = this.limits.winGoal;
|
|
2566
|
+
const isGoalReached = winGoal ? sessionWin >= winGoal : false;
|
|
2567
|
+
const notificationSent = isGoalReached && this.alerts.some(
|
|
2568
|
+
(a) => a.type === "win_goal" && !a.actionRequired
|
|
2569
|
+
);
|
|
2570
|
+
return {
|
|
2571
|
+
sessionWin,
|
|
2572
|
+
winGoal,
|
|
2573
|
+
isGoalReached,
|
|
2574
|
+
notificationSent
|
|
2575
|
+
};
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Get self-exclusion status.
|
|
2579
|
+
*/
|
|
2580
|
+
getSelfExclusionStatus() {
|
|
2581
|
+
const exclusionUntil = this.limits.selfExclusionUntil;
|
|
2582
|
+
if (!exclusionUntil) {
|
|
2583
|
+
return { isExcluded: false };
|
|
2584
|
+
}
|
|
2585
|
+
const exclusionDate = new Date(exclusionUntil);
|
|
2586
|
+
const now = /* @__PURE__ */ new Date();
|
|
2587
|
+
const isExcluded = now < exclusionDate;
|
|
2588
|
+
if (!isExcluded) {
|
|
2589
|
+
return { isExcluded: false };
|
|
2590
|
+
}
|
|
2591
|
+
const remainingDays = Math.ceil((exclusionDate.getTime() - now.getTime()) / (1e3 * 60 * 60 * 24));
|
|
2592
|
+
return {
|
|
2593
|
+
isExcluded: true,
|
|
2594
|
+
exclusionUntil: exclusionDate,
|
|
2595
|
+
remainingDays
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
/**
|
|
2599
|
+
* Check if reality check is due.
|
|
2600
|
+
*/
|
|
2601
|
+
shouldShowRealityCheck() {
|
|
2602
|
+
const interval = this.limits.realityCheckInterval;
|
|
2603
|
+
if (!interval || !this.playerState.sessionStartTime) {
|
|
2604
|
+
return false;
|
|
2605
|
+
}
|
|
2606
|
+
const elapsedMinutes = this.getSessionTimeInfo().elapsedMinutes;
|
|
2607
|
+
const checksDue = Math.floor(elapsedMinutes / interval);
|
|
2608
|
+
return checksDue > this.realityCheckCounter;
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Generate reality check event.
|
|
2612
|
+
*/
|
|
2613
|
+
generateRealityCheck() {
|
|
2614
|
+
if (!this.shouldShowRealityCheck()) {
|
|
2615
|
+
return null;
|
|
2616
|
+
}
|
|
2617
|
+
this.realityCheckCounter++;
|
|
2618
|
+
this.lastRealityCheckTime = Date.now();
|
|
2619
|
+
const elapsedMinutes = this.getSessionTimeInfo().elapsedMinutes;
|
|
2620
|
+
const totalSpins = this.playerState.history?.length || 0;
|
|
2621
|
+
const totalStaked = this.playerState.history?.reduce((sum, entry) => sum + entry.bet, 0) || 0;
|
|
2622
|
+
const totalWon = this.playerState.history?.reduce((sum, entry) => sum + entry.win, 0) || 0;
|
|
2623
|
+
const netResult = totalWon - totalStaked;
|
|
2624
|
+
const event = {
|
|
2625
|
+
timestamp: Date.now(),
|
|
2626
|
+
elapsedMinutes,
|
|
2627
|
+
totalSpins,
|
|
2628
|
+
totalStaked,
|
|
2629
|
+
totalWon,
|
|
2630
|
+
netResult,
|
|
2631
|
+
message: `Reality Check: You've been playing for ${elapsedMinutes} minutes. Total staked: ${totalStaked}, Total won: ${totalWon}, Net: ${netResult}`
|
|
2632
|
+
};
|
|
2633
|
+
this.addAlert({
|
|
2634
|
+
type: "reality_check",
|
|
2635
|
+
severity: "info",
|
|
2636
|
+
message: event.message,
|
|
2637
|
+
timestamp: event.timestamp
|
|
2638
|
+
});
|
|
2639
|
+
return event;
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* Check all limits and generate alerts.
|
|
2643
|
+
*/
|
|
2644
|
+
checkAllLimits() {
|
|
2645
|
+
const timeInfo = this.getSessionTimeInfo();
|
|
2646
|
+
if (timeInfo.isLimitReached) {
|
|
2647
|
+
this.addAlert({
|
|
2648
|
+
type: "session_time",
|
|
2649
|
+
severity: "critical",
|
|
2650
|
+
message: `Session time limit of ${this.limits.sessionTimeLimit} minutes reached. Play suspended.`,
|
|
2651
|
+
timestamp: Date.now(),
|
|
2652
|
+
actionRequired: true,
|
|
2653
|
+
suggestedAction: "Please take a break or end your session."
|
|
2654
|
+
});
|
|
2655
|
+
} else if (timeInfo.warningThresholdReached) {
|
|
2656
|
+
this.addAlert({
|
|
2657
|
+
type: "session_time",
|
|
2658
|
+
severity: "warning",
|
|
2659
|
+
message: `You've been playing for ${timeInfo.elapsedMinutes} minutes. Limit is ${this.limits.sessionTimeLimit} minutes.`,
|
|
2660
|
+
timestamp: Date.now(),
|
|
2661
|
+
actionRequired: false
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
const lossInfo = this.getLossTrackingInfo();
|
|
2665
|
+
if (lossInfo.isLimitReached) {
|
|
2666
|
+
this.addAlert({
|
|
2667
|
+
type: "loss_limit",
|
|
2668
|
+
severity: "critical",
|
|
2669
|
+
message: `Session loss limit of ${this.limits.lossLimit} reached. Play suspended.`,
|
|
2670
|
+
timestamp: Date.now(),
|
|
2671
|
+
actionRequired: true,
|
|
2672
|
+
suggestedAction: "Please end your session or wait until tomorrow."
|
|
2673
|
+
});
|
|
2674
|
+
} else if (lossInfo.warningThresholdReached) {
|
|
2675
|
+
this.addAlert({
|
|
2676
|
+
type: "loss_limit",
|
|
2677
|
+
severity: "warning",
|
|
2678
|
+
message: `Your session losses are ${lossInfo.sessionLoss}. Limit is ${this.limits.lossLimit}.`,
|
|
2679
|
+
timestamp: Date.now(),
|
|
2680
|
+
actionRequired: false
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
const winInfo = this.getWinTrackingInfo();
|
|
2684
|
+
if (winInfo.isGoalReached && !winInfo.notificationSent) {
|
|
2685
|
+
this.addAlert({
|
|
2686
|
+
type: "win_goal",
|
|
2687
|
+
severity: "info",
|
|
2688
|
+
message: `Congratulations! You've reached your win goal of ${this.limits.winGoal}.`,
|
|
2689
|
+
timestamp: Date.now(),
|
|
2690
|
+
actionRequired: false,
|
|
2691
|
+
suggestedAction: "Consider cashing out your winnings."
|
|
2692
|
+
});
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
/**
|
|
2696
|
+
* Add an alert to the list.
|
|
2697
|
+
*/
|
|
2698
|
+
addAlert(alert) {
|
|
2699
|
+
this.alerts.push(alert);
|
|
2700
|
+
if (this.alerts.length > 100) {
|
|
2701
|
+
this.alerts = this.alerts.slice(-100);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* Get all active alerts.
|
|
2706
|
+
*/
|
|
2707
|
+
getAlerts() {
|
|
2708
|
+
return [...this.alerts];
|
|
2709
|
+
}
|
|
2710
|
+
/**
|
|
2711
|
+
* Clear alerts (call after player acknowledges them).
|
|
2712
|
+
*/
|
|
2713
|
+
clearAlerts() {
|
|
2714
|
+
this.alerts = [];
|
|
2715
|
+
}
|
|
2716
|
+
/**
|
|
2717
|
+
* Get player activity statistics.
|
|
2718
|
+
*/
|
|
2719
|
+
getPlayerActivityStats() {
|
|
2720
|
+
const history = this.playerState.history || [];
|
|
2721
|
+
const totalStaked = history.reduce((sum, entry) => sum + entry.bet, 0);
|
|
2722
|
+
const totalWon = history.reduce((sum, entry) => sum + entry.win, 0);
|
|
2723
|
+
let totalTimePlayed = 0;
|
|
2724
|
+
if (this.playerState.sessionStartTime) {
|
|
2725
|
+
totalTimePlayed = this.getSessionTimeInfo().elapsedMinutes;
|
|
2726
|
+
}
|
|
2727
|
+
return {
|
|
2728
|
+
totalSessions: 1,
|
|
2729
|
+
// In production, track across multiple sessions
|
|
2730
|
+
totalTimePlayed,
|
|
2731
|
+
totalStaked,
|
|
2732
|
+
totalWon,
|
|
2733
|
+
netResult: totalWon - totalStaked,
|
|
2734
|
+
averageSessionDuration: totalTimePlayed,
|
|
2735
|
+
longestSession: totalTimePlayed,
|
|
2736
|
+
biggestLoss: Math.min(0, totalWon - totalStaked),
|
|
2737
|
+
biggestWin: Math.max(0, ...history.map((h) => h.win)),
|
|
2738
|
+
lastSessionDate: this.playerState.sessionStartTime
|
|
2739
|
+
};
|
|
2740
|
+
}
|
|
2741
|
+
/**
|
|
2742
|
+
* Validate limits configuration.
|
|
2743
|
+
*/
|
|
2744
|
+
validateLimits() {
|
|
2745
|
+
const errors = [];
|
|
2746
|
+
if (this.limits.sessionTimeLimit && this.limits.sessionTimeLimit <= 0) {
|
|
2747
|
+
errors.push("Session time limit must be positive");
|
|
2748
|
+
}
|
|
2749
|
+
if (this.limits.lossLimit && this.limits.lossLimit <= 0) {
|
|
2750
|
+
errors.push("Loss limit must be positive");
|
|
2751
|
+
}
|
|
2752
|
+
if (this.limits.winGoal && this.limits.winGoal <= 0) {
|
|
2753
|
+
errors.push("Win goal must be positive");
|
|
2754
|
+
}
|
|
2755
|
+
if (this.limits.maxBetLimit && this.limits.maxBetLimit <= 0) {
|
|
2756
|
+
errors.push("Max bet limit must be positive");
|
|
2757
|
+
}
|
|
2758
|
+
if (this.limits.realityCheckInterval && this.limits.realityCheckInterval <= 0) {
|
|
2759
|
+
errors.push("Reality check interval must be positive");
|
|
2760
|
+
}
|
|
2761
|
+
if (this.limits.selfExclusionUntil) {
|
|
2762
|
+
const exclusionDate = new Date(this.limits.selfExclusionUntil);
|
|
2763
|
+
if (isNaN(exclusionDate.getTime())) {
|
|
2764
|
+
errors.push("Invalid self-exclusion date format");
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
if (errors.length > 0) {
|
|
2768
|
+
throw new Error(`Invalid RG limits configuration: ${errors.join(", ")}`);
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
/**
|
|
2772
|
+
* Reset session tracking (call when player starts new session).
|
|
2773
|
+
*/
|
|
2774
|
+
resetSession() {
|
|
2775
|
+
this.playerState.sessionStartTime = Date.now();
|
|
2776
|
+
this.playerState.sessionLoss = 0;
|
|
2777
|
+
this.playerState.sessionWin = 0;
|
|
2778
|
+
this.realityCheckCounter = 0;
|
|
2779
|
+
this.clearAlerts();
|
|
2780
|
+
}
|
|
2781
|
+
/**
|
|
2782
|
+
* Apply a win/loss result to session tracking.
|
|
2783
|
+
*/
|
|
2784
|
+
applyResult(stake, win) {
|
|
2785
|
+
const netResult = win - stake;
|
|
2786
|
+
if (netResult < 0) {
|
|
2787
|
+
this.playerState.sessionLoss = (this.playerState.sessionLoss || 0) + Math.abs(netResult);
|
|
2788
|
+
}
|
|
2789
|
+
if (netResult > 0) {
|
|
2790
|
+
this.playerState.sessionWin = (this.playerState.sessionWin || 0) + netResult;
|
|
2791
|
+
}
|
|
2792
|
+
this.checkAllLimits();
|
|
2793
|
+
}
|
|
2794
|
+
/**
|
|
2795
|
+
* Export responsible gaming report for player download.
|
|
2796
|
+
*/
|
|
2797
|
+
exportRGReport() {
|
|
2798
|
+
const stats = this.getPlayerActivityStats();
|
|
2799
|
+
const selfExclusion = this.getSelfExclusionStatus();
|
|
2800
|
+
const report = {
|
|
2801
|
+
reportGenerated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2802
|
+
playerLimits: this.limits,
|
|
2803
|
+
selfExclusionStatus: selfExclusion,
|
|
2804
|
+
activityStatistics: stats,
|
|
2805
|
+
recentAlerts: this.alerts.slice(-20)
|
|
2806
|
+
};
|
|
2807
|
+
return JSON.stringify(report, null, 2);
|
|
2808
|
+
}
|
|
2809
|
+
};
|
|
2810
|
+
|
|
2811
|
+
// src/sampleData.ts
|
|
2812
|
+
var sampleConfig = {
|
|
2813
|
+
gameId: "fruit-slot",
|
|
2814
|
+
reelCount: 5,
|
|
2815
|
+
rowCount: 3,
|
|
2816
|
+
symbolMap: {
|
|
2817
|
+
0: "cherry",
|
|
2818
|
+
1: "lemon",
|
|
2819
|
+
2: "orange",
|
|
2820
|
+
3: "plum",
|
|
2821
|
+
4: "grape",
|
|
2822
|
+
5: "watermelon",
|
|
2823
|
+
6: "star",
|
|
2824
|
+
7: "bell",
|
|
2825
|
+
8: "seven",
|
|
2826
|
+
9: "diamond"
|
|
2827
|
+
},
|
|
2828
|
+
paytableData: {
|
|
2829
|
+
0: { x3: 10, x4: 20, x5: 50 },
|
|
2830
|
+
1: { x3: 5, x4: 15, x5: 30 },
|
|
2831
|
+
2: { x3: 4, x4: 10, x5: 25 },
|
|
2832
|
+
3: { x3: 3, x4: 8, x5: 20 },
|
|
2833
|
+
4: { x3: 2, x4: 5, x5: 15 },
|
|
2834
|
+
5: { x3: 1, x4: 3, x5: 10 },
|
|
2835
|
+
6: { x3: 0, x4: 2, x5: 8 },
|
|
2836
|
+
7: { x3: 0, x4: 1, x5: 5 },
|
|
2837
|
+
8: { x3: 0, x4: 0, x5: 2 },
|
|
2838
|
+
9: { x3: 0, x4: 0, x5: 1 }
|
|
2839
|
+
},
|
|
2840
|
+
reelStrips: [
|
|
2841
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
|
2842
|
+
[1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0],
|
|
2843
|
+
[2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1],
|
|
2844
|
+
[3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2],
|
|
2845
|
+
[4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3]
|
|
2846
|
+
],
|
|
2847
|
+
linePatterns: [
|
|
2848
|
+
[{ reel: 0, row: 1 }, { reel: 1, row: 1 }, { reel: 2, row: 1 }, { reel: 3, row: 1 }, { reel: 4, row: 1 }],
|
|
2849
|
+
[{ reel: 0, row: 0 }, { reel: 1, row: 0 }, { reel: 2, row: 0 }, { reel: 3, row: 0 }, { reel: 4, row: 0 }],
|
|
2850
|
+
[{ reel: 0, row: 2 }, { reel: 1, row: 2 }, { reel: 2, row: 2 }, { reel: 3, row: 2 }, { reel: 4, row: 2 }],
|
|
2851
|
+
[{ reel: 0, row: 0 }, { reel: 1, row: 1 }, { reel: 2, row: 2 }, { reel: 3, row: 1 }, { reel: 4, row: 0 }],
|
|
2852
|
+
[{ reel: 0, row: 2 }, { reel: 1, row: 1 }, { reel: 2, row: 0 }, { reel: 3, row: 1 }, { reel: 4, row: 2 }]
|
|
2853
|
+
],
|
|
2854
|
+
currency: {
|
|
2855
|
+
code: "USD",
|
|
2856
|
+
symbol: "$",
|
|
2857
|
+
decimalSeparator: ".",
|
|
2858
|
+
thousandSeparator: ",",
|
|
2859
|
+
minDecimalPlaces: 2
|
|
2860
|
+
},
|
|
2861
|
+
features: {
|
|
2862
|
+
autoplay: true,
|
|
2863
|
+
fastSpin: true,
|
|
2864
|
+
realityCheck: true,
|
|
2865
|
+
fullscreen: true
|
|
2866
|
+
},
|
|
2867
|
+
defaultBet: 1,
|
|
2868
|
+
minBet: 0.1,
|
|
2869
|
+
maxBet: 100,
|
|
2870
|
+
betStep: 0.1,
|
|
2871
|
+
defaultLines: 5,
|
|
2872
|
+
rtp: 96.5,
|
|
2873
|
+
volatility: "medium"
|
|
2874
|
+
};
|
|
2875
|
+
export {
|
|
2876
|
+
AuditManager,
|
|
2877
|
+
BonusManager,
|
|
2878
|
+
BuyBonusManager,
|
|
2879
|
+
CascadingReelsManager,
|
|
2880
|
+
ConfigBuilder,
|
|
2881
|
+
DebugManager,
|
|
2882
|
+
ExpandingWildsManager,
|
|
2883
|
+
GambleManager,
|
|
2884
|
+
GameSession,
|
|
2885
|
+
HistoryManager,
|
|
2886
|
+
InMemoryAuditStorage,
|
|
2887
|
+
InMemoryWalletStorage,
|
|
2888
|
+
JackpotManager,
|
|
2889
|
+
Random,
|
|
2890
|
+
RespinsManager,
|
|
2891
|
+
ResponsibleGamingManager,
|
|
2892
|
+
ScatterEnhancementsManager,
|
|
2893
|
+
SpinEvaluator,
|
|
2894
|
+
WalletManager,
|
|
2895
|
+
WaysEvaluator,
|
|
2896
|
+
sampleConfig
|
|
2897
|
+
};
|