@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/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 into a player-facing modal
2846
- // (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
2847
- // overlay is suppressed while a play-error modal owns the screen.
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
- ps?.on('connectionStateChanged', (s) => {
2883
- if (s.status === 'restored') {
2884
- if (!playErrorOpen)
2885
- shell.closeModal();
2886
- return;
2887
- }
2888
- if (playErrorOpen)
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
- * Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
3081
- * the bonus start (Continue animates each; Finish fast-forwards without animation), reaching the
3082
- * final ack so /wallet/end-round credits the win — fixing the old resume that presented one
3083
- * snapshot and never settled. The original trigger is gone on reload, so the FS counter here uses
3084
- * the bridge's session counts; FS mode is entered/exited around the drain.
3085
- */
3086
- const resumeDrain = async (firstRaw, animate) => {
3087
- const scene = gameScene();
3088
- if (!scene || !ps)
3089
- return;
3090
- // A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
3091
- // never-aborted signal to satisfy onSpin's RenderContext. ctx carries the round identity (built
3092
- // once from the trigger action) — recovery drains a single flat bonus using the bridge session
3093
- // counts; the full per-level nesting is a LIVE-play concern (playRound).
3094
- const ctx = {
3095
- ...makeContext(firstRaw.action ?? 'spin'),
3096
- signal: new AbortController().signal,
3097
- };
3098
- const fsView = (raw, totalWin) => {
3099
- const s = raw.session;
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
- stop(); // a play error already surfaced its own modal just halt the run
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
  };