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