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