@energy8platform/game-engine 0.37.0 → 0.39.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 +46 -2
- package/dist/devtools.cjs.js +32 -4
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.d.ts +44 -0
- package/dist/devtools.esm.js +32 -4
- package/dist/devtools.esm.js.map +1 -1
- package/dist/host.cjs.js +279 -109
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +5 -0
- package/dist/host.esm.js +279 -109
- package/dist/host.esm.js.map +1 -1
- package/dist/reel-panel-client.cjs.js +32 -4
- package/dist/reel-panel-client.cjs.js.map +1 -1
- package/dist/reel-panel-client.esm.js +32 -4
- package/dist/reel-panel-client.esm.js.map +1 -1
- package/dist/slot.cjs.js +184 -42
- package/dist/slot.cjs.js.map +1 -1
- package/dist/slot.d.ts +86 -8
- package/dist/slot.esm.js +184 -43
- package/dist/slot.esm.js.map +1 -1
- package/package.json +3 -3
- 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 +12 -0
- package/src/slot/config/ReelSystemConfig.ts +71 -4
- package/src/slot/devtools/fieldSchema.ts +5 -0
- package/src/slot/index.ts +3 -0
- package/src/slot/motion/AnticipationController.ts +62 -17
- package/src/slot/motion/SpinEngine.ts +95 -18
- 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
|
],
|
|
@@ -3377,6 +3345,9 @@ function toBonusOptions(model, t = (s) => s) {
|
|
|
3377
3345
|
// Hero art (SSOT) → card thumbnail. Passed verbatim: the shell loads it as-is, so no URL
|
|
3378
3346
|
// resolver is needed (matches a static buyBonus `thumbnail`). Keeps i18n/price/accent/social.
|
|
3379
3347
|
...(action.art ? { thumbnail: action.art } : {}),
|
|
3348
|
+
// Variant grouping (SSOT): actions sharing the key share one card in the pixi shell, flipped
|
|
3349
|
+
// through with arrows. Each stays its own action, so the id on activate/buy is unambiguous.
|
|
3350
|
+
...(action.groupedBy ? { groupedBy: action.groupedBy } : {}),
|
|
3380
3351
|
});
|
|
3381
3352
|
}
|
|
3382
3353
|
return out;
|
|
@@ -3658,6 +3629,11 @@ function buildShellConfig(opts, model, runtime) {
|
|
|
3658
3629
|
...(opts.features ?? {}),
|
|
3659
3630
|
};
|
|
3660
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;
|
|
3661
3637
|
return {
|
|
3662
3638
|
language: runtime.language ?? 'en',
|
|
3663
3639
|
isSocial,
|
|
@@ -4055,6 +4031,13 @@ var freeSpinsCounter = /*#__PURE__*/Object.freeze({
|
|
|
4055
4031
|
createFreeSpinsCounter: createFreeSpinsCounter
|
|
4056
4032
|
});
|
|
4057
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']);
|
|
4058
4041
|
/** Pull a Stake/SDK error code off an unknown thrown value. */
|
|
4059
4042
|
function errorCode(err) {
|
|
4060
4043
|
const code = err?.code;
|
|
@@ -4063,6 +4046,14 @@ function errorCode(err) {
|
|
|
4063
4046
|
function resolvePlayError(err) {
|
|
4064
4047
|
const code = errorCode(err);
|
|
4065
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
|
+
}
|
|
4066
4057
|
switch (code) {
|
|
4067
4058
|
case 'ACTIVE_SESSION_EXISTS':
|
|
4068
4059
|
return {
|
|
@@ -4186,6 +4177,66 @@ var overlayController = /*#__PURE__*/Object.freeze({
|
|
|
4186
4177
|
createOverlayController: createOverlayController
|
|
4187
4178
|
});
|
|
4188
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
|
+
|
|
4189
4240
|
/** Pure double-tap recognizer. The host feeds it pointer `tap(now)` (e.g. performance.now()) and
|
|
4190
4241
|
* supplies the enabled/active gates + the onSkip effect. */
|
|
4191
4242
|
function createDoubleTapSkip(deps) {
|
|
@@ -4229,6 +4280,116 @@ var pauseController = /*#__PURE__*/Object.freeze({
|
|
|
4229
4280
|
createPauseController: createPauseController
|
|
4230
4281
|
});
|
|
4231
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
|
+
|
|
4232
4393
|
function createAutoplayLoop(deps) {
|
|
4233
4394
|
let active = false;
|
|
4234
4395
|
let remaining = 0;
|
|
@@ -4240,6 +4401,12 @@ function createAutoplayLoop(deps) {
|
|
|
4240
4401
|
remaining = 0;
|
|
4241
4402
|
deps.onState({ active: false, remaining: 0 });
|
|
4242
4403
|
};
|
|
4404
|
+
const halt = () => {
|
|
4405
|
+
if (!active)
|
|
4406
|
+
return; // nothing running — nothing to preserve
|
|
4407
|
+
active = false;
|
|
4408
|
+
deps.onState({ active: false, remaining });
|
|
4409
|
+
};
|
|
4243
4410
|
async function loop() {
|
|
4244
4411
|
if (running)
|
|
4245
4412
|
return;
|
|
@@ -4258,7 +4425,9 @@ function createAutoplayLoop(deps) {
|
|
|
4258
4425
|
await deps.playRound(action);
|
|
4259
4426
|
}
|
|
4260
4427
|
catch {
|
|
4261
|
-
|
|
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();
|
|
4262
4431
|
return;
|
|
4263
4432
|
}
|
|
4264
4433
|
}
|
|
@@ -4279,6 +4448,7 @@ function createAutoplayLoop(deps) {
|
|
|
4279
4448
|
void loop();
|
|
4280
4449
|
},
|
|
4281
4450
|
stop,
|
|
4451
|
+
halt,
|
|
4282
4452
|
get active() { return active; },
|
|
4283
4453
|
get remaining() { return remaining; },
|
|
4284
4454
|
};
|