@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 CHANGED
@@ -2270,10 +2270,19 @@ async function createSlotGame(opts) {
2270
2270
  availableClose: false,
2271
2271
  title: shell.t('Something went wrong'),
2272
2272
  body: shell.t(message),
2273
- actions: [{ title: shell.t('Reload'), on: () => { try {
2274
- location.reload();
2275
- }
2276
- catch { /* non-browser */ } } }],
2273
+ actions: [
2274
+ {
2275
+ title: shell.t('Reload'),
2276
+ on: () => {
2277
+ try {
2278
+ location.reload();
2279
+ }
2280
+ catch {
2281
+ /* non-browser */
2282
+ }
2283
+ },
2284
+ },
2285
+ ],
2277
2286
  });
2278
2287
  return;
2279
2288
  }
@@ -2403,7 +2412,9 @@ async function createSlotGame(opts) {
2403
2412
  const { lookupCurrency } = await import('@energy8platform/stake-bridge');
2404
2413
  currencyMeta = lookupCurrency(opts.model.spec.currency);
2405
2414
  }
2406
- catch { /* stake-bridge not installed — resolveCurrency falls back to the code */ }
2415
+ catch {
2416
+ /* stake-bridge not installed — resolveCurrency falls back to the code */
2417
+ }
2407
2418
  }
2408
2419
  const runtime = {
2409
2420
  balance,
@@ -2430,7 +2441,11 @@ async function createSlotGame(opts) {
2430
2441
  // pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
2431
2442
  // scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
2432
2443
  // The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
2433
- const pixiShellCfg = { ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer };
2444
+ const pixiShellCfg = {
2445
+ ...buildShellConfig(opts.shell, opts.model, runtime),
2446
+ app: game.app,
2447
+ parent: game.uiLayer,
2448
+ };
2434
2449
  // The game may swap in its own shell (a custom renderer over the same core) via shellFactory;
2435
2450
  // default is the built-in Pixi shell. The host drives whichever it gets through the Shell contract.
2436
2451
  shell = (opts.shellFactory ?? createPixiShell)(pixiShellCfg);
@@ -2443,10 +2458,15 @@ async function createSlotGame(opts) {
2443
2458
  // async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
2444
2459
  // is the single source for both the displayed balance and `ensureAffordable`.
2445
2460
  const balanceGate = createBalanceGate((b) => shell.setBalance(b), balance);
2446
- ps?.on('balanceUpdate', (d) => { balanceGate.onBalance(d.balance); });
2461
+ ps?.on('balanceUpdate', (d) => {
2462
+ balanceGate.onBalance(d.balance);
2463
+ });
2447
2464
  // Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
2448
2465
  let currentTurbo = shell.state.turbo;
2449
- shell.on('turboChange', (level) => { currentTurbo = level; gameScene()?.onTurboChanged?.(level); });
2466
+ shell.on('turboChange', (level) => {
2467
+ currentTurbo = level;
2468
+ gameScene()?.onTurboChanged?.(level);
2469
+ });
2450
2470
  // Double-tap-to-skip is a game-level option (default on), set once via createSlotGame({ skipGesture }).
2451
2471
  const skipEnabled = opts.skipGesture ?? true;
2452
2472
  // Shell settings → engine state. Sound/volume map onto the AudioManager.
@@ -2483,21 +2503,38 @@ async function createSlotGame(opts) {
2483
2503
  sceneApi = {
2484
2504
  audio: createSceneAudio(game.audio),
2485
2505
  overlay: overlayCtl.overlay,
2486
- shell: { get safeArea() { return shell.safeArea; } },
2506
+ shell: {
2507
+ get safeArea() {
2508
+ return shell.safeArea;
2509
+ },
2510
+ },
2487
2511
  formatAmount: (v) => shell.formatWin(v),
2488
- get bet() { return currentBet; },
2489
- get mode() { return opts.model.modeMap['spin'] ?? 'BASE'; },
2490
- get turbo() { return currentTurbo; },
2512
+ get bet() {
2513
+ return currentBet;
2514
+ },
2515
+ get mode() {
2516
+ return opts.model.modeMap['spin'] ?? 'BASE';
2517
+ },
2518
+ get turbo() {
2519
+ return currentTurbo;
2520
+ },
2491
2521
  };
2492
2522
  const roleOf = (action) => opts.model.spec.actions[action]?.role;
2523
+ const isBonusAction = (action) => roleOf(action) === 'free';
2524
+ // Per-SEGMENT mode string. modeMap intentionally excludes `free` actions (they'd pollute the
2525
+ // Game-Info modes table), so a free segment falls back to its spec `mode` (or the action key).
2526
+ // This is what distinguishes nested bonuses (FREESPINS vs ADVENTURE) at the transition boundary.
2527
+ const segmentModeOf = (action) => opts.model.spec.actions[action]?.mode ?? opts.model.modeMap[action] ?? action.toUpperCase();
2493
2528
  // The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
2494
2529
  // attaches its own. So makeContext returns everything BUT `signal`.
2495
2530
  const makeContext = (action) => ({
2496
2531
  bet: currentBet,
2497
2532
  action,
2498
- mode: opts.model.modeMap[action] ?? action.toUpperCase(),
2533
+ mode: segmentModeOf(action),
2499
2534
  formatAmount: (v) => shell.formatWin(v),
2500
- get turbo() { return currentTurbo; },
2535
+ get turbo() {
2536
+ return currentTurbo;
2537
+ },
2501
2538
  });
2502
2539
  // Play-error + connection handling. A play rejection is classified into a player-facing modal
2503
2540
  // (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
@@ -2513,11 +2550,27 @@ async function createSlotGame(opts) {
2513
2550
  title: shell.t(v.title),
2514
2551
  body: shell.t(v.body),
2515
2552
  actions: v.reload
2516
- ? [{ title: shell.t('Reload'), on: () => { try {
2517
- window.location.reload();
2518
- }
2519
- catch { /* non-browser */ } } }]
2520
- : [{ title: shell.t('OK'), on: () => { playErrorOpen = false; } }],
2553
+ ? [
2554
+ {
2555
+ title: shell.t('Reload'),
2556
+ on: () => {
2557
+ try {
2558
+ window.location.reload();
2559
+ }
2560
+ catch {
2561
+ /* non-browser */
2562
+ }
2563
+ },
2564
+ },
2565
+ ]
2566
+ : [
2567
+ {
2568
+ title: shell.t('OK'),
2569
+ on: () => {
2570
+ playErrorOpen = false;
2571
+ },
2572
+ },
2573
+ ],
2521
2574
  });
2522
2575
  };
2523
2576
  ps?.on('connectionStateChanged', (s) => {
@@ -2546,7 +2599,10 @@ async function createSlotGame(opts) {
2546
2599
  const skip = createDoubleTapSkip({
2547
2600
  enabled: () => skipEnabled,
2548
2601
  active: () => presenting,
2549
- onSkip: () => { currentSegmentAbort?.abort(); gameScene()?.onSkip?.(); },
2602
+ onSkip: () => {
2603
+ currentSegmentAbort?.abort();
2604
+ gameScene()?.onSkip?.();
2605
+ },
2550
2606
  });
2551
2607
  // Listen for taps on the scene root (game.worldRoot — the scaled scene container). The shell
2552
2608
  // lives on the sibling uiLayer, so its bar taps never reach worldRoot — taps here are the play area.
@@ -2576,54 +2632,133 @@ async function createSlotGame(opts) {
2576
2632
  gameScene()?.onResume?.();
2577
2633
  },
2578
2634
  });
2635
+ /** Push a bonus segment's readout to the shell bar. Default (no `opts.bonus`) = the free-spins
2636
+ * counter (label 'Free spins', value current/total). With `opts.bonus`, the game supplies the
2637
+ * label + value string (adventure / hold-and-spin / respins) and we drive the generic 'bonus'
2638
+ * hero instead — the shell stays free of any per-game bonus concept. */
2639
+ const applyBonusReadout = (result, view, mode) => {
2640
+ const b = opts.bonus;
2641
+ if (!b) {
2642
+ shell.setFreeSpins(view);
2643
+ return;
2644
+ }
2645
+ const label = typeof b.label === 'function' ? b.label(mode) : (b.label ?? 'Free spins');
2646
+ const value = b.readout
2647
+ ? b.readout(result, { view, mode })
2648
+ : view.current == null
2649
+ ? String(view.total)
2650
+ : `${view.current} / ${view.total}`;
2651
+ shell.setBonus({ label, value, totalWin: view.totalWin });
2652
+ };
2653
+ /** The mode entered via setMode for a bonus — 'bonus' when the game customises the readout,
2654
+ * else 'freeSpins' (the back-compat default the shell already renders). */
2655
+ const bonusShellMode = opts.bonus ? 'bonus' : 'freeSpins';
2656
+ /** Apply an authoritative book override (freeSpins.total/remaining) onto an accumulated view —
2657
+ * the host counts by default, but if the book resends the count (e.g. a resumed parent after a
2658
+ * nested sub-bonus) that wins. */
2659
+ const overrideView = (view, fs) => {
2660
+ if (!fs)
2661
+ return view;
2662
+ const total = fs.total ?? view.total;
2663
+ const current = fs.remaining != null ? Math.max(0, total - fs.remaining) : view.current;
2664
+ return { current, total, totalWin: view.totalWin };
2665
+ };
2666
+ /** A per-round stack of active bonus levels driving the shell bar as levels push/pop. Supports
2667
+ * NESTED bonuses (e.g. free spins → adventure → free spins): each level keeps its own counter,
2668
+ * so a resumed parent restores its remaining count. A single-bonus round pushes once and
2669
+ * unwinds once — byte-identical to the pre-nesting behaviour. */
2670
+ const createBonusStack = (scene) => {
2671
+ const stack = [];
2672
+ return {
2673
+ inBonus: () => stack.length > 0,
2674
+ /** A bonus level becomes active — a fresh push, or a resumed parent (already on the stack). */
2675
+ async enter(mode, trigger, ctx, resumed) {
2676
+ if (resumed) {
2677
+ const lvl = stack[stack.length - 1]; // the parent stayed on the stack; the child popped
2678
+ lvl.view = overrideView(lvl.view, trigger.freeSpins);
2679
+ applyBonusReadout(trigger, lvl.view, lvl.mode);
2680
+ }
2681
+ else {
2682
+ const counter = createFreeSpinsCounter();
2683
+ const view = overrideView(counter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0), trigger.freeSpins);
2684
+ stack.push({ mode, counter, view });
2685
+ if (stack.length === 1)
2686
+ shell.setMode(bonusShellMode); // base → bonus
2687
+ applyBonusReadout(trigger, view, mode);
2688
+ }
2689
+ // ctx.mode is overridden to the mode being ENTERED so the scene reads the right level.
2690
+ await scene.onEnterMode?.(trigger, { ...ctx, mode, resumed });
2691
+ },
2692
+ /** A bonus level ends — a nested pop back to its parent, or the last level back to base. */
2693
+ async exit(mode, last, ctx) {
2694
+ stack.pop();
2695
+ await scene.onExitMode?.(last, { ...ctx, mode });
2696
+ if (stack.length === 0) {
2697
+ shell.setMode('base');
2698
+ // Back in base the FS "Total win" block is gone, so WIN must carry the round's
2699
+ // CUMULATIVE total (what got credited) — not the last segment's per-spin delta.
2700
+ shell.setWin(last.totalWin);
2701
+ }
2702
+ // else: an intermediate pop; the following resume-enter (or next exit) re-paints the bar.
2703
+ },
2704
+ /** Advance the active level's counter with a settled segment. */
2705
+ settle(r) {
2706
+ if (stack.length === 0)
2707
+ return;
2708
+ const top = stack[stack.length - 1];
2709
+ top.view = overrideView(top.counter.spin(r.freeSpins?.awarded ?? 0, r.totalWin), r.freeSpins);
2710
+ applyBonusReadout(r, top.view, top.mode);
2711
+ },
2712
+ };
2713
+ };
2579
2714
  /** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
2580
2715
  * update only AFTER each onSpin(), per the HUD-timing requirement. */
2581
2716
  const playRound = (action) => {
2582
2717
  const scene = gameScene();
2583
2718
  if (!scene)
2584
2719
  return;
2585
- // Per-round free-spins state: the shell enters FS mode on bonus-enter and shows current/total
2586
- // (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
2587
- // the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
2588
- let inBonus = false;
2720
+ // Per-round bonus stack: the shell enters bonus mode on the first level's push and shows the
2721
+ // active level's counter + cumulative win. The stack handles NESTED bonuses (free spins
2722
+ // adventure free spins); a single-bonus round pushes/unwinds once (unchanged).
2723
+ const bonus = createBonusStack(scene);
2589
2724
  let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
2590
- const fsCounter = createFreeSpinsCounter();
2591
2725
  shell.setBusy(true); // block re-spin / spacebar while the round plays out
2592
2726
  presenting = true; // open the skip window for the whole play→drain
2593
2727
  // RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
2594
2728
  // animation has finished — returning void would reopen it instantly, over a running animation.
2595
2729
  return runRound({
2596
2730
  // Suppress the debit paint from play() until this segment's afterPresent (HUD timing).
2597
- play: (a, b, rid) => { balanceGate.beginPlay(); return slotPlay$1.play(a, b, rid); },
2731
+ play: (a, b, rid) => {
2732
+ balanceGate.beginPlay();
2733
+ return slotPlay$1.play(a, b, rid);
2734
+ },
2598
2735
  ack: slotPlay$1.ack,
2599
2736
  scene,
2600
2737
  context: makeContext,
2601
- roleOf,
2738
+ modeOf: segmentModeOf,
2739
+ isBonusAction,
2602
2740
  // Hand the host the per-segment AbortController so a double-tap can skip the live segment.
2603
- beforeSegment: (ac) => { currentSegmentAbort = ac; },
2741
+ beforeSegment: (ac) => {
2742
+ currentSegmentAbort = ac;
2743
+ },
2604
2744
  onSpinStart: () => scene.onSpinStart?.(),
2605
2745
  onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
2606
2746
  afterPresent: (r) => {
2607
2747
  // WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
2608
- // free-spins counter (totalWin) below, not the WIN readout.
2748
+ // bonus counter (totalWin) via settle(), not the WIN readout.
2609
2749
  shell.setWin(r.totalWin - prevWin);
2610
2750
  prevWin = r.totalWin;
2611
2751
  balanceGate.afterPresent();
2612
- if (inBonus)
2613
- shell.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
2614
- },
2615
- onEnterMode: async (trigger, ctx) => {
2616
- inBonus = true;
2617
- shell.setMode('freeSpins');
2618
- shell.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
2619
- await scene.onEnterMode?.(trigger, ctx);
2752
+ bonus.settle(r);
2620
2753
  },
2621
- onExitMode: async (last, ctx) => {
2622
- inBonus = false;
2623
- await scene.onExitMode?.(last, ctx);
2624
- shell.setMode('base');
2625
- },
2626
- }, action).catch(showPlayError).finally(() => { presenting = false; shell.setBusy(false); });
2754
+ onModeEnter: (mode, trigger, ctx, resumed) => bonus.enter(mode, trigger, ctx, resumed),
2755
+ onModeExit: (mode, last, ctx) => bonus.exit(mode, last, ctx),
2756
+ }, action)
2757
+ .catch(showPlayError)
2758
+ .finally(() => {
2759
+ presenting = false;
2760
+ shell.setBusy(false);
2761
+ });
2627
2762
  };
2628
2763
  /**
2629
2764
  * Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
@@ -2637,7 +2772,9 @@ async function createSlotGame(opts) {
2637
2772
  if (!scene || !ps)
2638
2773
  return;
2639
2774
  // A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
2640
- // never-aborted signal to satisfy onSpin's RenderContext.
2775
+ // never-aborted signal to satisfy onSpin's RenderContext. ctx carries the round identity (built
2776
+ // once from the trigger action) — recovery drains a single flat bonus using the bridge session
2777
+ // counts; the full per-level nesting is a LIVE-play concern (playRound).
2641
2778
  const ctx = {
2642
2779
  ...makeContext(firstRaw.action ?? 'spin'),
2643
2780
  signal: new AbortController().signal,
@@ -2658,17 +2795,17 @@ async function createSlotGame(opts) {
2658
2795
  let inBonus = false;
2659
2796
  let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
2660
2797
  const applySegment = async () => {
2661
- // A recovered open round with remaining segments is a bonus → show FS mode + counter.
2798
+ // A recovered open round with remaining segments is a bonus → show bonus mode + counter.
2662
2799
  if (!inBonus && !r.complete) {
2663
2800
  inBonus = true;
2664
- shell.setMode('freeSpins');
2801
+ shell.setMode(bonusShellMode);
2665
2802
  }
2666
2803
  if (animate)
2667
2804
  await scene.onSpin(r, ctx);
2668
2805
  if (inBonus) {
2669
2806
  const v = fsView(raw, r.totalWin);
2670
2807
  if (v)
2671
- shell.setFreeSpins(v);
2808
+ applyBonusReadout(r, v, ctx.mode);
2672
2809
  }
2673
2810
  shell.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
2674
2811
  prevWin = r.totalWin;
@@ -2678,12 +2815,20 @@ async function createSlotGame(opts) {
2678
2815
  try {
2679
2816
  await applySegment();
2680
2817
  while (!r.complete && r.nextActions && r.nextActions.length > 0) {
2681
- raw = (await ps.play({ action: r.nextActions[0], bet: ctx.bet, roundId: r.roundId }));
2818
+ raw = (await ps.play({
2819
+ action: r.nextActions[0],
2820
+ bet: ctx.bet,
2821
+ roundId: r.roundId,
2822
+ }));
2682
2823
  r = enrichRoundMeta(opts.normalize(raw), raw);
2683
2824
  await applySegment();
2684
2825
  }
2685
- if (inBonus)
2826
+ if (inBonus) {
2686
2827
  shell.setMode('base');
2828
+ // Same as playRound: on return to base the WIN readout must show the round's cumulative
2829
+ // total (r is the final drained segment), not the last segment's per-spin delta.
2830
+ shell.setWin(r.totalWin);
2831
+ }
2687
2832
  }
2688
2833
  finally {
2689
2834
  shell.setBusy(false);
@@ -2691,8 +2836,12 @@ async function createSlotGame(opts) {
2691
2836
  };
2692
2837
  if (mode === 'base') {
2693
2838
  let activeFeature = null;
2694
- shell.on('featureActivate', ({ id }) => { activeFeature = id; });
2695
- shell.on('featureDeactivate', ({ id: _id }) => { activeFeature = null; });
2839
+ shell.on('featureActivate', ({ id }) => {
2840
+ activeFeature = id;
2841
+ });
2842
+ shell.on('featureDeactivate', ({ id: _id }) => {
2843
+ activeFeature = null;
2844
+ });
2696
2845
  const { stakeForAction } = await Promise.resolve().then(function () { return shellConfig; });
2697
2846
  // Guard a play: if the stake exceeds the balance, show a shell modal and DON'T play.
2698
2847
  const ensureAffordable = (action) => {
@@ -2712,7 +2861,10 @@ async function createSlotGame(opts) {
2712
2861
  return;
2713
2862
  void playRound(action);
2714
2863
  });
2715
- shell.on('betChange', (bet) => { currentBet = bet; gameScene()?.onBetChanged?.(bet); });
2864
+ shell.on('betChange', (bet) => {
2865
+ currentBet = bet;
2866
+ gameScene()?.onBetChanged?.(bet);
2867
+ });
2716
2868
  shell.on('buyBonusSelect', ({ id }) => {
2717
2869
  if (!ensureAffordable(id))
2718
2870
  return;
@@ -2743,7 +2895,7 @@ async function createSlotGame(opts) {
2743
2895
  resumeOffered = true;
2744
2896
  let snap = null;
2745
2897
  try {
2746
- snap = await ps?.getState() ?? null;
2898
+ snap = (await ps?.getState()) ?? null;
2747
2899
  }
2748
2900
  catch {
2749
2901
  snap = null;
@@ -2756,13 +2908,25 @@ async function createSlotGame(opts) {
2756
2908
  body: shell.t('You have an unfinished round. Continue it or finish it now?'),
2757
2909
  actions: [
2758
2910
  // Continue: replay the round from the start with animation, then settle.
2759
- { title: shell.t('Continue'), on: () => { void resumeDrain(snap, true); } },
2911
+ {
2912
+ title: shell.t('Continue'),
2913
+ on: () => {
2914
+ void resumeDrain(snap, true);
2915
+ },
2916
+ },
2760
2917
  // Finish: fast-forward the remaining segments (no animation) to settle the win now.
2761
- { title: shell.t('Finish'), on: () => { void resumeDrain(snap, false); } },
2918
+ {
2919
+ title: shell.t('Finish'),
2920
+ on: () => {
2921
+ void resumeDrain(snap, false);
2922
+ },
2923
+ },
2762
2924
  ],
2763
2925
  });
2764
2926
  };
2765
- game.scenes.on('change', () => { void offerResume(); });
2927
+ game.scenes.on('change', () => {
2928
+ void offerResume();
2929
+ });
2766
2930
  void offerResume();
2767
2931
  }
2768
2932
  else {
@@ -3229,36 +3393,75 @@ var slotPlay = /*#__PURE__*/Object.freeze({
3229
3393
  enrichRoundMeta: enrichRoundMeta
3230
3394
  });
3231
3395
 
3396
+ /**
3397
+ * Pure: given the CURRENT bonus-mode stack (bottom→top) and the next segment's target mode
3398
+ * (null = a base segment / round end), decide which levels to exit and whether a level enters.
3399
+ *
3400
+ * - target null → unwind everything (exit all, top-first).
3401
+ * - target === top → same level, no transition.
3402
+ * - target already deeper → RESUME: exit the levels above it; it re-activates (resumed).
3403
+ * - target not on the stack → PUSH: a fresh level enters.
3404
+ *
3405
+ * The caller owns the actual stack array and applies the plan (pop per `exit`, push when
3406
+ * `enter && !resumed`).
3407
+ */
3408
+ function planTransition(stack, target) {
3409
+ if (target == null)
3410
+ return { exit: [...stack].reverse(), enter: null };
3411
+ if (stack.length > 0 && stack[stack.length - 1] === target)
3412
+ return { exit: [], enter: null };
3413
+ const depth = stack.lastIndexOf(target);
3414
+ if (depth >= 0)
3415
+ return { exit: stack.slice(depth + 1).reverse(), enter: { mode: target, resumed: true } };
3416
+ return { exit: [], enter: { mode: target, resumed: false } };
3417
+ }
3232
3418
  async function runRound(deps, action) {
3233
3419
  deps.onSpinStart?.();
3234
3420
  const ctxBet = deps.context(action).bet;
3421
+ const modeOf = deps.modeOf ?? ((a) => a.toUpperCase());
3422
+ const isBonus = deps.isBonusAction ?? (() => false);
3235
3423
  const segment = async (a, roundId) => {
3236
3424
  const ac = new AbortController();
3237
3425
  deps.beforeSegment?.(ac);
3238
3426
  const r = await deps.play(a, ctxBet, roundId);
3427
+ // ctx carries the ROUND identity (built from the round action), stable across drained segments.
3239
3428
  const ctx = { ...deps.context(action), signal: ac.signal };
3240
3429
  await deps.scene.onSpin(r, ctx);
3241
3430
  deps.ack();
3242
3431
  deps.afterPresent?.(r);
3243
3432
  return { r, ctx };
3244
3433
  };
3434
+ // Active bonus-mode levels (bottom→top). Empty in the base game.
3435
+ const stack = [];
3436
+ // Emit the exits/enter to move the stack toward `nextAction` (null → unwind to base). Called
3437
+ // BEFORE the target segment presents, with the CURRENT (triggering) r/ctx — mirrors the classic
3438
+ // onEnterMode(trigger) timing so the host reads awarded spins off the segment that granted them.
3439
+ const transition = async (nextAction, r, ctx) => {
3440
+ const target = nextAction != null && isBonus(nextAction) ? modeOf(nextAction) : null;
3441
+ const plan = planTransition(stack, target);
3442
+ for (const mode of plan.exit) {
3443
+ stack.pop();
3444
+ await deps.onModeExit?.(mode, r, ctx);
3445
+ }
3446
+ if (plan.enter) {
3447
+ if (!plan.enter.resumed)
3448
+ stack.push(plan.enter.mode);
3449
+ await deps.onModeEnter?.(plan.enter.mode, r, ctx, plan.enter.resumed);
3450
+ }
3451
+ };
3245
3452
  let { r, ctx } = await segment(action, undefined);
3246
- let inMode = false;
3247
3453
  while (!r.complete && r.nextActions && r.nextActions.length > 0) {
3248
3454
  const next = r.nextActions[0];
3249
- if (!inMode && deps.roleOf(next) === 'free') {
3250
- inMode = true;
3251
- await deps.onEnterMode?.(r, ctx);
3252
- }
3455
+ await transition(next, r, ctx);
3253
3456
  ({ r, ctx } = await segment(next, r.roundId));
3254
3457
  }
3255
- if (inMode)
3256
- await deps.onExitMode?.(r, ctx);
3458
+ await transition(null, r, ctx); // round end: unwind any remaining levels to base
3257
3459
  deps.onSpinEnd?.(r, ctx);
3258
3460
  }
3259
3461
 
3260
3462
  var runRound$1 = /*#__PURE__*/Object.freeze({
3261
3463
  __proto__: null,
3464
+ planTransition: planTransition,
3262
3465
  runRound: runRound
3263
3466
  });
3264
3467