@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/core.cjs.js +3 -2
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.esm.js +3 -2
- package/dist/core.esm.js.map +1 -1
- package/dist/host.cjs.js +132 -35
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +10 -2
- package/dist/host.esm.js +132 -35
- package/dist/host.esm.js.map +1 -1
- package/dist/index.cjs.js +3 -2
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +3 -2
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/core/GameApplication.ts +7 -5
- package/src/host/createSlotGame.ts +99 -36
- package/src/host/runRound.ts +75 -14
- package/src/host/sceneController.ts +10 -2
package/dist/host.esm.js
CHANGED
|
@@ -1989,8 +1989,9 @@ class GameApplication extends EventEmitter {
|
|
|
1989
1989
|
}
|
|
1990
1990
|
}
|
|
1991
1991
|
initSubSystems() {
|
|
1992
|
-
// Asset Manager
|
|
1993
|
-
|
|
1992
|
+
// Asset Manager. Base defaults to '/' (site root) when the bridge sends no assetsUrl — the
|
|
1993
|
+
// asset folder lives in the manifest paths, not the base, so it needn't be named `assets`.
|
|
1994
|
+
const basePath = this.initData?.assetsUrl ?? '/';
|
|
1994
1995
|
this.assets = new AssetManager(basePath, this.config.manifest);
|
|
1995
1996
|
// Audio Manager
|
|
1996
1997
|
this.audio = new AudioManager(this.config.audio);
|
|
@@ -2519,12 +2520,17 @@ async function createSlotGame(opts) {
|
|
|
2519
2520
|
},
|
|
2520
2521
|
};
|
|
2521
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();
|
|
2522
2528
|
// The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
|
|
2523
2529
|
// attaches its own. So makeContext returns everything BUT `signal`.
|
|
2524
2530
|
const makeContext = (action) => ({
|
|
2525
2531
|
bet: currentBet,
|
|
2526
2532
|
action,
|
|
2527
|
-
mode:
|
|
2533
|
+
mode: segmentModeOf(action),
|
|
2528
2534
|
formatAmount: (v) => shell.formatWin(v),
|
|
2529
2535
|
get turbo() {
|
|
2530
2536
|
return currentTurbo;
|
|
@@ -2647,19 +2653,75 @@ async function createSlotGame(opts) {
|
|
|
2647
2653
|
/** The mode entered via setMode for a bonus — 'bonus' when the game customises the readout,
|
|
2648
2654
|
* else 'freeSpins' (the back-compat default the shell already renders). */
|
|
2649
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
|
+
};
|
|
2650
2714
|
/** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
|
|
2651
2715
|
* update only AFTER each onSpin(), per the HUD-timing requirement. */
|
|
2652
2716
|
const playRound = (action) => {
|
|
2653
2717
|
const scene = gameScene();
|
|
2654
2718
|
if (!scene)
|
|
2655
2719
|
return;
|
|
2656
|
-
// Per-round
|
|
2657
|
-
//
|
|
2658
|
-
//
|
|
2659
|
-
|
|
2660
|
-
let bonusMode = ''; // the mode string captured on bonus-enter, reused for each settled spin
|
|
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);
|
|
2661
2724
|
let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
|
|
2662
|
-
const fsCounter = createFreeSpinsCounter();
|
|
2663
2725
|
shell.setBusy(true); // block re-spin / spacebar while the round plays out
|
|
2664
2726
|
presenting = true; // open the skip window for the whole play→drain
|
|
2665
2727
|
// RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
|
|
@@ -2673,7 +2735,8 @@ async function createSlotGame(opts) {
|
|
|
2673
2735
|
ack: slotPlay$1.ack,
|
|
2674
2736
|
scene,
|
|
2675
2737
|
context: makeContext,
|
|
2676
|
-
|
|
2738
|
+
modeOf: segmentModeOf,
|
|
2739
|
+
isBonusAction,
|
|
2677
2740
|
// Hand the host the per-segment AbortController so a double-tap can skip the live segment.
|
|
2678
2741
|
beforeSegment: (ac) => {
|
|
2679
2742
|
currentSegmentAbort = ac;
|
|
@@ -2682,25 +2745,14 @@ async function createSlotGame(opts) {
|
|
|
2682
2745
|
onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
|
|
2683
2746
|
afterPresent: (r) => {
|
|
2684
2747
|
// WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
|
|
2685
|
-
//
|
|
2748
|
+
// bonus counter (totalWin) via settle(), not the WIN readout.
|
|
2686
2749
|
shell.setWin(r.totalWin - prevWin);
|
|
2687
2750
|
prevWin = r.totalWin;
|
|
2688
2751
|
balanceGate.afterPresent();
|
|
2689
|
-
|
|
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');
|
|
2752
|
+
bonus.settle(r);
|
|
2703
2753
|
},
|
|
2754
|
+
onModeEnter: (mode, trigger, ctx, resumed) => bonus.enter(mode, trigger, ctx, resumed),
|
|
2755
|
+
onModeExit: (mode, last, ctx) => bonus.exit(mode, last, ctx),
|
|
2704
2756
|
}, action)
|
|
2705
2757
|
.catch(showPlayError)
|
|
2706
2758
|
.finally(() => {
|
|
@@ -2720,7 +2772,9 @@ async function createSlotGame(opts) {
|
|
|
2720
2772
|
if (!scene || !ps)
|
|
2721
2773
|
return;
|
|
2722
2774
|
// 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.
|
|
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).
|
|
2724
2778
|
const ctx = {
|
|
2725
2779
|
...makeContext(firstRaw.action ?? 'spin'),
|
|
2726
2780
|
signal: new AbortController().signal,
|
|
@@ -2741,7 +2795,7 @@ async function createSlotGame(opts) {
|
|
|
2741
2795
|
let inBonus = false;
|
|
2742
2796
|
let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
|
|
2743
2797
|
const applySegment = async () => {
|
|
2744
|
-
// A recovered open round with remaining segments is a bonus → show
|
|
2798
|
+
// A recovered open round with remaining segments is a bonus → show bonus mode + counter.
|
|
2745
2799
|
if (!inBonus && !r.complete) {
|
|
2746
2800
|
inBonus = true;
|
|
2747
2801
|
shell.setMode(bonusShellMode);
|
|
@@ -2769,8 +2823,12 @@ async function createSlotGame(opts) {
|
|
|
2769
2823
|
r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
2770
2824
|
await applySegment();
|
|
2771
2825
|
}
|
|
2772
|
-
if (inBonus)
|
|
2826
|
+
if (inBonus) {
|
|
2773
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
|
+
}
|
|
2774
2832
|
}
|
|
2775
2833
|
finally {
|
|
2776
2834
|
shell.setBusy(false);
|
|
@@ -3335,36 +3393,75 @@ var slotPlay = /*#__PURE__*/Object.freeze({
|
|
|
3335
3393
|
enrichRoundMeta: enrichRoundMeta
|
|
3336
3394
|
});
|
|
3337
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
|
+
}
|
|
3338
3418
|
async function runRound(deps, action) {
|
|
3339
3419
|
deps.onSpinStart?.();
|
|
3340
3420
|
const ctxBet = deps.context(action).bet;
|
|
3421
|
+
const modeOf = deps.modeOf ?? ((a) => a.toUpperCase());
|
|
3422
|
+
const isBonus = deps.isBonusAction ?? (() => false);
|
|
3341
3423
|
const segment = async (a, roundId) => {
|
|
3342
3424
|
const ac = new AbortController();
|
|
3343
3425
|
deps.beforeSegment?.(ac);
|
|
3344
3426
|
const r = await deps.play(a, ctxBet, roundId);
|
|
3427
|
+
// ctx carries the ROUND identity (built from the round action), stable across drained segments.
|
|
3345
3428
|
const ctx = { ...deps.context(action), signal: ac.signal };
|
|
3346
3429
|
await deps.scene.onSpin(r, ctx);
|
|
3347
3430
|
deps.ack();
|
|
3348
3431
|
deps.afterPresent?.(r);
|
|
3349
3432
|
return { r, ctx };
|
|
3350
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
|
+
};
|
|
3351
3452
|
let { r, ctx } = await segment(action, undefined);
|
|
3352
|
-
let inMode = false;
|
|
3353
3453
|
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
3354
3454
|
const next = r.nextActions[0];
|
|
3355
|
-
|
|
3356
|
-
inMode = true;
|
|
3357
|
-
await deps.onEnterMode?.(r, ctx);
|
|
3358
|
-
}
|
|
3455
|
+
await transition(next, r, ctx);
|
|
3359
3456
|
({ r, ctx } = await segment(next, r.roundId));
|
|
3360
3457
|
}
|
|
3361
|
-
|
|
3362
|
-
await deps.onExitMode?.(r, ctx);
|
|
3458
|
+
await transition(null, r, ctx); // round end: unwind any remaining levels to base
|
|
3363
3459
|
deps.onSpinEnd?.(r, ctx);
|
|
3364
3460
|
}
|
|
3365
3461
|
|
|
3366
3462
|
var runRound$1 = /*#__PURE__*/Object.freeze({
|
|
3367
3463
|
__proto__: null,
|
|
3464
|
+
planTransition: planTransition,
|
|
3368
3465
|
runRound: runRound
|
|
3369
3466
|
});
|
|
3370
3467
|
|