@energy8platform/game-engine 0.38.0 → 0.40.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/README.md +82 -2
- package/dist/devtools.cjs.js +36 -4
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.d.ts +67 -0
- package/dist/devtools.esm.js +36 -4
- package/dist/devtools.esm.js.map +1 -1
- package/dist/host.cjs.js +276 -109
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +5 -0
- package/dist/host.esm.js +276 -109
- package/dist/host.esm.js.map +1 -1
- package/dist/reel-panel-client.cjs.js +36 -4
- package/dist/reel-panel-client.cjs.js.map +1 -1
- package/dist/reel-panel-client.esm.js +36 -4
- package/dist/reel-panel-client.esm.js.map +1 -1
- package/dist/slot.cjs.js +229 -43
- package/dist/slot.cjs.js.map +1 -1
- package/dist/slot.d.ts +123 -8
- package/dist/slot.esm.js +229 -44
- package/dist/slot.esm.js.map +1 -1
- package/package.json +2 -2
- package/src/host/autoplay.ts +25 -3
- package/src/host/connectionRecovery.ts +113 -0
- package/src/host/createSlotGame.ts +83 -103
- package/src/host/playError.ts +23 -0
- package/src/host/resumeDrain.ts +156 -0
- package/src/host/shellConfig.ts +9 -0
- package/src/slot/config/ReelSystemConfig.ts +96 -4
- package/src/slot/devtools/fieldSchema.ts +7 -0
- package/src/slot/index.ts +5 -0
- package/src/slot/motion/AnticipationController.ts +62 -17
- package/src/slot/motion/SpinEngine.ts +151 -23
- package/src/slot/system/ReelSystem.ts +59 -9
package/dist/host.esm.js
CHANGED
|
@@ -2691,6 +2691,8 @@ async function createSlotGame(opts) {
|
|
|
2691
2691
|
social: config?.socialMode,
|
|
2692
2692
|
disclaimerLines: config?.disclaimerLines,
|
|
2693
2693
|
jurisdiction: config?.jurisdiction,
|
|
2694
|
+
// Decides whether the shell offers a keyboard at all — see buildShellConfig.
|
|
2695
|
+
device: initData?.device,
|
|
2694
2696
|
// Currency-specific ladder + per-currency default from /wallet/authenticate (Stake) or the
|
|
2695
2697
|
// backend's `allowed_bets` (Artube); absent on dev/devBridge → buildShellConfig falls back to
|
|
2696
2698
|
// the spec.
|
|
@@ -2841,33 +2843,41 @@ async function createSlotGame(opts) {
|
|
|
2841
2843
|
return currentTurbo;
|
|
2842
2844
|
},
|
|
2843
2845
|
});
|
|
2844
|
-
// Play-error + connection handling. A play rejection is classified
|
|
2845
|
-
// (ACTIVE_SESSION_EXISTS → Reload, etc.)
|
|
2846
|
-
// overlay
|
|
2846
|
+
// Play-error + connection handling. A play rejection is classified (playError.ts): a failed
|
|
2847
|
+
// ROUND gets a player-facing modal (ACTIVE_SESSION_EXISTS → Reload, etc.), a failed LINK gets
|
|
2848
|
+
// none — the reconnect overlay owns that screen (connectionRecovery.ts). Either way the failure
|
|
2849
|
+
// halts an autoplay run WITHOUT clearing its counter. The overlay stays suppressed while a
|
|
2850
|
+
// play-error modal owns the screen.
|
|
2847
2851
|
let playErrorOpen = false;
|
|
2848
2852
|
let stopAutoplay = () => { }; // wired to the autoplay loop once it's created (below)
|
|
2853
|
+
let haltAutoplay = () => { }; // ditto — stops the run but KEEPS its counter
|
|
2854
|
+
const reload = () => {
|
|
2855
|
+
try {
|
|
2856
|
+
window.location.reload();
|
|
2857
|
+
}
|
|
2858
|
+
catch {
|
|
2859
|
+
/* non-browser */
|
|
2860
|
+
}
|
|
2861
|
+
};
|
|
2849
2862
|
const showPlayError = (err) => {
|
|
2850
|
-
stopAutoplay(); // a play error halts an autoplay run (the .catch swallows, so stop explicitly)
|
|
2851
2863
|
const v = resolvePlayError(err);
|
|
2864
|
+
// Halt, don't stop: the spins the player still has coming outlive the failure, so the bar can
|
|
2865
|
+
// show them (and offer to resume). The .catch in playRound swallows the rejection, so the
|
|
2866
|
+
// autoplay loop can't see it — this is what ends the run.
|
|
2867
|
+
haltAutoplay();
|
|
2868
|
+
// A failed LINK is not a failed round. The bridge is already reconnecting and the connection
|
|
2869
|
+
// overlay owns the screen; a modal here is exactly the "reload the page" screen that showed
|
|
2870
|
+
// up on every network blip — and only in autoplay, since only autoplay always has a play in
|
|
2871
|
+
// flight when the socket dies.
|
|
2872
|
+
if (v.connection)
|
|
2873
|
+
return;
|
|
2852
2874
|
playErrorOpen = true;
|
|
2853
2875
|
shell.openModal({
|
|
2854
2876
|
availableClose: !v.reload,
|
|
2855
2877
|
title: shell.t(v.title),
|
|
2856
2878
|
body: shell.t(v.body),
|
|
2857
2879
|
actions: v.reload
|
|
2858
|
-
? [
|
|
2859
|
-
{
|
|
2860
|
-
title: shell.t('Reload'),
|
|
2861
|
-
on: () => {
|
|
2862
|
-
try {
|
|
2863
|
-
window.location.reload();
|
|
2864
|
-
}
|
|
2865
|
-
catch {
|
|
2866
|
-
/* non-browser */
|
|
2867
|
-
}
|
|
2868
|
-
},
|
|
2869
|
-
},
|
|
2870
|
-
]
|
|
2880
|
+
? [{ title: shell.t('Reload'), on: reload }]
|
|
2871
2881
|
: [
|
|
2872
2882
|
{
|
|
2873
2883
|
title: shell.t('OK'),
|
|
@@ -2878,20 +2888,31 @@ async function createSlotGame(opts) {
|
|
|
2878
2888
|
],
|
|
2879
2889
|
});
|
|
2880
2890
|
};
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
return; // a play-error modal owns the screen — don't mask it with "reconnecting"
|
|
2889
|
-
shell.openModal({
|
|
2891
|
+
// Connection loss/return. Assigned below, once `resumeDrain` exists: a restored link finishes
|
|
2892
|
+
// the round the drop interrupted, and it must not reach the drain before it is defined.
|
|
2893
|
+
let recoverRound = async () => { };
|
|
2894
|
+
const { createConnectionRecovery } = await Promise.resolve().then(function () { return connectionRecovery; });
|
|
2895
|
+
const connection = createConnectionRecovery({
|
|
2896
|
+
haltAutoplay: () => haltAutoplay(),
|
|
2897
|
+
showReconnecting: () => shell.openModal({
|
|
2890
2898
|
availableClose: false,
|
|
2891
2899
|
title: shell.t('Reconnecting…'),
|
|
2892
2900
|
body: shell.t('Lost connection to the game server. Trying to reconnect…'),
|
|
2893
|
-
})
|
|
2901
|
+
}),
|
|
2902
|
+
// The bridge stopped retrying: say so instead of leaving "Reconnecting…" up for good.
|
|
2903
|
+
showGone: () => shell.openModal({
|
|
2904
|
+
availableClose: false,
|
|
2905
|
+
title: shell.t('Connection lost'),
|
|
2906
|
+
body: shell.t('Could not reconnect to the game server. Please reload the game.'),
|
|
2907
|
+
actions: [{ title: shell.t('Reload'), on: reload }],
|
|
2908
|
+
}),
|
|
2909
|
+
dismiss: () => shell.closeModal(),
|
|
2910
|
+
getState: async () => (await ps?.getState()) ?? null,
|
|
2911
|
+
drain: (snapshot) => recoverRound(snapshot),
|
|
2912
|
+
onError: showPlayError,
|
|
2913
|
+
isBlocked: () => playErrorOpen,
|
|
2894
2914
|
});
|
|
2915
|
+
ps?.on('connectionStateChanged', (s) => void connection.onState(s));
|
|
2895
2916
|
// Skip state: `currentSegmentAbort` is the controller for the segment presently animating;
|
|
2896
2917
|
// `presenting` is true for the whole play→drain window (gates the double-tap detector so taps
|
|
2897
2918
|
// only skip while a round is animating).
|
|
@@ -3075,85 +3096,27 @@ async function createSlotGame(opts) {
|
|
|
3075
3096
|
shell.setBusy(false);
|
|
3076
3097
|
});
|
|
3077
3098
|
};
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
if (!s)
|
|
3100
|
-
return null;
|
|
3101
|
-
// The bridge session counts ALL segments incl. the trigger (segment 0); the free-spins
|
|
3102
|
-
// counter is over FREE spins only, so drop the one trigger segment → 1/10, not 2/11.
|
|
3103
|
-
const played = s.spinsPlayed ?? 0;
|
|
3104
|
-
const current = Math.max(0, played - 1);
|
|
3105
|
-
const total = Math.max(0, played + (s.spinsRemaining ?? 0) - 1);
|
|
3106
|
-
return { current, total, totalWin };
|
|
3107
|
-
};
|
|
3108
|
-
let raw = firstRaw;
|
|
3109
|
-
let r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
3110
|
-
let inBonus = false;
|
|
3111
|
-
let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
|
|
3112
|
-
const applySegment = async () => {
|
|
3113
|
-
// A recovered open round with remaining segments is a bonus → show bonus mode + counter.
|
|
3114
|
-
if (!inBonus && !r.complete) {
|
|
3115
|
-
inBonus = true;
|
|
3116
|
-
shell.setMode(bonusShellMode);
|
|
3117
|
-
}
|
|
3118
|
-
shell.setWin(0, { animate: false }); // clear WIN before this segment animates (see playRound)
|
|
3119
|
-
if (animate)
|
|
3120
|
-
winReporter$1.open(); // a fast-forward drain doesn't present → no reports expected
|
|
3121
|
-
if (animate)
|
|
3122
|
-
await scene.onSpin(r, ctx);
|
|
3123
|
-
if (inBonus) {
|
|
3124
|
-
const v = fsView(raw, r.totalWin);
|
|
3125
|
-
if (v)
|
|
3126
|
-
applyBonusReadout(r, v, ctx.mode);
|
|
3127
|
-
}
|
|
3128
|
-
winReporter$1.close();
|
|
3129
|
-
shell.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
|
|
3130
|
-
prevWin = r.totalWin;
|
|
3131
|
-
ps.playAck(raw); // settles via /wallet/end-round on the FINAL segment
|
|
3132
|
-
};
|
|
3133
|
-
shell.setBusy(true); // block input while the recovered round drains
|
|
3134
|
-
try {
|
|
3135
|
-
await applySegment();
|
|
3136
|
-
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
3137
|
-
raw = (await ps.play({
|
|
3138
|
-
action: r.nextActions[0],
|
|
3139
|
-
bet: ctx.bet,
|
|
3140
|
-
roundId: r.roundId,
|
|
3141
|
-
}));
|
|
3142
|
-
r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
3143
|
-
await applySegment();
|
|
3144
|
-
}
|
|
3145
|
-
if (inBonus) {
|
|
3146
|
-
shell.setMode('base');
|
|
3147
|
-
// Same as playRound: on return to base the WIN readout must show the round's cumulative
|
|
3148
|
-
// total (r is the final drained segment), not the last segment's per-spin delta.
|
|
3149
|
-
shell.setWin(r.totalWin);
|
|
3150
|
-
}
|
|
3151
|
-
}
|
|
3152
|
-
finally {
|
|
3153
|
-
winReporter$1.close(); // also closes the window when a drained segment threw
|
|
3154
|
-
shell.setBusy(false);
|
|
3155
|
-
}
|
|
3156
|
-
};
|
|
3099
|
+
// Drain a recovered open round (reload / dropped connection) to settlement — see resumeDrain.ts.
|
|
3100
|
+
// `scene` folds in the old `!ps` guard: with no session there is nothing to play or ack.
|
|
3101
|
+
const { createResumeDrain } = await Promise.resolve().then(function () { return resumeDrain; });
|
|
3102
|
+
const resumeDrain$1 = createResumeDrain({
|
|
3103
|
+
scene: () => (ps ? gameScene() : undefined),
|
|
3104
|
+
play: (req) => ps.play(req),
|
|
3105
|
+
ack: (raw) => ps.playAck(raw),
|
|
3106
|
+
normalize: opts.normalize,
|
|
3107
|
+
context: makeContext,
|
|
3108
|
+
setWin: (amount, o) => shell.setWin(amount, o),
|
|
3109
|
+
setMode: (m) => shell.setMode(m),
|
|
3110
|
+
setBusy: (b) => shell.setBusy(b),
|
|
3111
|
+
winReporter: winReporter$1,
|
|
3112
|
+
applyBonusReadout,
|
|
3113
|
+
bonusMode: bonusShellMode,
|
|
3114
|
+
});
|
|
3115
|
+
// A round interrupted by a dropped connection is finished the same way a round interrupted by a
|
|
3116
|
+
// reload is — animated, through to settlement — except nothing asks the player first: it is
|
|
3117
|
+
// their own round, and an extra confirmation screen is what the reconnect flow is meant to
|
|
3118
|
+
// avoid. (The reload path keeps its Continue/Finish offer: there, the player has been away.)
|
|
3119
|
+
recoverRound = (snapshot) => resumeDrain$1(snapshot, true);
|
|
3157
3120
|
if (mode === 'base') {
|
|
3158
3121
|
let activeFeature = null;
|
|
3159
3122
|
shell.on('featureActivate', ({ id }) => {
|
|
@@ -3179,6 +3142,9 @@ async function createSlotGame(opts) {
|
|
|
3179
3142
|
const action = activeFeature ?? 'spin';
|
|
3180
3143
|
if (!ensureAffordable(action))
|
|
3181
3144
|
return;
|
|
3145
|
+
// Spinning by hand retires whatever a halted run left on the counter — the player moved on.
|
|
3146
|
+
// (No-op unless a run was halted: `stop()` returns early when there is nothing to clear.)
|
|
3147
|
+
stopAutoplay();
|
|
3182
3148
|
void playRound(action);
|
|
3183
3149
|
});
|
|
3184
3150
|
shell.on('betChange', (bet) => {
|
|
@@ -3188,6 +3154,7 @@ async function createSlotGame(opts) {
|
|
|
3188
3154
|
shell.on('buyBonusSelect', ({ id }) => {
|
|
3189
3155
|
if (!ensureAffordable(id))
|
|
3190
3156
|
return;
|
|
3157
|
+
stopAutoplay(); // as with a manual spin: a bought bonus retires a halted run's counter
|
|
3191
3158
|
void playRound(id);
|
|
3192
3159
|
});
|
|
3193
3160
|
// Autoplay: the shell owns the picker/confirm/STOP/counter/lockout (all driven by state.autoplay);
|
|
@@ -3203,6 +3170,7 @@ async function createSlotGame(opts) {
|
|
|
3203
3170
|
},
|
|
3204
3171
|
});
|
|
3205
3172
|
stopAutoplay = () => autoplay$1.stop();
|
|
3173
|
+
haltAutoplay = () => autoplay$1.halt();
|
|
3206
3174
|
shell.on('autoplayStart', (o) => autoplay$1.start(o?.remaining ?? 0));
|
|
3207
3175
|
shell.on('autoplayStop', () => autoplay$1.stop());
|
|
3208
3176
|
// Resume offer: when the game scene is (or becomes) current on a reload, ask the host whether
|
|
@@ -3231,14 +3199,14 @@ async function createSlotGame(opts) {
|
|
|
3231
3199
|
{
|
|
3232
3200
|
title: shell.t('Continue'),
|
|
3233
3201
|
on: () => {
|
|
3234
|
-
void resumeDrain(snap, true);
|
|
3202
|
+
void resumeDrain$1(snap, true);
|
|
3235
3203
|
},
|
|
3236
3204
|
},
|
|
3237
3205
|
// Finish: fast-forward the remaining segments (no animation) to settle the win now.
|
|
3238
3206
|
{
|
|
3239
3207
|
title: shell.t('Finish'),
|
|
3240
3208
|
on: () => {
|
|
3241
|
-
void resumeDrain(snap, false);
|
|
3209
|
+
void resumeDrain$1(snap, false);
|
|
3242
3210
|
},
|
|
3243
3211
|
},
|
|
3244
3212
|
],
|
|
@@ -3660,6 +3628,11 @@ function buildShellConfig(opts, model, runtime) {
|
|
|
3660
3628
|
...(opts.features ?? {}),
|
|
3661
3629
|
};
|
|
3662
3630
|
applyJurisdiction(features, runtime.jurisdiction);
|
|
3631
|
+
// A phone has no keys to press: no Spacebar-to-spin, and no keycap chart advertising it. Applied
|
|
3632
|
+
// after the author's features for the same reason a jurisdiction restriction is — the platform
|
|
3633
|
+
// knows what it launched us on, and a game can't opt out of the hardware.
|
|
3634
|
+
if (runtime.device === 'mobile')
|
|
3635
|
+
features.hotkeys = false;
|
|
3663
3636
|
return {
|
|
3664
3637
|
language: runtime.language ?? 'en',
|
|
3665
3638
|
isSocial,
|
|
@@ -4057,6 +4030,13 @@ var freeSpinsCounter = /*#__PURE__*/Object.freeze({
|
|
|
4057
4030
|
createFreeSpinsCounter: createFreeSpinsCounter
|
|
4058
4031
|
});
|
|
4059
4032
|
|
|
4033
|
+
/**
|
|
4034
|
+
* Codes that mean "the connection died", per bridge:
|
|
4035
|
+
* - `ConnectionLost` / `ConnectionFailed` — Artube's WS client (a pending play on a dropped
|
|
4036
|
+
* socket, or a play attempted while the socket is down);
|
|
4037
|
+
* - `ERR_NET` — Stake's RGS client, after its retries are exhausted on a network-level failure.
|
|
4038
|
+
*/
|
|
4039
|
+
const CONNECTION_CODES = new Set(['ConnectionLost', 'ConnectionFailed', 'ERR_NET']);
|
|
4060
4040
|
/** Pull a Stake/SDK error code off an unknown thrown value. */
|
|
4061
4041
|
function errorCode(err) {
|
|
4062
4042
|
const code = err?.code;
|
|
@@ -4065,6 +4045,14 @@ function errorCode(err) {
|
|
|
4065
4045
|
function resolvePlayError(err) {
|
|
4066
4046
|
const code = errorCode(err);
|
|
4067
4047
|
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
|
4048
|
+
if (code && CONNECTION_CODES.has(code)) {
|
|
4049
|
+
return {
|
|
4050
|
+
title: 'Connection lost',
|
|
4051
|
+
body: 'Lost connection to the game server. Trying to reconnect…',
|
|
4052
|
+
reload: false,
|
|
4053
|
+
connection: true,
|
|
4054
|
+
};
|
|
4055
|
+
}
|
|
4068
4056
|
switch (code) {
|
|
4069
4057
|
case 'ACTIVE_SESSION_EXISTS':
|
|
4070
4058
|
return {
|
|
@@ -4188,6 +4176,66 @@ var overlayController = /*#__PURE__*/Object.freeze({
|
|
|
4188
4176
|
createOverlayController: createOverlayController
|
|
4189
4177
|
});
|
|
4190
4178
|
|
|
4179
|
+
// packages/game-engine/src/host/connectionRecovery.ts
|
|
4180
|
+
/** The bridge has stopped retrying — see `ArtubeClient`'s exhausted reconnect loop. */
|
|
4181
|
+
const GONE = 'ConnectionGone';
|
|
4182
|
+
function createConnectionRecovery(deps) {
|
|
4183
|
+
let linkLost = false;
|
|
4184
|
+
const blocked = () => deps.isBlocked?.() ?? false;
|
|
4185
|
+
async function recover() {
|
|
4186
|
+
let snapshot = null;
|
|
4187
|
+
try {
|
|
4188
|
+
snapshot = await deps.getState();
|
|
4189
|
+
}
|
|
4190
|
+
catch {
|
|
4191
|
+
// The platform couldn't tell us. Nothing to finish, and nothing worth a screen: the player
|
|
4192
|
+
// is back in the game, and the next play resolves the open round (or refuses it, loudly).
|
|
4193
|
+
return;
|
|
4194
|
+
}
|
|
4195
|
+
if (!snapshot)
|
|
4196
|
+
return;
|
|
4197
|
+
try {
|
|
4198
|
+
await deps.drain(snapshot);
|
|
4199
|
+
}
|
|
4200
|
+
catch (err) {
|
|
4201
|
+
deps.onError(err);
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
4204
|
+
return {
|
|
4205
|
+
async onState(state) {
|
|
4206
|
+
if (state.status === 'connecting')
|
|
4207
|
+
return; // in transit — the screen stays as it is
|
|
4208
|
+
if (state.status === 'lost') {
|
|
4209
|
+
// Halt first: it is what the remark asks for, and it holds even when a modal already owns
|
|
4210
|
+
// the screen and the overlay below is skipped.
|
|
4211
|
+
deps.haltAutoplay();
|
|
4212
|
+
if (state.code === GONE) {
|
|
4213
|
+
if (!blocked())
|
|
4214
|
+
deps.showGone();
|
|
4215
|
+
return;
|
|
4216
|
+
}
|
|
4217
|
+
if (linkLost)
|
|
4218
|
+
return; // already announced — reconnect attempts don't restack the overlay
|
|
4219
|
+
linkLost = true;
|
|
4220
|
+
if (!blocked())
|
|
4221
|
+
deps.showReconnecting();
|
|
4222
|
+
return;
|
|
4223
|
+
}
|
|
4224
|
+
if (!linkLost)
|
|
4225
|
+
return; // nothing of ours is on screen; nothing of ours to recover
|
|
4226
|
+
linkLost = false;
|
|
4227
|
+
if (!blocked())
|
|
4228
|
+
deps.dismiss();
|
|
4229
|
+
await recover();
|
|
4230
|
+
},
|
|
4231
|
+
};
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
var connectionRecovery = /*#__PURE__*/Object.freeze({
|
|
4235
|
+
__proto__: null,
|
|
4236
|
+
createConnectionRecovery: createConnectionRecovery
|
|
4237
|
+
});
|
|
4238
|
+
|
|
4191
4239
|
/** Pure double-tap recognizer. The host feeds it pointer `tap(now)` (e.g. performance.now()) and
|
|
4192
4240
|
* supplies the enabled/active gates + the onSkip effect. */
|
|
4193
4241
|
function createDoubleTapSkip(deps) {
|
|
@@ -4231,6 +4279,116 @@ var pauseController = /*#__PURE__*/Object.freeze({
|
|
|
4231
4279
|
createPauseController: createPauseController
|
|
4232
4280
|
});
|
|
4233
4281
|
|
|
4282
|
+
// packages/game-engine/src/host/resumeDrain.ts
|
|
4283
|
+
/**
|
|
4284
|
+
* Rebuild the free-spins counter from a resumed segment's session counts.
|
|
4285
|
+
*
|
|
4286
|
+
* The bridge session counts ALL segments including the trigger (segment 0); the free-spins counter
|
|
4287
|
+
* is over FREE spins only, so one trigger segment is dropped → `1 / 10`, not `2 / 11`. `null` when
|
|
4288
|
+
* the snapshot carries no session at all — there is nothing to count, and painting zeroes would be
|
|
4289
|
+
* a claim, not a reading.
|
|
4290
|
+
*/
|
|
4291
|
+
function resumeBonusView(raw, totalWin) {
|
|
4292
|
+
const s = raw?.session;
|
|
4293
|
+
if (!s)
|
|
4294
|
+
return null;
|
|
4295
|
+
const played = s.spinsPlayed ?? 0;
|
|
4296
|
+
return {
|
|
4297
|
+
current: Math.max(0, played - 1),
|
|
4298
|
+
total: Math.max(0, played + (s.spinsRemaining ?? 0) - 1),
|
|
4299
|
+
totalWin,
|
|
4300
|
+
};
|
|
4301
|
+
}
|
|
4302
|
+
/**
|
|
4303
|
+
* Fold a segment's own free-spins counts over the session-derived view.
|
|
4304
|
+
*
|
|
4305
|
+
* Same precedence live play uses (`overrideView` in createSlotGame): when the book states the count
|
|
4306
|
+
* outright, it is the authority — the session's segment arithmetic is only a reconstruction, and it
|
|
4307
|
+
* assumes a shape (exactly one trigger segment ahead of the free spins) that not every game has.
|
|
4308
|
+
*/
|
|
4309
|
+
function overrideWithBook(view, fs) {
|
|
4310
|
+
if (!fs || (fs.total == null && fs.remaining == null))
|
|
4311
|
+
return view;
|
|
4312
|
+
const base = view ?? { current: 0, total: 0, totalWin: 0 };
|
|
4313
|
+
const total = fs.total ?? base.total;
|
|
4314
|
+
const current = fs.remaining != null ? Math.max(0, total - fs.remaining) : base.current;
|
|
4315
|
+
return { current, total, totalWin: base.totalWin };
|
|
4316
|
+
}
|
|
4317
|
+
function createResumeDrain(deps) {
|
|
4318
|
+
return async function drain(firstRaw, animate) {
|
|
4319
|
+
const scene = deps.scene();
|
|
4320
|
+
if (!scene)
|
|
4321
|
+
return;
|
|
4322
|
+
// A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
|
|
4323
|
+
// never-aborted signal to satisfy onSpin's RenderContext. ctx carries the round identity (built
|
|
4324
|
+
// once from the trigger action) — recovery drains a single flat bonus using the bridge session
|
|
4325
|
+
// counts; the full per-level nesting is a LIVE-play concern (playRound).
|
|
4326
|
+
const ctx = {
|
|
4327
|
+
...deps.context(firstRaw.action ?? 'spin'),
|
|
4328
|
+
signal: new AbortController().signal,
|
|
4329
|
+
};
|
|
4330
|
+
let raw = firstRaw;
|
|
4331
|
+
let r = enrichRoundMeta(deps.normalize(raw), raw);
|
|
4332
|
+
let inBonus = false;
|
|
4333
|
+
let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
|
|
4334
|
+
/** Push the current segment's counts to the bar (book counts win over the session's). */
|
|
4335
|
+
const paintCounter = () => {
|
|
4336
|
+
const view = overrideWithBook(resumeBonusView(raw, r.totalWin), r.freeSpins);
|
|
4337
|
+
if (view)
|
|
4338
|
+
deps.applyBonusReadout(r, view, ctx.mode);
|
|
4339
|
+
};
|
|
4340
|
+
const applySegment = async () => {
|
|
4341
|
+
// A recovered open round with remaining segments is a bonus → show bonus mode + counter.
|
|
4342
|
+
if (!inBonus && !r.complete) {
|
|
4343
|
+
inBonus = true;
|
|
4344
|
+
deps.setMode(deps.bonusMode);
|
|
4345
|
+
// BEFORE the segment animates, not after. The bar has just switched to its bonus face, and
|
|
4346
|
+
// its counter still holds the zeroes it was born with; leaving it that way means the player
|
|
4347
|
+
// watches a whole free spin play out under `0 / 0` and only then sees where the round
|
|
4348
|
+
// actually stands. The snapshot already carries the counts — paint them.
|
|
4349
|
+
paintCounter();
|
|
4350
|
+
}
|
|
4351
|
+
deps.setWin(0, { animate: false }); // clear WIN before this segment animates (see playRound)
|
|
4352
|
+
if (animate)
|
|
4353
|
+
deps.winReporter.open(); // a fast-forward drain doesn't present → no reports expected
|
|
4354
|
+
if (animate)
|
|
4355
|
+
await scene.onSpin(r, ctx);
|
|
4356
|
+
if (inBonus)
|
|
4357
|
+
paintCounter();
|
|
4358
|
+
deps.winReporter.close();
|
|
4359
|
+
deps.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
|
|
4360
|
+
prevWin = r.totalWin;
|
|
4361
|
+
deps.ack(raw); // settles via /wallet/end-round on the FINAL segment
|
|
4362
|
+
};
|
|
4363
|
+
deps.setBusy(true); // block input while the recovered round drains
|
|
4364
|
+
try {
|
|
4365
|
+
await applySegment();
|
|
4366
|
+
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
4367
|
+
raw = await deps.play({ action: r.nextActions[0], bet: ctx.bet, roundId: r.roundId });
|
|
4368
|
+
r = enrichRoundMeta(deps.normalize(raw), raw);
|
|
4369
|
+
await applySegment();
|
|
4370
|
+
}
|
|
4371
|
+
if (inBonus) {
|
|
4372
|
+
deps.setMode('base');
|
|
4373
|
+
// Same as playRound: on return to base the WIN readout must show the round's cumulative
|
|
4374
|
+
// total (r is the final drained segment), not the last segment's per-spin delta.
|
|
4375
|
+
deps.setWin(r.totalWin);
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4378
|
+
finally {
|
|
4379
|
+
deps.winReporter.close(); // also closes the window when a drained segment threw
|
|
4380
|
+
deps.setBusy(false);
|
|
4381
|
+
}
|
|
4382
|
+
};
|
|
4383
|
+
}
|
|
4384
|
+
|
|
4385
|
+
var resumeDrain = /*#__PURE__*/Object.freeze({
|
|
4386
|
+
__proto__: null,
|
|
4387
|
+
createResumeDrain: createResumeDrain,
|
|
4388
|
+
overrideWithBook: overrideWithBook,
|
|
4389
|
+
resumeBonusView: resumeBonusView
|
|
4390
|
+
});
|
|
4391
|
+
|
|
4234
4392
|
function createAutoplayLoop(deps) {
|
|
4235
4393
|
let active = false;
|
|
4236
4394
|
let remaining = 0;
|
|
@@ -4242,6 +4400,12 @@ function createAutoplayLoop(deps) {
|
|
|
4242
4400
|
remaining = 0;
|
|
4243
4401
|
deps.onState({ active: false, remaining: 0 });
|
|
4244
4402
|
};
|
|
4403
|
+
const halt = () => {
|
|
4404
|
+
if (!active)
|
|
4405
|
+
return; // nothing running — nothing to preserve
|
|
4406
|
+
active = false;
|
|
4407
|
+
deps.onState({ active: false, remaining });
|
|
4408
|
+
};
|
|
4245
4409
|
async function loop() {
|
|
4246
4410
|
if (running)
|
|
4247
4411
|
return;
|
|
@@ -4260,7 +4424,9 @@ function createAutoplayLoop(deps) {
|
|
|
4260
4424
|
await deps.playRound(action);
|
|
4261
4425
|
}
|
|
4262
4426
|
catch {
|
|
4263
|
-
|
|
4427
|
+
// The round failed (the host surfaces its own message, if any is due). Halt rather than
|
|
4428
|
+
// stop: a connection blip must not eat the spins the player still has coming.
|
|
4429
|
+
halt();
|
|
4264
4430
|
return;
|
|
4265
4431
|
}
|
|
4266
4432
|
}
|
|
@@ -4281,6 +4447,7 @@ function createAutoplayLoop(deps) {
|
|
|
4281
4447
|
void loop();
|
|
4282
4448
|
},
|
|
4283
4449
|
stop,
|
|
4450
|
+
halt,
|
|
4284
4451
|
get active() { return active; },
|
|
4285
4452
|
get remaining() { return remaining; },
|
|
4286
4453
|
};
|