@energy8platform/game-engine 0.29.0 → 0.31.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/dist/host.cjs.js +266 -63
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +52 -2
- package/dist/host.esm.js +266 -63
- package/dist/host.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/host/buildConfig.ts +3 -2
- package/src/host/createSlotGame.ts +274 -65
- package/src/host/runRound.ts +75 -14
- package/src/host/sceneController.ts +10 -2
- package/src/host/types.ts +24 -1
|
@@ -7,7 +7,8 @@ import { showFatalError, installGlobalErrorHandlers } from './fatalError';
|
|
|
7
7
|
import type { CreateSlotGameOptions, SlotGameHandle } from './types';
|
|
8
8
|
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
9
9
|
import type { ShellMode } from '@energy8platform/shell/pixi';
|
|
10
|
-
import type { SceneApi, SlotSceneController } from './sceneController';
|
|
10
|
+
import type { SceneApi, SlotSceneController, RenderContext } from './sceneController';
|
|
11
|
+
import type { FreeSpinsView } from './freeSpinsCounter';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
|
|
@@ -37,7 +38,18 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
37
38
|
availableClose: false,
|
|
38
39
|
title: shell.t('Something went wrong'),
|
|
39
40
|
body: shell.t(message),
|
|
40
|
-
actions: [
|
|
41
|
+
actions: [
|
|
42
|
+
{
|
|
43
|
+
title: shell.t('Reload'),
|
|
44
|
+
on: () => {
|
|
45
|
+
try {
|
|
46
|
+
location.reload();
|
|
47
|
+
} catch {
|
|
48
|
+
/* non-browser */
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
],
|
|
41
53
|
});
|
|
42
54
|
return;
|
|
43
55
|
}
|
|
@@ -61,7 +73,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
61
73
|
const launch = classifyStakeLaunch(location.href);
|
|
62
74
|
if (launch === 'blocked') {
|
|
63
75
|
fatal('Invalid game server address. Please relaunch the game from the lobby.');
|
|
64
|
-
throw new Error(
|
|
76
|
+
throw new Error(
|
|
77
|
+
'createSlotGame: refusing to run — Stake launch with a missing or invalid rgs_url',
|
|
78
|
+
);
|
|
65
79
|
}
|
|
66
80
|
isStakeNow = launch === 'stake';
|
|
67
81
|
if (isStakeNow) {
|
|
@@ -149,7 +163,8 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
149
163
|
// ACK the result AFTER the scene animates it (the scene calls host.ack()). On Stake this
|
|
150
164
|
// triggers /wallet/end-round so a winning round settles post-animation instead of staying
|
|
151
165
|
// open and blocking the next spin.
|
|
152
|
-
ack: (raw) =>
|
|
166
|
+
ack: (raw) =>
|
|
167
|
+
game.platformSession!.playAck(raw as import('@energy8platform/platform-core').PlayResultData),
|
|
153
168
|
});
|
|
154
169
|
|
|
155
170
|
if (opts.shell) {
|
|
@@ -187,7 +202,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
187
202
|
try {
|
|
188
203
|
const { lookupCurrency } = await import('@energy8platform/stake-bridge');
|
|
189
204
|
currencyMeta = lookupCurrency(opts.model.spec.currency);
|
|
190
|
-
} catch {
|
|
205
|
+
} catch {
|
|
206
|
+
/* stake-bridge not installed — resolveCurrency falls back to the code */
|
|
207
|
+
}
|
|
191
208
|
}
|
|
192
209
|
const runtime = {
|
|
193
210
|
balance,
|
|
@@ -209,14 +226,18 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
209
226
|
const cc = config?.currency as { code?: string; symbol?: string } | undefined;
|
|
210
227
|
console.info(
|
|
211
228
|
`[e8] currency → bridge.code=${cc?.code ?? '∅'} bridge.symbol=${cc?.symbol ?? '∅'} ` +
|
|
212
|
-
|
|
213
|
-
|
|
229
|
+
`| spec=${opts.model.spec.currency ?? '∅'} ` +
|
|
230
|
+
`| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`,
|
|
214
231
|
);
|
|
215
232
|
}
|
|
216
233
|
// pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
|
|
217
234
|
// scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
|
|
218
235
|
// The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
|
|
219
|
-
const pixiShellCfg: import('@energy8platform/shell/pixi').PixiShellConfig = {
|
|
236
|
+
const pixiShellCfg: import('@energy8platform/shell/pixi').PixiShellConfig = {
|
|
237
|
+
...buildShellConfig(opts.shell, opts.model, runtime),
|
|
238
|
+
app: game.app,
|
|
239
|
+
parent: game.uiLayer,
|
|
240
|
+
};
|
|
220
241
|
// The game may swap in its own shell (a custom renderer over the same core) via shellFactory;
|
|
221
242
|
// default is the built-in Pixi shell. The host drives whichever it gets through the Shell contract.
|
|
222
243
|
shell = (opts.shellFactory ?? createPixiShell)(pixiShellCfg);
|
|
@@ -229,21 +250,34 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
229
250
|
// async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
|
|
230
251
|
// is the single source for both the displayed balance and `ensureAffordable`.
|
|
231
252
|
const balanceGate = createBalanceGate((b) => shell!.setBalance(b), balance);
|
|
232
|
-
ps?.on('balanceUpdate', (d: { balance: number }) => {
|
|
253
|
+
ps?.on('balanceUpdate', (d: { balance: number }) => {
|
|
254
|
+
balanceGate.onBalance(d.balance);
|
|
255
|
+
});
|
|
233
256
|
|
|
234
257
|
// Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
|
|
235
258
|
let currentTurbo = shell.state.turbo;
|
|
236
|
-
shell.on('turboChange', (level: number) => {
|
|
259
|
+
shell.on('turboChange', (level: number) => {
|
|
260
|
+
currentTurbo = level;
|
|
261
|
+
gameScene()?.onTurboChanged?.(level);
|
|
262
|
+
});
|
|
237
263
|
// Double-tap-to-skip is a game-level option (default on), set once via createSlotGame({ skipGesture }).
|
|
238
264
|
const skipEnabled = opts.skipGesture ?? true;
|
|
239
265
|
|
|
240
266
|
// Shell settings → engine state. Sound/volume map onto the AudioManager.
|
|
241
267
|
shell.on('settingChange', ({ key, value }: { key: string; value: unknown }) => {
|
|
242
268
|
switch (key) {
|
|
243
|
-
case 'sound':
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
case '
|
|
269
|
+
case 'sound':
|
|
270
|
+
value ? game.audio.unmuteAll() : game.audio.muteAll();
|
|
271
|
+
break;
|
|
272
|
+
case 'master':
|
|
273
|
+
game.audio.setMasterVolume(Number(value));
|
|
274
|
+
break;
|
|
275
|
+
case 'music':
|
|
276
|
+
game.audio.setVolume('music', Number(value));
|
|
277
|
+
break;
|
|
278
|
+
case 'sfx':
|
|
279
|
+
game.audio.setVolume('sfx', Number(value));
|
|
280
|
+
break;
|
|
247
281
|
}
|
|
248
282
|
});
|
|
249
283
|
|
|
@@ -267,14 +301,30 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
267
301
|
sceneApi = {
|
|
268
302
|
audio: createSceneAudio(game.audio),
|
|
269
303
|
overlay: overlayCtl.overlay,
|
|
270
|
-
shell: {
|
|
304
|
+
shell: {
|
|
305
|
+
get safeArea() {
|
|
306
|
+
return shell!.safeArea;
|
|
307
|
+
},
|
|
308
|
+
},
|
|
271
309
|
formatAmount: (v) => shell!.formatWin(v),
|
|
272
|
-
get bet() {
|
|
273
|
-
|
|
274
|
-
|
|
310
|
+
get bet() {
|
|
311
|
+
return currentBet;
|
|
312
|
+
},
|
|
313
|
+
get mode() {
|
|
314
|
+
return opts.model.modeMap['spin'] ?? 'BASE';
|
|
315
|
+
},
|
|
316
|
+
get turbo() {
|
|
317
|
+
return currentTurbo;
|
|
318
|
+
},
|
|
275
319
|
};
|
|
276
320
|
|
|
277
321
|
const roleOf = (action: string) => opts.model.spec.actions[action]?.role;
|
|
322
|
+
const isBonusAction = (action: string) => roleOf(action) === 'free';
|
|
323
|
+
// Per-SEGMENT mode string. modeMap intentionally excludes `free` actions (they'd pollute the
|
|
324
|
+
// Game-Info modes table), so a free segment falls back to its spec `mode` (or the action key).
|
|
325
|
+
// This is what distinguishes nested bonuses (FREESPINS vs ADVENTURE) at the transition boundary.
|
|
326
|
+
const segmentModeOf = (action: string) =>
|
|
327
|
+
opts.model.spec.actions[action]?.mode ?? opts.model.modeMap[action] ?? action.toUpperCase();
|
|
278
328
|
// The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
|
|
279
329
|
// attaches its own. So makeContext returns everything BUT `signal`.
|
|
280
330
|
const makeContext = (
|
|
@@ -282,9 +332,11 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
282
332
|
): Omit<import('./sceneController').RenderContext, 'signal'> => ({
|
|
283
333
|
bet: currentBet,
|
|
284
334
|
action,
|
|
285
|
-
mode:
|
|
335
|
+
mode: segmentModeOf(action),
|
|
286
336
|
formatAmount: (v) => shell!.formatWin(v),
|
|
287
|
-
get turbo() {
|
|
337
|
+
get turbo() {
|
|
338
|
+
return currentTurbo;
|
|
339
|
+
},
|
|
288
340
|
});
|
|
289
341
|
// Play-error + connection handling. A play rejection is classified into a player-facing modal
|
|
290
342
|
// (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
|
|
@@ -300,12 +352,33 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
300
352
|
title: shell!.t(v.title),
|
|
301
353
|
body: shell!.t(v.body),
|
|
302
354
|
actions: v.reload
|
|
303
|
-
? [
|
|
304
|
-
|
|
355
|
+
? [
|
|
356
|
+
{
|
|
357
|
+
title: shell!.t('Reload'),
|
|
358
|
+
on: () => {
|
|
359
|
+
try {
|
|
360
|
+
window.location.reload();
|
|
361
|
+
} catch {
|
|
362
|
+
/* non-browser */
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
]
|
|
367
|
+
: [
|
|
368
|
+
{
|
|
369
|
+
title: shell!.t('OK'),
|
|
370
|
+
on: () => {
|
|
371
|
+
playErrorOpen = false;
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
],
|
|
305
375
|
});
|
|
306
376
|
};
|
|
307
377
|
ps?.on('connectionStateChanged', (s: { status: string }) => {
|
|
308
|
-
if (s.status === 'restored') {
|
|
378
|
+
if (s.status === 'restored') {
|
|
379
|
+
if (!playErrorOpen) shell!.closeModal();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
309
382
|
if (playErrorOpen) return; // a play-error modal owns the screen — don't mask it with "reconnecting"
|
|
310
383
|
shell!.openModal({
|
|
311
384
|
availableClose: false,
|
|
@@ -327,7 +400,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
327
400
|
const skip = createDoubleTapSkip({
|
|
328
401
|
enabled: () => skipEnabled,
|
|
329
402
|
active: () => presenting,
|
|
330
|
-
onSkip: () => {
|
|
403
|
+
onSkip: () => {
|
|
404
|
+
currentSegmentAbort?.abort();
|
|
405
|
+
gameScene()?.onSkip?.();
|
|
406
|
+
},
|
|
331
407
|
});
|
|
332
408
|
// Listen for taps on the scene root (game.worldRoot — the scaled scene container). The shell
|
|
333
409
|
// lives on the sibling uiLayer, so its bar taps never reach worldRoot — taps here are the play area.
|
|
@@ -346,9 +422,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
346
422
|
return () => document.removeEventListener('visibilitychange', cb);
|
|
347
423
|
},
|
|
348
424
|
onHidden: () => {
|
|
349
|
-
game.app.ticker.stop();
|
|
350
|
-
game.audio.duckMusic(0);
|
|
351
|
-
stopAutoplay();
|
|
425
|
+
game.app.ticker.stop(); // freezes tweens, onUpdate, in-flight onSpin animation
|
|
426
|
+
game.audio.duckMusic(0); // silence music (ducked to 0; restored on resume)
|
|
427
|
+
stopAutoplay(); // hold autoplay — don't start the next auto-round
|
|
352
428
|
gameScene()?.onPause?.();
|
|
353
429
|
},
|
|
354
430
|
onVisible: () => {
|
|
@@ -358,17 +434,108 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
358
434
|
},
|
|
359
435
|
});
|
|
360
436
|
|
|
437
|
+
/** Push a bonus segment's readout to the shell bar. Default (no `opts.bonus`) = the free-spins
|
|
438
|
+
* counter (label 'Free spins', value current/total). With `opts.bonus`, the game supplies the
|
|
439
|
+
* label + value string (adventure / hold-and-spin / respins) and we drive the generic 'bonus'
|
|
440
|
+
* hero instead — the shell stays free of any per-game bonus concept. */
|
|
441
|
+
const applyBonusReadout = (result: T, view: FreeSpinsView, mode: string): void => {
|
|
442
|
+
const b = opts.bonus;
|
|
443
|
+
if (!b) {
|
|
444
|
+
shell!.setFreeSpins(view);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const label = typeof b.label === 'function' ? b.label(mode) : (b.label ?? 'Free spins');
|
|
448
|
+
const value = b.readout
|
|
449
|
+
? b.readout(result, { view, mode })
|
|
450
|
+
: view.current == null
|
|
451
|
+
? String(view.total)
|
|
452
|
+
: `${view.current} / ${view.total}`;
|
|
453
|
+
shell!.setBonus({ label, value, totalWin: view.totalWin });
|
|
454
|
+
};
|
|
455
|
+
/** The mode entered via setMode for a bonus — 'bonus' when the game customises the readout,
|
|
456
|
+
* else 'freeSpins' (the back-compat default the shell already renders). */
|
|
457
|
+
const bonusShellMode: ShellMode = opts.bonus ? 'bonus' : 'freeSpins';
|
|
458
|
+
|
|
459
|
+
/** Apply an authoritative book override (freeSpins.total/remaining) onto an accumulated view —
|
|
460
|
+
* the host counts by default, but if the book resends the count (e.g. a resumed parent after a
|
|
461
|
+
* nested sub-bonus) that wins. */
|
|
462
|
+
const overrideView = (
|
|
463
|
+
view: FreeSpinsView,
|
|
464
|
+
fs?: SlotSpinResultBase['freeSpins'],
|
|
465
|
+
): FreeSpinsView => {
|
|
466
|
+
if (!fs) return view;
|
|
467
|
+
const total = fs.total ?? view.total;
|
|
468
|
+
const current = fs.remaining != null ? Math.max(0, total - fs.remaining) : view.current;
|
|
469
|
+
return { current, total, totalWin: view.totalWin };
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
interface BonusLevel {
|
|
473
|
+
mode: string;
|
|
474
|
+
counter: ReturnType<typeof createFreeSpinsCounter>;
|
|
475
|
+
view: FreeSpinsView;
|
|
476
|
+
}
|
|
477
|
+
/** A per-round stack of active bonus levels driving the shell bar as levels push/pop. Supports
|
|
478
|
+
* NESTED bonuses (e.g. free spins → adventure → free spins): each level keeps its own counter,
|
|
479
|
+
* so a resumed parent restores its remaining count. A single-bonus round pushes once and
|
|
480
|
+
* unwinds once — byte-identical to the pre-nesting behaviour. */
|
|
481
|
+
const createBonusStack = (scene: SlotSceneController<T>) => {
|
|
482
|
+
const stack: BonusLevel[] = [];
|
|
483
|
+
return {
|
|
484
|
+
inBonus: (): boolean => stack.length > 0,
|
|
485
|
+
/** A bonus level becomes active — a fresh push, or a resumed parent (already on the stack). */
|
|
486
|
+
async enter(mode: string, trigger: T, ctx: RenderContext, resumed: boolean): Promise<void> {
|
|
487
|
+
if (resumed) {
|
|
488
|
+
const lvl = stack[stack.length - 1]; // the parent stayed on the stack; the child popped
|
|
489
|
+
lvl.view = overrideView(lvl.view, trigger.freeSpins);
|
|
490
|
+
applyBonusReadout(trigger, lvl.view, lvl.mode);
|
|
491
|
+
} else {
|
|
492
|
+
const counter = createFreeSpinsCounter();
|
|
493
|
+
const view = overrideView(
|
|
494
|
+
counter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0),
|
|
495
|
+
trigger.freeSpins,
|
|
496
|
+
);
|
|
497
|
+
stack.push({ mode, counter, view });
|
|
498
|
+
if (stack.length === 1) shell!.setMode(bonusShellMode); // base → bonus
|
|
499
|
+
applyBonusReadout(trigger, view, mode);
|
|
500
|
+
}
|
|
501
|
+
// ctx.mode is overridden to the mode being ENTERED so the scene reads the right level.
|
|
502
|
+
await scene.onEnterMode?.(trigger, { ...ctx, mode, resumed });
|
|
503
|
+
},
|
|
504
|
+
/** A bonus level ends — a nested pop back to its parent, or the last level back to base. */
|
|
505
|
+
async exit(mode: string, last: T, ctx: RenderContext): Promise<void> {
|
|
506
|
+
stack.pop();
|
|
507
|
+
await scene.onExitMode?.(last, { ...ctx, mode });
|
|
508
|
+
if (stack.length === 0) {
|
|
509
|
+
shell!.setMode('base');
|
|
510
|
+
// Back in base the FS "Total win" block is gone, so WIN must carry the round's
|
|
511
|
+
// CUMULATIVE total (what got credited) — not the last segment's per-spin delta.
|
|
512
|
+
shell!.setWin(last.totalWin);
|
|
513
|
+
}
|
|
514
|
+
// else: an intermediate pop; the following resume-enter (or next exit) re-paints the bar.
|
|
515
|
+
},
|
|
516
|
+
/** Advance the active level's counter with a settled segment. */
|
|
517
|
+
settle(r: T): void {
|
|
518
|
+
if (stack.length === 0) return;
|
|
519
|
+
const top = stack[stack.length - 1];
|
|
520
|
+
top.view = overrideView(
|
|
521
|
+
top.counter.spin(r.freeSpins?.awarded ?? 0, r.totalWin),
|
|
522
|
+
r.freeSpins,
|
|
523
|
+
);
|
|
524
|
+
applyBonusReadout(r, top.view, top.mode);
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
};
|
|
528
|
+
|
|
361
529
|
/** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
|
|
362
530
|
* update only AFTER each onSpin(), per the HUD-timing requirement. */
|
|
363
531
|
const playRound = (action: string) => {
|
|
364
532
|
const scene = gameScene();
|
|
365
533
|
if (!scene) return;
|
|
366
|
-
// Per-round
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
|
|
534
|
+
// Per-round bonus stack: the shell enters bonus mode on the first level's push and shows the
|
|
535
|
+
// active level's counter + cumulative win. The stack handles NESTED bonuses (free spins →
|
|
536
|
+
// adventure → free spins); a single-bonus round pushes/unwinds once (unchanged).
|
|
537
|
+
const bonus = createBonusStack(scene);
|
|
370
538
|
let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
|
|
371
|
-
const fsCounter = createFreeSpinsCounter();
|
|
372
539
|
shell!.setBusy(true); // block re-spin / spacebar while the round plays out
|
|
373
540
|
presenting = true; // open the skip window for the whole play→drain
|
|
374
541
|
// RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
|
|
@@ -376,37 +543,39 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
376
543
|
return runRound<T>(
|
|
377
544
|
{
|
|
378
545
|
// Suppress the debit paint from play() until this segment's afterPresent (HUD timing).
|
|
379
|
-
play: (a, b, rid) => {
|
|
546
|
+
play: (a, b, rid) => {
|
|
547
|
+
balanceGate.beginPlay();
|
|
548
|
+
return slotPlay.play(a, b, rid);
|
|
549
|
+
},
|
|
380
550
|
ack: slotPlay.ack,
|
|
381
551
|
scene,
|
|
382
552
|
context: makeContext,
|
|
383
|
-
|
|
553
|
+
modeOf: segmentModeOf,
|
|
554
|
+
isBonusAction,
|
|
384
555
|
// Hand the host the per-segment AbortController so a double-tap can skip the live segment.
|
|
385
|
-
beforeSegment: (ac) => {
|
|
556
|
+
beforeSegment: (ac) => {
|
|
557
|
+
currentSegmentAbort = ac;
|
|
558
|
+
},
|
|
386
559
|
onSpinStart: () => scene.onSpinStart?.(),
|
|
387
560
|
onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
|
|
388
561
|
afterPresent: (r) => {
|
|
389
562
|
// WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
|
|
390
|
-
//
|
|
563
|
+
// bonus counter (totalWin) via settle(), not the WIN readout.
|
|
391
564
|
shell!.setWin(r.totalWin - prevWin);
|
|
392
565
|
prevWin = r.totalWin;
|
|
393
566
|
balanceGate.afterPresent();
|
|
394
|
-
|
|
395
|
-
},
|
|
396
|
-
onEnterMode: async (trigger, ctx) => {
|
|
397
|
-
inBonus = true;
|
|
398
|
-
shell!.setMode('freeSpins');
|
|
399
|
-
shell!.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
|
|
400
|
-
await scene.onEnterMode?.(trigger, ctx);
|
|
401
|
-
},
|
|
402
|
-
onExitMode: async (last, ctx) => {
|
|
403
|
-
inBonus = false;
|
|
404
|
-
await scene.onExitMode?.(last, ctx);
|
|
405
|
-
shell!.setMode('base');
|
|
567
|
+
bonus.settle(r);
|
|
406
568
|
},
|
|
569
|
+
onModeEnter: (mode, trigger, ctx, resumed) => bonus.enter(mode, trigger, ctx, resumed),
|
|
570
|
+
onModeExit: (mode, last, ctx) => bonus.exit(mode, last, ctx),
|
|
407
571
|
},
|
|
408
572
|
action,
|
|
409
|
-
)
|
|
573
|
+
)
|
|
574
|
+
.catch(showPlayError)
|
|
575
|
+
.finally(() => {
|
|
576
|
+
presenting = false;
|
|
577
|
+
shell!.setBusy(false);
|
|
578
|
+
});
|
|
410
579
|
};
|
|
411
580
|
|
|
412
581
|
/**
|
|
@@ -423,8 +592,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
423
592
|
const scene = gameScene();
|
|
424
593
|
if (!scene || !ps) return;
|
|
425
594
|
// A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
|
|
426
|
-
// never-aborted signal to satisfy onSpin's RenderContext.
|
|
427
|
-
|
|
595
|
+
// never-aborted signal to satisfy onSpin's RenderContext. ctx carries the round identity (built
|
|
596
|
+
// once from the trigger action) — recovery drains a single flat bonus using the bridge session
|
|
597
|
+
// counts; the full per-level nesting is a LIVE-play concern (playRound).
|
|
598
|
+
const ctx: RenderContext = {
|
|
428
599
|
...makeContext((firstRaw as { action?: string }).action ?? 'spin'),
|
|
429
600
|
signal: new AbortController().signal,
|
|
430
601
|
};
|
|
@@ -443,10 +614,16 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
443
614
|
let inBonus = false;
|
|
444
615
|
let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
|
|
445
616
|
const applySegment = async (): Promise<void> => {
|
|
446
|
-
// A recovered open round with remaining segments is a bonus → show
|
|
447
|
-
if (!inBonus && !r.complete) {
|
|
617
|
+
// A recovered open round with remaining segments is a bonus → show bonus mode + counter.
|
|
618
|
+
if (!inBonus && !r.complete) {
|
|
619
|
+
inBonus = true;
|
|
620
|
+
shell!.setMode(bonusShellMode);
|
|
621
|
+
}
|
|
448
622
|
if (animate) await scene.onSpin(r, ctx);
|
|
449
|
-
if (inBonus) {
|
|
623
|
+
if (inBonus) {
|
|
624
|
+
const v = fsView(raw, r.totalWin);
|
|
625
|
+
if (v) applyBonusReadout(r, v, ctx.mode);
|
|
626
|
+
}
|
|
450
627
|
shell!.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
|
|
451
628
|
prevWin = r.totalWin;
|
|
452
629
|
ps!.playAck(raw); // settles via /wallet/end-round on the FINAL segment
|
|
@@ -455,12 +632,20 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
455
632
|
try {
|
|
456
633
|
await applySegment();
|
|
457
634
|
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
458
|
-
raw = (await ps.play({
|
|
459
|
-
|
|
635
|
+
raw = (await ps.play({
|
|
636
|
+
action: r.nextActions[0],
|
|
637
|
+
bet: ctx.bet,
|
|
638
|
+
roundId: r.roundId,
|
|
639
|
+
})) as import('@energy8platform/platform-core').PlayResultData;
|
|
460
640
|
r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
461
641
|
await applySegment();
|
|
462
642
|
}
|
|
463
|
-
if (inBonus)
|
|
643
|
+
if (inBonus) {
|
|
644
|
+
shell!.setMode('base');
|
|
645
|
+
// Same as playRound: on return to base the WIN readout must show the round's cumulative
|
|
646
|
+
// total (r is the final drained segment), not the last segment's per-spin delta.
|
|
647
|
+
shell!.setWin(r.totalWin);
|
|
648
|
+
}
|
|
464
649
|
} finally {
|
|
465
650
|
shell!.setBusy(false);
|
|
466
651
|
}
|
|
@@ -468,13 +653,18 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
468
653
|
|
|
469
654
|
if (mode === 'base') {
|
|
470
655
|
let activeFeature: string | null = null;
|
|
471
|
-
shell.on('featureActivate', ({ id }: { id: string }) => {
|
|
472
|
-
|
|
656
|
+
shell.on('featureActivate', ({ id }: { id: string }) => {
|
|
657
|
+
activeFeature = id;
|
|
658
|
+
});
|
|
659
|
+
shell.on('featureDeactivate', ({ id: _id }: { id: string }) => {
|
|
660
|
+
activeFeature = null;
|
|
661
|
+
});
|
|
473
662
|
|
|
474
663
|
const { stakeForAction } = await import('./shellConfig');
|
|
475
664
|
// Guard a play: if the stake exceeds the balance, show a shell modal and DON'T play.
|
|
476
665
|
const ensureAffordable = (action: string): boolean => {
|
|
477
|
-
if (stakeForAction(opts.model, action, currentBet) <= balanceGate.balance + 1e-9)
|
|
666
|
+
if (stakeForAction(opts.model, action, currentBet) <= balanceGate.balance + 1e-9)
|
|
667
|
+
return true;
|
|
478
668
|
shell!.openModal({
|
|
479
669
|
availableClose: true,
|
|
480
670
|
title: shell!.t('Insufficient balance'),
|
|
@@ -489,7 +679,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
489
679
|
if (!ensureAffordable(action)) return;
|
|
490
680
|
void playRound(action);
|
|
491
681
|
});
|
|
492
|
-
shell.on('betChange', (bet: number) => {
|
|
682
|
+
shell.on('betChange', (bet: number) => {
|
|
683
|
+
currentBet = bet;
|
|
684
|
+
gameScene()?.onBetChanged?.(bet);
|
|
685
|
+
});
|
|
493
686
|
shell.on('buyBonusSelect', ({ id }: { id: string }) => {
|
|
494
687
|
if (!ensureAffordable(id)) return;
|
|
495
688
|
void playRound(id);
|
|
@@ -519,7 +712,11 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
519
712
|
if (resumeOffered || !shell || !gameScene()) return;
|
|
520
713
|
resumeOffered = true;
|
|
521
714
|
let snap: import('@energy8platform/platform-core').PlayResultData | null = null;
|
|
522
|
-
try {
|
|
715
|
+
try {
|
|
716
|
+
snap = (await ps?.getState()) ?? null;
|
|
717
|
+
} catch {
|
|
718
|
+
snap = null;
|
|
719
|
+
}
|
|
523
720
|
if (!snap) return;
|
|
524
721
|
shell.openModal({
|
|
525
722
|
availableClose: false,
|
|
@@ -527,13 +724,25 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
527
724
|
body: shell.t('You have an unfinished round. Continue it or finish it now?'),
|
|
528
725
|
actions: [
|
|
529
726
|
// Continue: replay the round from the start with animation, then settle.
|
|
530
|
-
{
|
|
727
|
+
{
|
|
728
|
+
title: shell.t('Continue'),
|
|
729
|
+
on: () => {
|
|
730
|
+
void resumeDrain(snap!, true);
|
|
731
|
+
},
|
|
732
|
+
},
|
|
531
733
|
// Finish: fast-forward the remaining segments (no animation) to settle the win now.
|
|
532
|
-
{
|
|
734
|
+
{
|
|
735
|
+
title: shell.t('Finish'),
|
|
736
|
+
on: () => {
|
|
737
|
+
void resumeDrain(snap!, false);
|
|
738
|
+
},
|
|
739
|
+
},
|
|
533
740
|
],
|
|
534
741
|
});
|
|
535
742
|
};
|
|
536
|
-
game.scenes.on('change', () => {
|
|
743
|
+
game.scenes.on('change', () => {
|
|
744
|
+
void offerResume();
|
|
745
|
+
});
|
|
537
746
|
void offerResume();
|
|
538
747
|
} else {
|
|
539
748
|
const stakeMode = stakeBridge?.replayMode ?? 'BASE';
|
package/src/host/runRound.ts
CHANGED
|
@@ -5,22 +5,63 @@ export interface RunRoundDeps<T extends SlotSpinResultBase> {
|
|
|
5
5
|
play(action: string, bet: number, roundId?: string): Promise<T>;
|
|
6
6
|
ack(): void;
|
|
7
7
|
scene: Pick<SlotSceneController<T>, 'onSpin'>;
|
|
8
|
-
/** Build the
|
|
8
|
+
/** Build the render context (without signal — runRound injects it per segment). Called with the
|
|
9
|
+
* ROUND action, so `ctx.action`/`ctx.mode` carry the round identity across every drained segment
|
|
10
|
+
* (a buy_bonus round's free spins still read as BONUS). Nested-bonus detection uses `modeOf`, not
|
|
11
|
+
* `ctx.mode`, and the scene learns level changes via onModeEnter/onModeExit. */
|
|
9
12
|
context(action: string): Omit<RenderContext, 'signal'> & { signal?: AbortSignal };
|
|
10
|
-
|
|
13
|
+
/** Bonus mode string for a segment action (FREESPINS / ADVENTURE / …) — drives nested-bonus
|
|
14
|
+
* transition detection. Defaults to `action.toUpperCase()`. */
|
|
15
|
+
modeOf?(action: string): string;
|
|
16
|
+
/** True when an action is a bonus (free-play) segment, false for base/trigger segments.
|
|
17
|
+
* Omit for base-only games (no bonus transitions ever fire). */
|
|
18
|
+
isBonusAction?(action: string): boolean;
|
|
11
19
|
afterPresent?(result: T): void;
|
|
12
20
|
/** Once, before the first segment is played (player pressed spin). */
|
|
13
21
|
onSpinStart?(): void;
|
|
14
22
|
/** Once, after the full drain. */
|
|
15
23
|
onSpinEnd?(last: T, ctx: RenderContext): void;
|
|
16
|
-
/** Fires when
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
/** Fires when a bonus LEVEL becomes active — a fresh push (`resumed=false`) or a return to a
|
|
25
|
+
* suspended parent after a nested sub-bonus popped (`resumed=true`). Fires per boundary, so a
|
|
26
|
+
* round may enter several levels. `trigger`/`ctx` are the segment that caused the transition. */
|
|
27
|
+
onModeEnter?(mode: string, trigger: T, ctx: RenderContext, resumed: boolean): Promise<void>;
|
|
28
|
+
/** Fires when a bonus LEVEL ends (pop) — either descending past it into base, or unwinding at
|
|
29
|
+
* round end. Fires once per popped level, top-first. */
|
|
30
|
+
onModeExit?(mode: string, last: T, ctx: RenderContext): Promise<void>;
|
|
20
31
|
/** Hands the host the AbortController for the segment about to present (for skip). */
|
|
21
32
|
beforeSegment?(ac: AbortController): void;
|
|
22
33
|
}
|
|
23
34
|
|
|
35
|
+
/** A planned move of the mode stack toward a target (or to base when `target` is null). Pure. */
|
|
36
|
+
export interface TransitionPlan {
|
|
37
|
+
/** Modes to exit, top-first (each is popped). */
|
|
38
|
+
exit: string[];
|
|
39
|
+
/** The level that becomes active after the exits, or null when none (same level, or unwind to
|
|
40
|
+
* base). `resumed` distinguishes returning to an existing parent from a fresh push. */
|
|
41
|
+
enter: { mode: string; resumed: boolean } | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pure: given the CURRENT bonus-mode stack (bottom→top) and the next segment's target mode
|
|
46
|
+
* (null = a base segment / round end), decide which levels to exit and whether a level enters.
|
|
47
|
+
*
|
|
48
|
+
* - target null → unwind everything (exit all, top-first).
|
|
49
|
+
* - target === top → same level, no transition.
|
|
50
|
+
* - target already deeper → RESUME: exit the levels above it; it re-activates (resumed).
|
|
51
|
+
* - target not on the stack → PUSH: a fresh level enters.
|
|
52
|
+
*
|
|
53
|
+
* The caller owns the actual stack array and applies the plan (pop per `exit`, push when
|
|
54
|
+
* `enter && !resumed`).
|
|
55
|
+
*/
|
|
56
|
+
export function planTransition(stack: readonly string[], target: string | null): TransitionPlan {
|
|
57
|
+
if (target == null) return { exit: [...stack].reverse(), enter: null };
|
|
58
|
+
if (stack.length > 0 && stack[stack.length - 1] === target) return { exit: [], enter: null };
|
|
59
|
+
const depth = stack.lastIndexOf(target);
|
|
60
|
+
if (depth >= 0)
|
|
61
|
+
return { exit: stack.slice(depth + 1).reverse(), enter: { mode: target, resumed: true } };
|
|
62
|
+
return { exit: [], enter: { mode: target, resumed: false } };
|
|
63
|
+
}
|
|
64
|
+
|
|
24
65
|
export async function runRound<T extends SlotSpinResultBase>(
|
|
25
66
|
deps: RunRoundDeps<T>,
|
|
26
67
|
action: string,
|
|
@@ -28,28 +69,48 @@ export async function runRound<T extends SlotSpinResultBase>(
|
|
|
28
69
|
deps.onSpinStart?.();
|
|
29
70
|
|
|
30
71
|
const ctxBet = deps.context(action).bet;
|
|
72
|
+
const modeOf = deps.modeOf ?? ((a: string) => a.toUpperCase());
|
|
73
|
+
const isBonus = deps.isBonusAction ?? (() => false);
|
|
31
74
|
|
|
32
|
-
const segment = async (
|
|
75
|
+
const segment = async (
|
|
76
|
+
a: string,
|
|
77
|
+
roundId: string | undefined,
|
|
78
|
+
): Promise<{ r: T; ctx: RenderContext }> => {
|
|
33
79
|
const ac = new AbortController();
|
|
34
80
|
deps.beforeSegment?.(ac);
|
|
35
81
|
const r = await deps.play(a, ctxBet, roundId);
|
|
82
|
+
// ctx carries the ROUND identity (built from the round action), stable across drained segments.
|
|
36
83
|
const ctx = { ...deps.context(action), signal: ac.signal } as RenderContext;
|
|
37
84
|
await deps.scene.onSpin(r, ctx);
|
|
38
85
|
deps.ack();
|
|
39
86
|
deps.afterPresent?.(r);
|
|
40
87
|
return { r, ctx };
|
|
41
88
|
};
|
|
42
|
-
let { r, ctx } = await segment(action, undefined);
|
|
43
89
|
|
|
44
|
-
|
|
90
|
+
// Active bonus-mode levels (bottom→top). Empty in the base game.
|
|
91
|
+
const stack: string[] = [];
|
|
92
|
+
// Emit the exits/enter to move the stack toward `nextAction` (null → unwind to base). Called
|
|
93
|
+
// BEFORE the target segment presents, with the CURRENT (triggering) r/ctx — mirrors the classic
|
|
94
|
+
// onEnterMode(trigger) timing so the host reads awarded spins off the segment that granted them.
|
|
95
|
+
const transition = async (nextAction: string | null, r: T, ctx: RenderContext): Promise<void> => {
|
|
96
|
+
const target = nextAction != null && isBonus(nextAction) ? modeOf(nextAction) : null;
|
|
97
|
+
const plan = planTransition(stack, target);
|
|
98
|
+
for (const mode of plan.exit) {
|
|
99
|
+
stack.pop();
|
|
100
|
+
await deps.onModeExit?.(mode, r, ctx);
|
|
101
|
+
}
|
|
102
|
+
if (plan.enter) {
|
|
103
|
+
if (!plan.enter.resumed) stack.push(plan.enter.mode);
|
|
104
|
+
await deps.onModeEnter?.(plan.enter.mode, r, ctx, plan.enter.resumed);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
let { r, ctx } = await segment(action, undefined);
|
|
45
109
|
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
46
110
|
const next = r.nextActions[0];
|
|
47
|
-
|
|
48
|
-
inMode = true;
|
|
49
|
-
await deps.onEnterMode?.(r, ctx);
|
|
50
|
-
}
|
|
111
|
+
await transition(next, r, ctx);
|
|
51
112
|
({ r, ctx } = await segment(next, r.roundId));
|
|
52
113
|
}
|
|
53
|
-
|
|
114
|
+
await transition(null, r, ctx); // round end: unwind any remaining levels to base
|
|
54
115
|
deps.onSpinEnd?.(r, ctx);
|
|
55
116
|
}
|