@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.
@@ -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: [{ title: shell.t('Reload'), on: () => { try { location.reload(); } catch { /* non-browser */ } } }],
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('createSlotGame: refusing to run — Stake launch with a missing or invalid rgs_url');
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) => game.platformSession!.playAck(raw as import('@energy8platform/platform-core').PlayResultData),
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 { /* stake-bridge not installed — resolveCurrency falls back to the code */ }
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
- `| spec=${opts.model.spec.currency ?? '∅'} ` +
213
- `| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`,
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 = { ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer };
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 }) => { balanceGate.onBalance(d.balance); });
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) => { currentTurbo = level; gameScene()?.onTurboChanged?.(level); });
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': value ? game.audio.unmuteAll() : game.audio.muteAll(); break;
244
- case 'master': game.audio.setMasterVolume(Number(value)); break;
245
- case 'music': game.audio.setVolume('music', Number(value)); break;
246
- case 'sfx': game.audio.setVolume('sfx', Number(value)); break;
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: { get safeArea() { return shell!.safeArea; } },
304
+ shell: {
305
+ get safeArea() {
306
+ return shell!.safeArea;
307
+ },
308
+ },
271
309
  formatAmount: (v) => shell!.formatWin(v),
272
- get bet() { return currentBet; },
273
- get mode() { return opts.model.modeMap['spin'] ?? 'BASE'; },
274
- get turbo() { return currentTurbo; },
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: opts.model.modeMap[action] ?? action.toUpperCase(),
335
+ mode: segmentModeOf(action),
286
336
  formatAmount: (v) => shell!.formatWin(v),
287
- get turbo() { return currentTurbo; },
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
- ? [{ title: shell!.t('Reload'), on: () => { try { window.location.reload(); } catch { /* non-browser */ } } }]
304
- : [{ title: shell!.t('OK'), on: () => { playErrorOpen = false; } }],
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') { if (!playErrorOpen) shell!.closeModal(); return; }
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: () => { currentSegmentAbort?.abort(); gameScene()?.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(); // freezes tweens, onUpdate, in-flight onSpin animation
350
- game.audio.duckMusic(0); // silence music (ducked to 0; restored on resume)
351
- stopAutoplay(); // hold autoplay — don't start the next auto-round
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 free-spins state: the shell enters FS mode on bonus-enter and shows current/total
367
- // (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
368
- // the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
369
- let inBonus = false;
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) => { balanceGate.beginPlay(); return slotPlay.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
- roleOf,
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) => { currentSegmentAbort = 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
- // free-spins counter (totalWin) below, not the WIN readout.
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
- if (inBonus) shell!.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
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
- ).catch(showPlayError).finally(() => { presenting = false; shell!.setBusy(false); });
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
- const ctx: import('./sceneController').RenderContext = {
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 FS mode + counter.
447
- if (!inBonus && !r.complete) { inBonus = true; shell!.setMode('freeSpins'); }
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) { const v = fsView(raw, r.totalWin); if (v) shell!.setFreeSpins(v); }
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({ action: r.nextActions[0], bet: ctx.bet, roundId: r.roundId })) as
459
- import('@energy8platform/platform-core').PlayResultData;
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) shell!.setMode('base');
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 }) => { activeFeature = id; });
472
- shell.on('featureDeactivate', ({ id: _id }: { id: string }) => { activeFeature = null; });
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) return true;
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) => { currentBet = bet; gameScene()?.onBetChanged?.(bet); });
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 { snap = await ps?.getState() ?? null; } catch { snap = null; }
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
- { title: shell.t('Continue'), on: () => { void resumeDrain(snap!, true); } },
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
- { title: shell.t('Finish'), on: () => { void resumeDrain(snap!, false); } },
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', () => { void offerResume(); });
743
+ game.scenes.on('change', () => {
744
+ void offerResume();
745
+ });
537
746
  void offerResume();
538
747
  } else {
539
748
  const stakeMode = stakeBridge?.replayMode ?? 'BASE';
@@ -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 per-round render context (without signal — runRound injects it per segment). */
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
- roleOf(action: string): string | undefined;
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 entering a non-BASE mode (first free segment). */
17
- onEnterMode?(trigger: T, ctx: RenderContext): Promise<void>;
18
- /** Fires after the last segment of a mode. */
19
- onExitMode?(last: T, ctx: RenderContext): Promise<void>;
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 (a: string, roundId: string | undefined): Promise<{ r: T; ctx: RenderContext }> => {
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
- let inMode = false;
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
- if (!inMode && deps.roleOf(next) === 'free') {
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
- if (inMode) await deps.onExitMode?.(r, ctx);
114
+ await transition(null, r, ctx); // round end: unwind any remaining levels to base
54
115
  deps.onSpinEnd?.(r, ctx);
55
116
  }