@energy8platform/game-engine 0.30.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.esm.js CHANGED
@@ -2519,12 +2519,17 @@ async function createSlotGame(opts) {
2519
2519
  },
2520
2520
  };
2521
2521
  const roleOf = (action) => opts.model.spec.actions[action]?.role;
2522
+ const isBonusAction = (action) => roleOf(action) === 'free';
2523
+ // Per-SEGMENT mode string. modeMap intentionally excludes `free` actions (they'd pollute the
2524
+ // Game-Info modes table), so a free segment falls back to its spec `mode` (or the action key).
2525
+ // This is what distinguishes nested bonuses (FREESPINS vs ADVENTURE) at the transition boundary.
2526
+ const segmentModeOf = (action) => opts.model.spec.actions[action]?.mode ?? opts.model.modeMap[action] ?? action.toUpperCase();
2522
2527
  // The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
2523
2528
  // attaches its own. So makeContext returns everything BUT `signal`.
2524
2529
  const makeContext = (action) => ({
2525
2530
  bet: currentBet,
2526
2531
  action,
2527
- mode: opts.model.modeMap[action] ?? action.toUpperCase(),
2532
+ mode: segmentModeOf(action),
2528
2533
  formatAmount: (v) => shell.formatWin(v),
2529
2534
  get turbo() {
2530
2535
  return currentTurbo;
@@ -2647,19 +2652,75 @@ async function createSlotGame(opts) {
2647
2652
  /** The mode entered via setMode for a bonus — 'bonus' when the game customises the readout,
2648
2653
  * else 'freeSpins' (the back-compat default the shell already renders). */
2649
2654
  const bonusShellMode = opts.bonus ? 'bonus' : 'freeSpins';
2655
+ /** Apply an authoritative book override (freeSpins.total/remaining) onto an accumulated view —
2656
+ * the host counts by default, but if the book resends the count (e.g. a resumed parent after a
2657
+ * nested sub-bonus) that wins. */
2658
+ const overrideView = (view, fs) => {
2659
+ if (!fs)
2660
+ return view;
2661
+ const total = fs.total ?? view.total;
2662
+ const current = fs.remaining != null ? Math.max(0, total - fs.remaining) : view.current;
2663
+ return { current, total, totalWin: view.totalWin };
2664
+ };
2665
+ /** A per-round stack of active bonus levels driving the shell bar as levels push/pop. Supports
2666
+ * NESTED bonuses (e.g. free spins → adventure → free spins): each level keeps its own counter,
2667
+ * so a resumed parent restores its remaining count. A single-bonus round pushes once and
2668
+ * unwinds once — byte-identical to the pre-nesting behaviour. */
2669
+ const createBonusStack = (scene) => {
2670
+ const stack = [];
2671
+ return {
2672
+ inBonus: () => stack.length > 0,
2673
+ /** A bonus level becomes active — a fresh push, or a resumed parent (already on the stack). */
2674
+ async enter(mode, trigger, ctx, resumed) {
2675
+ if (resumed) {
2676
+ const lvl = stack[stack.length - 1]; // the parent stayed on the stack; the child popped
2677
+ lvl.view = overrideView(lvl.view, trigger.freeSpins);
2678
+ applyBonusReadout(trigger, lvl.view, lvl.mode);
2679
+ }
2680
+ else {
2681
+ const counter = createFreeSpinsCounter();
2682
+ const view = overrideView(counter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0), trigger.freeSpins);
2683
+ stack.push({ mode, counter, view });
2684
+ if (stack.length === 1)
2685
+ shell.setMode(bonusShellMode); // base → bonus
2686
+ applyBonusReadout(trigger, view, mode);
2687
+ }
2688
+ // ctx.mode is overridden to the mode being ENTERED so the scene reads the right level.
2689
+ await scene.onEnterMode?.(trigger, { ...ctx, mode, resumed });
2690
+ },
2691
+ /** A bonus level ends — a nested pop back to its parent, or the last level back to base. */
2692
+ async exit(mode, last, ctx) {
2693
+ stack.pop();
2694
+ await scene.onExitMode?.(last, { ...ctx, mode });
2695
+ if (stack.length === 0) {
2696
+ shell.setMode('base');
2697
+ // Back in base the FS "Total win" block is gone, so WIN must carry the round's
2698
+ // CUMULATIVE total (what got credited) — not the last segment's per-spin delta.
2699
+ shell.setWin(last.totalWin);
2700
+ }
2701
+ // else: an intermediate pop; the following resume-enter (or next exit) re-paints the bar.
2702
+ },
2703
+ /** Advance the active level's counter with a settled segment. */
2704
+ settle(r) {
2705
+ if (stack.length === 0)
2706
+ return;
2707
+ const top = stack[stack.length - 1];
2708
+ top.view = overrideView(top.counter.spin(r.freeSpins?.awarded ?? 0, r.totalWin), r.freeSpins);
2709
+ applyBonusReadout(r, top.view, top.mode);
2710
+ },
2711
+ };
2712
+ };
2650
2713
  /** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
2651
2714
  * update only AFTER each onSpin(), per the HUD-timing requirement. */
2652
2715
  const playRound = (action) => {
2653
2716
  const scene = gameScene();
2654
2717
  if (!scene)
2655
2718
  return;
2656
- // Per-round free-spins state: the shell enters FS mode on bonus-enter and shows current/total
2657
- // (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
2658
- // the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
2659
- let inBonus = false;
2660
- let bonusMode = ''; // the mode string captured on bonus-enter, reused for each settled spin
2719
+ // Per-round bonus stack: the shell enters bonus mode on the first level's push and shows the
2720
+ // active level's counter + cumulative win. The stack handles NESTED bonuses (free spins
2721
+ // adventure free spins); a single-bonus round pushes/unwinds once (unchanged).
2722
+ const bonus = createBonusStack(scene);
2661
2723
  let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
2662
- const fsCounter = createFreeSpinsCounter();
2663
2724
  shell.setBusy(true); // block re-spin / spacebar while the round plays out
2664
2725
  presenting = true; // open the skip window for the whole play→drain
2665
2726
  // RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
@@ -2673,7 +2734,8 @@ async function createSlotGame(opts) {
2673
2734
  ack: slotPlay$1.ack,
2674
2735
  scene,
2675
2736
  context: makeContext,
2676
- roleOf,
2737
+ modeOf: segmentModeOf,
2738
+ isBonusAction,
2677
2739
  // Hand the host the per-segment AbortController so a double-tap can skip the live segment.
2678
2740
  beforeSegment: (ac) => {
2679
2741
  currentSegmentAbort = ac;
@@ -2682,25 +2744,14 @@ async function createSlotGame(opts) {
2682
2744
  onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
2683
2745
  afterPresent: (r) => {
2684
2746
  // WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
2685
- // free-spins counter (totalWin) below, not the WIN readout.
2747
+ // bonus counter (totalWin) via settle(), not the WIN readout.
2686
2748
  shell.setWin(r.totalWin - prevWin);
2687
2749
  prevWin = r.totalWin;
2688
2750
  balanceGate.afterPresent();
2689
- if (inBonus)
2690
- applyBonusReadout(r, fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin), bonusMode);
2691
- },
2692
- onEnterMode: async (trigger, ctx) => {
2693
- inBonus = true;
2694
- bonusMode = ctx.mode;
2695
- shell.setMode(bonusShellMode);
2696
- applyBonusReadout(trigger, fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0), bonusMode);
2697
- await scene.onEnterMode?.(trigger, ctx);
2698
- },
2699
- onExitMode: async (last, ctx) => {
2700
- inBonus = false;
2701
- await scene.onExitMode?.(last, ctx);
2702
- shell.setMode('base');
2751
+ bonus.settle(r);
2703
2752
  },
2753
+ onModeEnter: (mode, trigger, ctx, resumed) => bonus.enter(mode, trigger, ctx, resumed),
2754
+ onModeExit: (mode, last, ctx) => bonus.exit(mode, last, ctx),
2704
2755
  }, action)
2705
2756
  .catch(showPlayError)
2706
2757
  .finally(() => {
@@ -2720,7 +2771,9 @@ async function createSlotGame(opts) {
2720
2771
  if (!scene || !ps)
2721
2772
  return;
2722
2773
  // A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
2723
- // never-aborted signal to satisfy onSpin's RenderContext.
2774
+ // never-aborted signal to satisfy onSpin's RenderContext. ctx carries the round identity (built
2775
+ // once from the trigger action) — recovery drains a single flat bonus using the bridge session
2776
+ // counts; the full per-level nesting is a LIVE-play concern (playRound).
2724
2777
  const ctx = {
2725
2778
  ...makeContext(firstRaw.action ?? 'spin'),
2726
2779
  signal: new AbortController().signal,
@@ -2741,7 +2794,7 @@ async function createSlotGame(opts) {
2741
2794
  let inBonus = false;
2742
2795
  let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
2743
2796
  const applySegment = async () => {
2744
- // A recovered open round with remaining segments is a bonus → show FS mode + counter.
2797
+ // A recovered open round with remaining segments is a bonus → show bonus mode + counter.
2745
2798
  if (!inBonus && !r.complete) {
2746
2799
  inBonus = true;
2747
2800
  shell.setMode(bonusShellMode);
@@ -2769,8 +2822,12 @@ async function createSlotGame(opts) {
2769
2822
  r = enrichRoundMeta(opts.normalize(raw), raw);
2770
2823
  await applySegment();
2771
2824
  }
2772
- if (inBonus)
2825
+ if (inBonus) {
2773
2826
  shell.setMode('base');
2827
+ // Same as playRound: on return to base the WIN readout must show the round's cumulative
2828
+ // total (r is the final drained segment), not the last segment's per-spin delta.
2829
+ shell.setWin(r.totalWin);
2830
+ }
2774
2831
  }
2775
2832
  finally {
2776
2833
  shell.setBusy(false);
@@ -3335,36 +3392,75 @@ var slotPlay = /*#__PURE__*/Object.freeze({
3335
3392
  enrichRoundMeta: enrichRoundMeta
3336
3393
  });
3337
3394
 
3395
+ /**
3396
+ * Pure: given the CURRENT bonus-mode stack (bottom→top) and the next segment's target mode
3397
+ * (null = a base segment / round end), decide which levels to exit and whether a level enters.
3398
+ *
3399
+ * - target null → unwind everything (exit all, top-first).
3400
+ * - target === top → same level, no transition.
3401
+ * - target already deeper → RESUME: exit the levels above it; it re-activates (resumed).
3402
+ * - target not on the stack → PUSH: a fresh level enters.
3403
+ *
3404
+ * The caller owns the actual stack array and applies the plan (pop per `exit`, push when
3405
+ * `enter && !resumed`).
3406
+ */
3407
+ function planTransition(stack, target) {
3408
+ if (target == null)
3409
+ return { exit: [...stack].reverse(), enter: null };
3410
+ if (stack.length > 0 && stack[stack.length - 1] === target)
3411
+ return { exit: [], enter: null };
3412
+ const depth = stack.lastIndexOf(target);
3413
+ if (depth >= 0)
3414
+ return { exit: stack.slice(depth + 1).reverse(), enter: { mode: target, resumed: true } };
3415
+ return { exit: [], enter: { mode: target, resumed: false } };
3416
+ }
3338
3417
  async function runRound(deps, action) {
3339
3418
  deps.onSpinStart?.();
3340
3419
  const ctxBet = deps.context(action).bet;
3420
+ const modeOf = deps.modeOf ?? ((a) => a.toUpperCase());
3421
+ const isBonus = deps.isBonusAction ?? (() => false);
3341
3422
  const segment = async (a, roundId) => {
3342
3423
  const ac = new AbortController();
3343
3424
  deps.beforeSegment?.(ac);
3344
3425
  const r = await deps.play(a, ctxBet, roundId);
3426
+ // ctx carries the ROUND identity (built from the round action), stable across drained segments.
3345
3427
  const ctx = { ...deps.context(action), signal: ac.signal };
3346
3428
  await deps.scene.onSpin(r, ctx);
3347
3429
  deps.ack();
3348
3430
  deps.afterPresent?.(r);
3349
3431
  return { r, ctx };
3350
3432
  };
3433
+ // Active bonus-mode levels (bottom→top). Empty in the base game.
3434
+ const stack = [];
3435
+ // Emit the exits/enter to move the stack toward `nextAction` (null → unwind to base). Called
3436
+ // BEFORE the target segment presents, with the CURRENT (triggering) r/ctx — mirrors the classic
3437
+ // onEnterMode(trigger) timing so the host reads awarded spins off the segment that granted them.
3438
+ const transition = async (nextAction, r, ctx) => {
3439
+ const target = nextAction != null && isBonus(nextAction) ? modeOf(nextAction) : null;
3440
+ const plan = planTransition(stack, target);
3441
+ for (const mode of plan.exit) {
3442
+ stack.pop();
3443
+ await deps.onModeExit?.(mode, r, ctx);
3444
+ }
3445
+ if (plan.enter) {
3446
+ if (!plan.enter.resumed)
3447
+ stack.push(plan.enter.mode);
3448
+ await deps.onModeEnter?.(plan.enter.mode, r, ctx, plan.enter.resumed);
3449
+ }
3450
+ };
3351
3451
  let { r, ctx } = await segment(action, undefined);
3352
- let inMode = false;
3353
3452
  while (!r.complete && r.nextActions && r.nextActions.length > 0) {
3354
3453
  const next = r.nextActions[0];
3355
- if (!inMode && deps.roleOf(next) === 'free') {
3356
- inMode = true;
3357
- await deps.onEnterMode?.(r, ctx);
3358
- }
3454
+ await transition(next, r, ctx);
3359
3455
  ({ r, ctx } = await segment(next, r.roundId));
3360
3456
  }
3361
- if (inMode)
3362
- await deps.onExitMode?.(r, ctx);
3457
+ await transition(null, r, ctx); // round end: unwind any remaining levels to base
3363
3458
  deps.onSpinEnd?.(r, ctx);
3364
3459
  }
3365
3460
 
3366
3461
  var runRound$1 = /*#__PURE__*/Object.freeze({
3367
3462
  __proto__: null,
3463
+ planTransition: planTransition,
3368
3464
  runRound: runRound
3369
3465
  });
3370
3466