@energy8platform/game-engine 0.30.0 → 0.32.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.
@@ -241,9 +241,10 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
241
241
 
242
242
  const pixiOpts = {
243
243
  preference: 'webgl' as const,
244
- background: typeof this.config.loading?.backgroundColor === 'number'
245
- ? this.config.loading.backgroundColor
246
- : 0x000000,
244
+ background:
245
+ typeof this.config.loading?.backgroundColor === 'number'
246
+ ? this.config.loading.backgroundColor
247
+ : 0x000000,
247
248
  antialias: true,
248
249
  resolution: Math.min(window.devicePixelRatio, 2),
249
250
  autoDensity: true,
@@ -286,8 +287,9 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
286
287
  }
287
288
 
288
289
  private initSubSystems(): void {
289
- // Asset Manager
290
- const basePath = this.initData?.assetsUrl ?? '';
290
+ // Asset Manager. Base defaults to '/' (site root) when the bridge sends no assetsUrl — the
291
+ // asset folder lives in the manifest paths, not the base, so it needn't be named `assets`.
292
+ const basePath = this.initData?.assetsUrl ?? '/';
291
293
  this.assets = new AssetManager(basePath, this.config.manifest);
292
294
 
293
295
  // Audio Manager
@@ -7,7 +7,7 @@ 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
11
  import type { FreeSpinsView } from './freeSpinsCounter';
12
12
 
13
13
  /**
@@ -319,6 +319,12 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
319
319
  };
320
320
 
321
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();
322
328
  // The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
323
329
  // attaches its own. So makeContext returns everything BUT `signal`.
324
330
  const makeContext = (
@@ -326,7 +332,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
326
332
  ): Omit<import('./sceneController').RenderContext, 'signal'> => ({
327
333
  bet: currentBet,
328
334
  action,
329
- mode: opts.model.modeMap[action] ?? action.toUpperCase(),
335
+ mode: segmentModeOf(action),
330
336
  formatAmount: (v) => shell!.formatWin(v),
331
337
  get turbo() {
332
338
  return currentTurbo;
@@ -450,18 +456,86 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
450
456
  * else 'freeSpins' (the back-compat default the shell already renders). */
451
457
  const bonusShellMode: ShellMode = opts.bonus ? 'bonus' : 'freeSpins';
452
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
+
453
529
  /** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
454
530
  * update only AFTER each onSpin(), per the HUD-timing requirement. */
455
531
  const playRound = (action: string) => {
456
532
  const scene = gameScene();
457
533
  if (!scene) return;
458
- // Per-round free-spins state: the shell enters FS mode on bonus-enter and shows current/total
459
- // (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
460
- // the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
461
- let inBonus = false;
462
- let bonusMode = ''; // the mode string captured on bonus-enter, reused for each settled spin
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);
463
538
  let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
464
- const fsCounter = createFreeSpinsCounter();
465
539
  shell!.setBusy(true); // block re-spin / spacebar while the round plays out
466
540
  presenting = true; // open the skip window for the whole play→drain
467
541
  // RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
@@ -476,7 +550,8 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
476
550
  ack: slotPlay.ack,
477
551
  scene,
478
552
  context: makeContext,
479
- roleOf,
553
+ modeOf: segmentModeOf,
554
+ isBonusAction,
480
555
  // Hand the host the per-segment AbortController so a double-tap can skip the live segment.
481
556
  beforeSegment: (ac) => {
482
557
  currentSegmentAbort = ac;
@@ -485,33 +560,14 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
485
560
  onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
486
561
  afterPresent: (r) => {
487
562
  // WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
488
- // free-spins counter (totalWin) below, not the WIN readout.
563
+ // bonus counter (totalWin) via settle(), not the WIN readout.
489
564
  shell!.setWin(r.totalWin - prevWin);
490
565
  prevWin = r.totalWin;
491
566
  balanceGate.afterPresent();
492
- if (inBonus)
493
- applyBonusReadout(
494
- r,
495
- fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin),
496
- bonusMode,
497
- );
498
- },
499
- onEnterMode: async (trigger, ctx) => {
500
- inBonus = true;
501
- bonusMode = ctx.mode;
502
- shell!.setMode(bonusShellMode);
503
- applyBonusReadout(
504
- trigger,
505
- fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0),
506
- bonusMode,
507
- );
508
- await scene.onEnterMode?.(trigger, ctx);
509
- },
510
- onExitMode: async (last, ctx) => {
511
- inBonus = false;
512
- await scene.onExitMode?.(last, ctx);
513
- shell!.setMode('base');
567
+ bonus.settle(r);
514
568
  },
569
+ onModeEnter: (mode, trigger, ctx, resumed) => bonus.enter(mode, trigger, ctx, resumed),
570
+ onModeExit: (mode, last, ctx) => bonus.exit(mode, last, ctx),
515
571
  },
516
572
  action,
517
573
  )
@@ -536,8 +592,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
536
592
  const scene = gameScene();
537
593
  if (!scene || !ps) return;
538
594
  // A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
539
- // never-aborted signal to satisfy onSpin's RenderContext.
540
- 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 = {
541
599
  ...makeContext((firstRaw as { action?: string }).action ?? 'spin'),
542
600
  signal: new AbortController().signal,
543
601
  };
@@ -556,7 +614,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
556
614
  let inBonus = false;
557
615
  let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
558
616
  const applySegment = async (): Promise<void> => {
559
- // A recovered open round with remaining segments is a bonus → show FS mode + counter.
617
+ // A recovered open round with remaining segments is a bonus → show bonus mode + counter.
560
618
  if (!inBonus && !r.complete) {
561
619
  inBonus = true;
562
620
  shell!.setMode(bonusShellMode);
@@ -582,7 +640,12 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
582
640
  r = enrichRoundMeta(opts.normalize(raw), raw);
583
641
  await applySegment();
584
642
  }
585
- 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
+ }
586
649
  } finally {
587
650
  shell!.setBusy(false);
588
651
  }
@@ -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
  }
@@ -13,6 +13,10 @@ export interface RenderContext {
13
13
  formatAmount(value: number): string;
14
14
  /** LIVE turbo level (0 = off, 1..3 = escalating speed). Read at access. */
15
15
  readonly turbo: number;
16
+ /** Only meaningful in `onEnterMode`: true when RETURNING to a suspended parent bonus after a
17
+ * nested sub-bonus finished (e.g. back to free spins after an adventure), false on a fresh
18
+ * entry. Lets a scene restore vs rebuild. Undefined outside `onEnterMode`. */
19
+ resumed?: boolean;
16
20
  /** Aborted when the player skips this segment (double-tap). The scene's async pacing can race or
17
21
  * cancel on it; on abort the scene must collapse to the segment's final visual state. */
18
22
  signal: AbortSignal;
@@ -82,9 +86,13 @@ export interface SlotSceneController<T extends SlotSpinResultBase = SlotSpinResu
82
86
  onSpinStart(): void;
83
87
  /** Render ONE segment (a spin or one free spin). Await your own pacing. */
84
88
  onSpin(result: T, ctx: RenderContext): Promise<void>;
85
- /** Fires when ctx.mode changes between segments (entering a non-BASE mode/bonus). */
89
+ /** Fires when a bonus LEVEL begins. With nested bonuses this fires once per level (free spins,
90
+ * then adventure, …) — check `ctx.mode` for which. `ctx.resumed` is true when returning to a
91
+ * suspended parent after a nested sub-bonus finished, so a scene can restore instead of rebuild.
92
+ * A single-bonus round fires it exactly once (as before). */
86
93
  onEnterMode(result: T, ctx: RenderContext): Promise<void>;
87
- /** Fires when leaving a mode (back toward BASE). */
94
+ /** Fires when a bonus LEVEL ends — popping a nested sub-bonus back to its parent, or unwinding
95
+ * the last level back to BASE. `ctx.mode` is the level being left. Fires once per level. */
88
96
  onExitMode(result: T, ctx: RenderContext): Promise<void>;
89
97
  /** Fires once per round after the full drain (controls unlocked). */
90
98
  onSpinEnd(result: T, ctx: RenderContext): void;