@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 +266 -63
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +52 -2
- package/dist/host.esm.js +266 -63
- package/dist/host.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/host/buildConfig.ts +3 -2
- package/src/host/createSlotGame.ts +274 -65
- package/src/host/runRound.ts +75 -14
- package/src/host/sceneController.ts +10 -2
- package/src/host/types.ts +24 -1
package/dist/host.esm.js
CHANGED
|
@@ -2269,10 +2269,19 @@ async function createSlotGame(opts) {
|
|
|
2269
2269
|
availableClose: false,
|
|
2270
2270
|
title: shell.t('Something went wrong'),
|
|
2271
2271
|
body: shell.t(message),
|
|
2272
|
-
actions: [
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2272
|
+
actions: [
|
|
2273
|
+
{
|
|
2274
|
+
title: shell.t('Reload'),
|
|
2275
|
+
on: () => {
|
|
2276
|
+
try {
|
|
2277
|
+
location.reload();
|
|
2278
|
+
}
|
|
2279
|
+
catch {
|
|
2280
|
+
/* non-browser */
|
|
2281
|
+
}
|
|
2282
|
+
},
|
|
2283
|
+
},
|
|
2284
|
+
],
|
|
2276
2285
|
});
|
|
2277
2286
|
return;
|
|
2278
2287
|
}
|
|
@@ -2402,7 +2411,9 @@ async function createSlotGame(opts) {
|
|
|
2402
2411
|
const { lookupCurrency } = await import('@energy8platform/stake-bridge');
|
|
2403
2412
|
currencyMeta = lookupCurrency(opts.model.spec.currency);
|
|
2404
2413
|
}
|
|
2405
|
-
catch {
|
|
2414
|
+
catch {
|
|
2415
|
+
/* stake-bridge not installed — resolveCurrency falls back to the code */
|
|
2416
|
+
}
|
|
2406
2417
|
}
|
|
2407
2418
|
const runtime = {
|
|
2408
2419
|
balance,
|
|
@@ -2429,7 +2440,11 @@ async function createSlotGame(opts) {
|
|
|
2429
2440
|
// pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
|
|
2430
2441
|
// scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
|
|
2431
2442
|
// The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
|
|
2432
|
-
const pixiShellCfg = {
|
|
2443
|
+
const pixiShellCfg = {
|
|
2444
|
+
...buildShellConfig(opts.shell, opts.model, runtime),
|
|
2445
|
+
app: game.app,
|
|
2446
|
+
parent: game.uiLayer,
|
|
2447
|
+
};
|
|
2433
2448
|
// The game may swap in its own shell (a custom renderer over the same core) via shellFactory;
|
|
2434
2449
|
// default is the built-in Pixi shell. The host drives whichever it gets through the Shell contract.
|
|
2435
2450
|
shell = (opts.shellFactory ?? createPixiShell)(pixiShellCfg);
|
|
@@ -2442,10 +2457,15 @@ async function createSlotGame(opts) {
|
|
|
2442
2457
|
// async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
|
|
2443
2458
|
// is the single source for both the displayed balance and `ensureAffordable`.
|
|
2444
2459
|
const balanceGate = createBalanceGate((b) => shell.setBalance(b), balance);
|
|
2445
|
-
ps?.on('balanceUpdate', (d) => {
|
|
2460
|
+
ps?.on('balanceUpdate', (d) => {
|
|
2461
|
+
balanceGate.onBalance(d.balance);
|
|
2462
|
+
});
|
|
2446
2463
|
// Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
|
|
2447
2464
|
let currentTurbo = shell.state.turbo;
|
|
2448
|
-
shell.on('turboChange', (level) => {
|
|
2465
|
+
shell.on('turboChange', (level) => {
|
|
2466
|
+
currentTurbo = level;
|
|
2467
|
+
gameScene()?.onTurboChanged?.(level);
|
|
2468
|
+
});
|
|
2449
2469
|
// Double-tap-to-skip is a game-level option (default on), set once via createSlotGame({ skipGesture }).
|
|
2450
2470
|
const skipEnabled = opts.skipGesture ?? true;
|
|
2451
2471
|
// Shell settings → engine state. Sound/volume map onto the AudioManager.
|
|
@@ -2482,21 +2502,38 @@ async function createSlotGame(opts) {
|
|
|
2482
2502
|
sceneApi = {
|
|
2483
2503
|
audio: createSceneAudio(game.audio),
|
|
2484
2504
|
overlay: overlayCtl.overlay,
|
|
2485
|
-
shell: {
|
|
2505
|
+
shell: {
|
|
2506
|
+
get safeArea() {
|
|
2507
|
+
return shell.safeArea;
|
|
2508
|
+
},
|
|
2509
|
+
},
|
|
2486
2510
|
formatAmount: (v) => shell.formatWin(v),
|
|
2487
|
-
get bet() {
|
|
2488
|
-
|
|
2489
|
-
|
|
2511
|
+
get bet() {
|
|
2512
|
+
return currentBet;
|
|
2513
|
+
},
|
|
2514
|
+
get mode() {
|
|
2515
|
+
return opts.model.modeMap['spin'] ?? 'BASE';
|
|
2516
|
+
},
|
|
2517
|
+
get turbo() {
|
|
2518
|
+
return currentTurbo;
|
|
2519
|
+
},
|
|
2490
2520
|
};
|
|
2491
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();
|
|
2492
2527
|
// The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
|
|
2493
2528
|
// attaches its own. So makeContext returns everything BUT `signal`.
|
|
2494
2529
|
const makeContext = (action) => ({
|
|
2495
2530
|
bet: currentBet,
|
|
2496
2531
|
action,
|
|
2497
|
-
mode:
|
|
2532
|
+
mode: segmentModeOf(action),
|
|
2498
2533
|
formatAmount: (v) => shell.formatWin(v),
|
|
2499
|
-
get turbo() {
|
|
2534
|
+
get turbo() {
|
|
2535
|
+
return currentTurbo;
|
|
2536
|
+
},
|
|
2500
2537
|
});
|
|
2501
2538
|
// Play-error + connection handling. A play rejection is classified into a player-facing modal
|
|
2502
2539
|
// (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
|
|
@@ -2512,11 +2549,27 @@ async function createSlotGame(opts) {
|
|
|
2512
2549
|
title: shell.t(v.title),
|
|
2513
2550
|
body: shell.t(v.body),
|
|
2514
2551
|
actions: v.reload
|
|
2515
|
-
? [
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2552
|
+
? [
|
|
2553
|
+
{
|
|
2554
|
+
title: shell.t('Reload'),
|
|
2555
|
+
on: () => {
|
|
2556
|
+
try {
|
|
2557
|
+
window.location.reload();
|
|
2558
|
+
}
|
|
2559
|
+
catch {
|
|
2560
|
+
/* non-browser */
|
|
2561
|
+
}
|
|
2562
|
+
},
|
|
2563
|
+
},
|
|
2564
|
+
]
|
|
2565
|
+
: [
|
|
2566
|
+
{
|
|
2567
|
+
title: shell.t('OK'),
|
|
2568
|
+
on: () => {
|
|
2569
|
+
playErrorOpen = false;
|
|
2570
|
+
},
|
|
2571
|
+
},
|
|
2572
|
+
],
|
|
2520
2573
|
});
|
|
2521
2574
|
};
|
|
2522
2575
|
ps?.on('connectionStateChanged', (s) => {
|
|
@@ -2545,7 +2598,10 @@ async function createSlotGame(opts) {
|
|
|
2545
2598
|
const skip = createDoubleTapSkip({
|
|
2546
2599
|
enabled: () => skipEnabled,
|
|
2547
2600
|
active: () => presenting,
|
|
2548
|
-
onSkip: () => {
|
|
2601
|
+
onSkip: () => {
|
|
2602
|
+
currentSegmentAbort?.abort();
|
|
2603
|
+
gameScene()?.onSkip?.();
|
|
2604
|
+
},
|
|
2549
2605
|
});
|
|
2550
2606
|
// Listen for taps on the scene root (game.worldRoot — the scaled scene container). The shell
|
|
2551
2607
|
// lives on the sibling uiLayer, so its bar taps never reach worldRoot — taps here are the play area.
|
|
@@ -2575,54 +2631,133 @@ async function createSlotGame(opts) {
|
|
|
2575
2631
|
gameScene()?.onResume?.();
|
|
2576
2632
|
},
|
|
2577
2633
|
});
|
|
2634
|
+
/** Push a bonus segment's readout to the shell bar. Default (no `opts.bonus`) = the free-spins
|
|
2635
|
+
* counter (label 'Free spins', value current/total). With `opts.bonus`, the game supplies the
|
|
2636
|
+
* label + value string (adventure / hold-and-spin / respins) and we drive the generic 'bonus'
|
|
2637
|
+
* hero instead — the shell stays free of any per-game bonus concept. */
|
|
2638
|
+
const applyBonusReadout = (result, view, mode) => {
|
|
2639
|
+
const b = opts.bonus;
|
|
2640
|
+
if (!b) {
|
|
2641
|
+
shell.setFreeSpins(view);
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
const label = typeof b.label === 'function' ? b.label(mode) : (b.label ?? 'Free spins');
|
|
2645
|
+
const value = b.readout
|
|
2646
|
+
? b.readout(result, { view, mode })
|
|
2647
|
+
: view.current == null
|
|
2648
|
+
? String(view.total)
|
|
2649
|
+
: `${view.current} / ${view.total}`;
|
|
2650
|
+
shell.setBonus({ label, value, totalWin: view.totalWin });
|
|
2651
|
+
};
|
|
2652
|
+
/** The mode entered via setMode for a bonus — 'bonus' when the game customises the readout,
|
|
2653
|
+
* else 'freeSpins' (the back-compat default the shell already renders). */
|
|
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
|
+
};
|
|
2578
2713
|
/** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
|
|
2579
2714
|
* update only AFTER each onSpin(), per the HUD-timing requirement. */
|
|
2580
2715
|
const playRound = (action) => {
|
|
2581
2716
|
const scene = gameScene();
|
|
2582
2717
|
if (!scene)
|
|
2583
2718
|
return;
|
|
2584
|
-
// Per-round
|
|
2585
|
-
//
|
|
2586
|
-
//
|
|
2587
|
-
|
|
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);
|
|
2588
2723
|
let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
|
|
2589
|
-
const fsCounter = createFreeSpinsCounter();
|
|
2590
2724
|
shell.setBusy(true); // block re-spin / spacebar while the round plays out
|
|
2591
2725
|
presenting = true; // open the skip window for the whole play→drain
|
|
2592
2726
|
// RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
|
|
2593
2727
|
// animation has finished — returning void would reopen it instantly, over a running animation.
|
|
2594
2728
|
return runRound({
|
|
2595
2729
|
// Suppress the debit paint from play() until this segment's afterPresent (HUD timing).
|
|
2596
|
-
play: (a, b, rid) => {
|
|
2730
|
+
play: (a, b, rid) => {
|
|
2731
|
+
balanceGate.beginPlay();
|
|
2732
|
+
return slotPlay$1.play(a, b, rid);
|
|
2733
|
+
},
|
|
2597
2734
|
ack: slotPlay$1.ack,
|
|
2598
2735
|
scene,
|
|
2599
2736
|
context: makeContext,
|
|
2600
|
-
|
|
2737
|
+
modeOf: segmentModeOf,
|
|
2738
|
+
isBonusAction,
|
|
2601
2739
|
// Hand the host the per-segment AbortController so a double-tap can skip the live segment.
|
|
2602
|
-
beforeSegment: (ac) => {
|
|
2740
|
+
beforeSegment: (ac) => {
|
|
2741
|
+
currentSegmentAbort = ac;
|
|
2742
|
+
},
|
|
2603
2743
|
onSpinStart: () => scene.onSpinStart?.(),
|
|
2604
2744
|
onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
|
|
2605
2745
|
afterPresent: (r) => {
|
|
2606
2746
|
// WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
|
|
2607
|
-
//
|
|
2747
|
+
// bonus counter (totalWin) via settle(), not the WIN readout.
|
|
2608
2748
|
shell.setWin(r.totalWin - prevWin);
|
|
2609
2749
|
prevWin = r.totalWin;
|
|
2610
2750
|
balanceGate.afterPresent();
|
|
2611
|
-
|
|
2612
|
-
shell.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
|
|
2613
|
-
},
|
|
2614
|
-
onEnterMode: async (trigger, ctx) => {
|
|
2615
|
-
inBonus = true;
|
|
2616
|
-
shell.setMode('freeSpins');
|
|
2617
|
-
shell.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
|
|
2618
|
-
await scene.onEnterMode?.(trigger, ctx);
|
|
2751
|
+
bonus.settle(r);
|
|
2619
2752
|
},
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2753
|
+
onModeEnter: (mode, trigger, ctx, resumed) => bonus.enter(mode, trigger, ctx, resumed),
|
|
2754
|
+
onModeExit: (mode, last, ctx) => bonus.exit(mode, last, ctx),
|
|
2755
|
+
}, action)
|
|
2756
|
+
.catch(showPlayError)
|
|
2757
|
+
.finally(() => {
|
|
2758
|
+
presenting = false;
|
|
2759
|
+
shell.setBusy(false);
|
|
2760
|
+
});
|
|
2626
2761
|
};
|
|
2627
2762
|
/**
|
|
2628
2763
|
* Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
|
|
@@ -2636,7 +2771,9 @@ async function createSlotGame(opts) {
|
|
|
2636
2771
|
if (!scene || !ps)
|
|
2637
2772
|
return;
|
|
2638
2773
|
// A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
|
|
2639
|
-
// 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).
|
|
2640
2777
|
const ctx = {
|
|
2641
2778
|
...makeContext(firstRaw.action ?? 'spin'),
|
|
2642
2779
|
signal: new AbortController().signal,
|
|
@@ -2657,17 +2794,17 @@ async function createSlotGame(opts) {
|
|
|
2657
2794
|
let inBonus = false;
|
|
2658
2795
|
let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
|
|
2659
2796
|
const applySegment = async () => {
|
|
2660
|
-
// A recovered open round with remaining segments is a bonus → show
|
|
2797
|
+
// A recovered open round with remaining segments is a bonus → show bonus mode + counter.
|
|
2661
2798
|
if (!inBonus && !r.complete) {
|
|
2662
2799
|
inBonus = true;
|
|
2663
|
-
shell.setMode(
|
|
2800
|
+
shell.setMode(bonusShellMode);
|
|
2664
2801
|
}
|
|
2665
2802
|
if (animate)
|
|
2666
2803
|
await scene.onSpin(r, ctx);
|
|
2667
2804
|
if (inBonus) {
|
|
2668
2805
|
const v = fsView(raw, r.totalWin);
|
|
2669
2806
|
if (v)
|
|
2670
|
-
|
|
2807
|
+
applyBonusReadout(r, v, ctx.mode);
|
|
2671
2808
|
}
|
|
2672
2809
|
shell.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
|
|
2673
2810
|
prevWin = r.totalWin;
|
|
@@ -2677,12 +2814,20 @@ async function createSlotGame(opts) {
|
|
|
2677
2814
|
try {
|
|
2678
2815
|
await applySegment();
|
|
2679
2816
|
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
2680
|
-
raw = (await ps.play({
|
|
2817
|
+
raw = (await ps.play({
|
|
2818
|
+
action: r.nextActions[0],
|
|
2819
|
+
bet: ctx.bet,
|
|
2820
|
+
roundId: r.roundId,
|
|
2821
|
+
}));
|
|
2681
2822
|
r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
2682
2823
|
await applySegment();
|
|
2683
2824
|
}
|
|
2684
|
-
if (inBonus)
|
|
2825
|
+
if (inBonus) {
|
|
2685
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
|
+
}
|
|
2686
2831
|
}
|
|
2687
2832
|
finally {
|
|
2688
2833
|
shell.setBusy(false);
|
|
@@ -2690,8 +2835,12 @@ async function createSlotGame(opts) {
|
|
|
2690
2835
|
};
|
|
2691
2836
|
if (mode === 'base') {
|
|
2692
2837
|
let activeFeature = null;
|
|
2693
|
-
shell.on('featureActivate', ({ id }) => {
|
|
2694
|
-
|
|
2838
|
+
shell.on('featureActivate', ({ id }) => {
|
|
2839
|
+
activeFeature = id;
|
|
2840
|
+
});
|
|
2841
|
+
shell.on('featureDeactivate', ({ id: _id }) => {
|
|
2842
|
+
activeFeature = null;
|
|
2843
|
+
});
|
|
2695
2844
|
const { stakeForAction } = await Promise.resolve().then(function () { return shellConfig; });
|
|
2696
2845
|
// Guard a play: if the stake exceeds the balance, show a shell modal and DON'T play.
|
|
2697
2846
|
const ensureAffordable = (action) => {
|
|
@@ -2711,7 +2860,10 @@ async function createSlotGame(opts) {
|
|
|
2711
2860
|
return;
|
|
2712
2861
|
void playRound(action);
|
|
2713
2862
|
});
|
|
2714
|
-
shell.on('betChange', (bet) => {
|
|
2863
|
+
shell.on('betChange', (bet) => {
|
|
2864
|
+
currentBet = bet;
|
|
2865
|
+
gameScene()?.onBetChanged?.(bet);
|
|
2866
|
+
});
|
|
2715
2867
|
shell.on('buyBonusSelect', ({ id }) => {
|
|
2716
2868
|
if (!ensureAffordable(id))
|
|
2717
2869
|
return;
|
|
@@ -2742,7 +2894,7 @@ async function createSlotGame(opts) {
|
|
|
2742
2894
|
resumeOffered = true;
|
|
2743
2895
|
let snap = null;
|
|
2744
2896
|
try {
|
|
2745
|
-
snap = await ps?.getState() ?? null;
|
|
2897
|
+
snap = (await ps?.getState()) ?? null;
|
|
2746
2898
|
}
|
|
2747
2899
|
catch {
|
|
2748
2900
|
snap = null;
|
|
@@ -2755,13 +2907,25 @@ async function createSlotGame(opts) {
|
|
|
2755
2907
|
body: shell.t('You have an unfinished round. Continue it or finish it now?'),
|
|
2756
2908
|
actions: [
|
|
2757
2909
|
// Continue: replay the round from the start with animation, then settle.
|
|
2758
|
-
{
|
|
2910
|
+
{
|
|
2911
|
+
title: shell.t('Continue'),
|
|
2912
|
+
on: () => {
|
|
2913
|
+
void resumeDrain(snap, true);
|
|
2914
|
+
},
|
|
2915
|
+
},
|
|
2759
2916
|
// Finish: fast-forward the remaining segments (no animation) to settle the win now.
|
|
2760
|
-
{
|
|
2917
|
+
{
|
|
2918
|
+
title: shell.t('Finish'),
|
|
2919
|
+
on: () => {
|
|
2920
|
+
void resumeDrain(snap, false);
|
|
2921
|
+
},
|
|
2922
|
+
},
|
|
2761
2923
|
],
|
|
2762
2924
|
});
|
|
2763
2925
|
};
|
|
2764
|
-
game.scenes.on('change', () => {
|
|
2926
|
+
game.scenes.on('change', () => {
|
|
2927
|
+
void offerResume();
|
|
2928
|
+
});
|
|
2765
2929
|
void offerResume();
|
|
2766
2930
|
}
|
|
2767
2931
|
else {
|
|
@@ -3228,36 +3392,75 @@ var slotPlay = /*#__PURE__*/Object.freeze({
|
|
|
3228
3392
|
enrichRoundMeta: enrichRoundMeta
|
|
3229
3393
|
});
|
|
3230
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
|
+
}
|
|
3231
3417
|
async function runRound(deps, action) {
|
|
3232
3418
|
deps.onSpinStart?.();
|
|
3233
3419
|
const ctxBet = deps.context(action).bet;
|
|
3420
|
+
const modeOf = deps.modeOf ?? ((a) => a.toUpperCase());
|
|
3421
|
+
const isBonus = deps.isBonusAction ?? (() => false);
|
|
3234
3422
|
const segment = async (a, roundId) => {
|
|
3235
3423
|
const ac = new AbortController();
|
|
3236
3424
|
deps.beforeSegment?.(ac);
|
|
3237
3425
|
const r = await deps.play(a, ctxBet, roundId);
|
|
3426
|
+
// ctx carries the ROUND identity (built from the round action), stable across drained segments.
|
|
3238
3427
|
const ctx = { ...deps.context(action), signal: ac.signal };
|
|
3239
3428
|
await deps.scene.onSpin(r, ctx);
|
|
3240
3429
|
deps.ack();
|
|
3241
3430
|
deps.afterPresent?.(r);
|
|
3242
3431
|
return { r, ctx };
|
|
3243
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
|
+
};
|
|
3244
3451
|
let { r, ctx } = await segment(action, undefined);
|
|
3245
|
-
let inMode = false;
|
|
3246
3452
|
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
3247
3453
|
const next = r.nextActions[0];
|
|
3248
|
-
|
|
3249
|
-
inMode = true;
|
|
3250
|
-
await deps.onEnterMode?.(r, ctx);
|
|
3251
|
-
}
|
|
3454
|
+
await transition(next, r, ctx);
|
|
3252
3455
|
({ r, ctx } = await segment(next, r.roundId));
|
|
3253
3456
|
}
|
|
3254
|
-
|
|
3255
|
-
await deps.onExitMode?.(r, ctx);
|
|
3457
|
+
await transition(null, r, ctx); // round end: unwind any remaining levels to base
|
|
3256
3458
|
deps.onSpinEnd?.(r, ctx);
|
|
3257
3459
|
}
|
|
3258
3460
|
|
|
3259
3461
|
var runRound$1 = /*#__PURE__*/Object.freeze({
|
|
3260
3462
|
__proto__: null,
|
|
3463
|
+
planTransition: planTransition,
|
|
3261
3464
|
runRound: runRound
|
|
3262
3465
|
});
|
|
3263
3466
|
|