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