@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energy8platform/game-engine",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Universal casino game engine built on PixiJS v8 and @energy8platform/game-sdk",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs.js",
@@ -94,7 +94,7 @@
94
94
  },
95
95
  "dependencies": {
96
96
  "@energy8platform/platform-core": ">=0.33.0",
97
- "@energy8platform/shell": ">=0.8.0"
97
+ "@energy8platform/shell": ">=0.9.0"
98
98
  },
99
99
  "peerDependencies": {
100
100
  "@energy8platform/game-sdk": "^2.9.0",
@@ -5,6 +5,11 @@
5
5
  * drains fully before the next auto-spin), updating the shell's autoplay readout each spin and
6
6
  * halting on Stop, on an unaffordable spin, or on a play error.
7
7
  *
8
+ * Two ways a run ends, and they differ in what the player is left looking at: `stop()` clears the
9
+ * counter (the player pressed STOP, or the budget ran out — the run is over), while `halt()` keeps
10
+ * it (a lost connection cut the run short; the spins are still owed, and the shell shows them so
11
+ * the player can resume).
12
+ *
8
13
  * Sequential by construction: it awaits each round (incl. its bonus drain) before the next, so
9
14
  * there is never more than one round in flight. Pure over injected deps — unit-testable.
10
15
  */
@@ -20,10 +25,18 @@ export interface AutoplayDeps {
20
25
  }
21
26
 
22
27
  export interface Autoplay {
23
- /** Begin an autoplay run of `count` rounds (no-op if already running or count ≤ 0). */
28
+ /** Begin an autoplay run of `count` rounds (no-op if already running or count ≤ 0). Also how a
29
+ * halted run RESUMES: the shell sends the preserved count back (`start(remaining)`). */
24
30
  start(count: number): void;
25
- /** Stop the run; the in-flight round (if any) finishes, then the loop exits. */
31
+ /** Stop the run and clear the counter the player pressed STOP, or the budget ran out. */
26
32
  stop(): void;
33
+ /**
34
+ * Halt the run but KEEP the counter: the run was cut short by something that isn't the player and
35
+ * isn't the budget — a lost connection, a failed round. The player then sees how many spins were
36
+ * left and can resume them (`start(remaining)`), which is what a certification lab means by
37
+ * "autoplay stops, and after reconnection the counter is displayed correctly".
38
+ */
39
+ halt(): void;
27
40
  readonly active: boolean;
28
41
  readonly remaining: number;
29
42
  }
@@ -40,6 +53,12 @@ export function createAutoplayLoop(deps: AutoplayDeps): Autoplay {
40
53
  deps.onState({ active: false, remaining: 0 });
41
54
  };
42
55
 
56
+ const halt = (): void => {
57
+ if (!active) return; // nothing running — nothing to preserve
58
+ active = false;
59
+ deps.onState({ active: false, remaining });
60
+ };
61
+
43
62
  async function loop(): Promise<void> {
44
63
  if (running) return;
45
64
  running = true;
@@ -53,7 +72,9 @@ export function createAutoplayLoop(deps: AutoplayDeps): Autoplay {
53
72
  try {
54
73
  await deps.playRound(action);
55
74
  } catch {
56
- stop(); // a play error already surfaced its own modal just halt the run
75
+ // The round failed (the host surfaces its own message, if any is due). Halt rather than
76
+ // stop: a connection blip must not eat the spins the player still has coming.
77
+ halt();
57
78
  return;
58
79
  }
59
80
  }
@@ -72,6 +93,7 @@ export function createAutoplayLoop(deps: AutoplayDeps): Autoplay {
72
93
  void loop();
73
94
  },
74
95
  stop,
96
+ halt,
75
97
  get active() { return active; },
76
98
  get remaining() { return remaining; },
77
99
  };
@@ -0,0 +1,113 @@
1
+ // packages/game-engine/src/host/connectionRecovery.ts
2
+
3
+ /**
4
+ * What the game does while the link to the platform is down, and when it comes back.
5
+ *
6
+ * The rules come from a certification remark: *"if connection is lost during autoplay, autoplay
7
+ * stops, and after reconnection the counter is displayed correctly"*. What the game used to do
8
+ * instead was worse in both halves — the in-flight play rejected, the host classified that
9
+ * rejection as an ordinary round failure and put up a "reload the page" screen (visible only in
10
+ * autoplay, because only autoplay always has a play in flight), and the autoplay counter was
11
+ * cleared to zero on the way. Reconnecting healed the link but nothing took the screen back down.
12
+ *
13
+ * So, per transition:
14
+ * - **lost** — halt autoplay (the counter survives, see `autoplay.halt()`) and put up the
15
+ * reconnect overlay. Repeated `lost` (one per failed reconnect attempt) doesn't restack it.
16
+ * - **lost + `ConnectionGone`** — the bridge gave up retrying. That is terminal and honest about
17
+ * it: a Reload modal, not a "Reconnecting…" that will never resolve.
18
+ * - **restored** — take the overlay down and finish what the drop interrupted: if the platform
19
+ * still holds an open round, play it out silently to settlement. No modal asks the player to
20
+ * confirm this — it is their own round, they already paid for it, and an extra screen here is
21
+ * the very thing the remark objected to.
22
+ *
23
+ * A `restored` that never followed a `lost` does nothing at all: it must not close a modal that
24
+ * belongs to someone else.
25
+ *
26
+ * Pure over injected deps — unit-testable, which `createSlotGame` itself is not.
27
+ */
28
+
29
+ import type { PlayResultData } from '@energy8platform/platform-core';
30
+
31
+ export interface ConnectionState {
32
+ status: 'lost' | 'restored' | 'connecting';
33
+ code?: string;
34
+ message?: string;
35
+ }
36
+
37
+ export interface ConnectionRecoveryDeps {
38
+ /** Stop an autoplay run WITHOUT clearing its counter (`Autoplay.halt`). */
39
+ haltAutoplay(): void;
40
+ /** Put up the blocking "Reconnecting…" overlay. */
41
+ showReconnecting(): void;
42
+ /** Put up the terminal "the connection is gone — reload" modal. */
43
+ showGone(): void;
44
+ /** Take down whatever the loss put up. */
45
+ dismiss(): void;
46
+ /** The platform's snapshot of an unfinished round, or `null` when there is nothing to finish. */
47
+ getState(): Promise<PlayResultData | null>;
48
+ /** Play a recovered round out to settlement (the host's `resumeDrain`). */
49
+ drain(snapshot: PlayResultData): Promise<void>;
50
+ /** Report a recovery that itself failed (the host routes it to its play-error modal). */
51
+ onError(err: unknown): void;
52
+ /**
53
+ * True while a modal the player must act on owns the screen. The overlay must not mask it, and
54
+ * a restored link must not close it — but the round underneath is still finished.
55
+ */
56
+ isBlocked?(): boolean;
57
+ }
58
+
59
+ export interface ConnectionRecovery {
60
+ /** Feed one `connectionStateChanged` payload. Awaitable: `restored` finishes the open round. */
61
+ onState(state: ConnectionState): Promise<void>;
62
+ }
63
+
64
+ /** The bridge has stopped retrying — see `ArtubeClient`'s exhausted reconnect loop. */
65
+ const GONE = 'ConnectionGone';
66
+
67
+ export function createConnectionRecovery(deps: ConnectionRecoveryDeps): ConnectionRecovery {
68
+ let linkLost = false;
69
+
70
+ const blocked = (): boolean => deps.isBlocked?.() ?? false;
71
+
72
+ async function recover(): Promise<void> {
73
+ let snapshot: PlayResultData | null = null;
74
+ try {
75
+ snapshot = await deps.getState();
76
+ } catch {
77
+ // The platform couldn't tell us. Nothing to finish, and nothing worth a screen: the player
78
+ // is back in the game, and the next play resolves the open round (or refuses it, loudly).
79
+ return;
80
+ }
81
+ if (!snapshot) return;
82
+ try {
83
+ await deps.drain(snapshot);
84
+ } catch (err) {
85
+ deps.onError(err);
86
+ }
87
+ }
88
+
89
+ return {
90
+ async onState(state: ConnectionState): Promise<void> {
91
+ if (state.status === 'connecting') return; // in transit — the screen stays as it is
92
+
93
+ if (state.status === 'lost') {
94
+ // Halt first: it is what the remark asks for, and it holds even when a modal already owns
95
+ // the screen and the overlay below is skipped.
96
+ deps.haltAutoplay();
97
+ if (state.code === GONE) {
98
+ if (!blocked()) deps.showGone();
99
+ return;
100
+ }
101
+ if (linkLost) return; // already announced — reconnect attempts don't restack the overlay
102
+ linkLost = true;
103
+ if (!blocked()) deps.showReconnecting();
104
+ return;
105
+ }
106
+
107
+ if (!linkLost) return; // nothing of ours is on screen; nothing of ours to recover
108
+ linkLost = false;
109
+ if (!blocked()) deps.dismiss();
110
+ await recover();
111
+ },
112
+ };
113
+ }
@@ -9,6 +9,7 @@ import { releaseExternalOverlay } from '@energy8platform/platform-core/loading';
9
9
  import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
10
10
  import type { ShellMode } from '@energy8platform/shell/pixi';
11
11
  import type { SceneApi, SlotSceneController, RenderContext } from './sceneController';
12
+ import type { ConnectionState } from './connectionRecovery';
12
13
  import type { FreeSpinsView } from './freeSpinsCounter';
13
14
 
14
15
  /**
@@ -282,6 +283,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
282
283
  * per session; demo sessions are 'FUN'); Stake sends full meta on `config.currency` instead. */
283
284
  currency?: string;
284
285
  lang?: string;
286
+ /** The client the platform launched us on. Both bridges read it off the launch URL
287
+ * (Stake's `device=`, Artube's `device=`); absent on dev/devBridge launches. */
288
+ device?: string;
285
289
  } | null;
286
290
  const config = initData?.config;
287
291
  // A reload mid-round is just another entry: authenticate answers with BOTH the currency's
@@ -324,6 +328,8 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
324
328
  social: config?.socialMode,
325
329
  disclaimerLines: config?.disclaimerLines,
326
330
  jurisdiction: config?.jurisdiction,
331
+ // Decides whether the shell offers a keyboard at all — see buildShellConfig.
332
+ device: initData?.device,
327
333
  // Currency-specific ladder + per-currency default from /wallet/authenticate (Stake) or the
328
334
  // backend's `allowed_bets` (Artube); absent on dev/devBridge → buildShellConfig falls back to
329
335
  // the spec.
@@ -490,32 +496,39 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
490
496
  return currentTurbo;
491
497
  },
492
498
  });
493
- // Play-error + connection handling. A play rejection is classified into a player-facing modal
494
- // (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
495
- // overlay is suppressed while a play-error modal owns the screen.
499
+ // Play-error + connection handling. A play rejection is classified (playError.ts): a failed
500
+ // ROUND gets a player-facing modal (ACTIVE_SESSION_EXISTS → Reload, etc.), a failed LINK gets
501
+ // none — the reconnect overlay owns that screen (connectionRecovery.ts). Either way the failure
502
+ // halts an autoplay run WITHOUT clearing its counter. The overlay stays suppressed while a
503
+ // play-error modal owns the screen.
496
504
  let playErrorOpen = false;
497
505
  let stopAutoplay: () => void = () => {}; // wired to the autoplay loop once it's created (below)
506
+ let haltAutoplay: () => void = () => {}; // ditto — stops the run but KEEPS its counter
507
+ const reload = () => {
508
+ try {
509
+ window.location.reload();
510
+ } catch {
511
+ /* non-browser */
512
+ }
513
+ };
498
514
  const showPlayError = (err: unknown): void => {
499
- stopAutoplay(); // a play error halts an autoplay run (the .catch swallows, so stop explicitly)
500
515
  const v = resolvePlayError(err);
516
+ // Halt, don't stop: the spins the player still has coming outlive the failure, so the bar can
517
+ // show them (and offer to resume). The .catch in playRound swallows the rejection, so the
518
+ // autoplay loop can't see it — this is what ends the run.
519
+ haltAutoplay();
520
+ // A failed LINK is not a failed round. The bridge is already reconnecting and the connection
521
+ // overlay owns the screen; a modal here is exactly the "reload the page" screen that showed
522
+ // up on every network blip — and only in autoplay, since only autoplay always has a play in
523
+ // flight when the socket dies.
524
+ if (v.connection) return;
501
525
  playErrorOpen = true;
502
526
  shell!.openModal({
503
527
  availableClose: !v.reload,
504
528
  title: shell!.t(v.title),
505
529
  body: shell!.t(v.body),
506
530
  actions: v.reload
507
- ? [
508
- {
509
- title: shell!.t('Reload'),
510
- on: () => {
511
- try {
512
- window.location.reload();
513
- } catch {
514
- /* non-browser */
515
- }
516
- },
517
- },
518
- ]
531
+ ? [{ title: shell!.t('Reload'), on: reload }]
519
532
  : [
520
533
  {
521
534
  title: shell!.t('OK'),
@@ -526,18 +539,35 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
526
539
  ],
527
540
  });
528
541
  };
529
- ps?.on('connectionStateChanged', (s: { status: string }) => {
530
- if (s.status === 'restored') {
531
- if (!playErrorOpen) shell!.closeModal();
532
- return;
533
- }
534
- if (playErrorOpen) return; // a play-error modal owns the screen — don't mask it with "reconnecting"
535
- shell!.openModal({
536
- availableClose: false,
537
- title: shell!.t('Reconnecting…'),
538
- body: shell!.t('Lost connection to the game server. Trying to reconnect…'),
539
- });
542
+ // Connection loss/return. Assigned below, once `resumeDrain` exists: a restored link finishes
543
+ // the round the drop interrupted, and it must not reach the drain before it is defined.
544
+ let recoverRound: (
545
+ snapshot: import('@energy8platform/platform-core').PlayResultData,
546
+ ) => Promise<void> = async () => {};
547
+ const { createConnectionRecovery } = await import('./connectionRecovery');
548
+ const connection = createConnectionRecovery({
549
+ haltAutoplay: () => haltAutoplay(),
550
+ showReconnecting: () =>
551
+ shell!.openModal({
552
+ availableClose: false,
553
+ title: shell!.t('Reconnecting…'),
554
+ body: shell!.t('Lost connection to the game server. Trying to reconnect…'),
555
+ }),
556
+ // The bridge stopped retrying: say so instead of leaving "Reconnecting…" up for good.
557
+ showGone: () =>
558
+ shell!.openModal({
559
+ availableClose: false,
560
+ title: shell!.t('Connection lost'),
561
+ body: shell!.t('Could not reconnect to the game server. Please reload the game.'),
562
+ actions: [{ title: shell!.t('Reload'), on: reload }],
563
+ }),
564
+ dismiss: () => shell!.closeModal(),
565
+ getState: async () => (await ps?.getState()) ?? null,
566
+ drain: (snapshot) => recoverRound(snapshot),
567
+ onError: showPlayError,
568
+ isBlocked: () => playErrorOpen,
540
569
  });
570
+ ps?.on('connectionStateChanged', (s: ConnectionState) => void connection.onState(s));
541
571
 
542
572
  // Skip state: `currentSegmentAbort` is the controller for the segment presently animating;
543
573
  // `presenting` is true for the whole play→drain window (gates the double-tap detector so taps
@@ -740,82 +770,27 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
740
770
  });
741
771
  };
742
772
 
743
- /**
744
- * Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
745
- * the bonus start (Continue animates each; Finish fast-forwards without animation), reaching the
746
- * final ack so /wallet/end-round credits the win — fixing the old resume that presented one
747
- * snapshot and never settled. The original trigger is gone on reload, so the FS counter here uses
748
- * the bridge's session counts; FS mode is entered/exited around the drain.
749
- */
750
- const resumeDrain = async (
751
- firstRaw: import('@energy8platform/platform-core').PlayResultData,
752
- animate: boolean,
753
- ): Promise<void> => {
754
- const scene = gameScene();
755
- if (!scene || !ps) return;
756
- // A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
757
- // never-aborted signal to satisfy onSpin's RenderContext. ctx carries the round identity (built
758
- // once from the trigger action) — recovery drains a single flat bonus using the bridge session
759
- // counts; the full per-level nesting is a LIVE-play concern (playRound).
760
- const ctx: RenderContext = {
761
- ...makeContext((firstRaw as { action?: string }).action ?? 'spin'),
762
- signal: new AbortController().signal,
763
- };
764
- const fsView = (raw: unknown, totalWin: number) => {
765
- const s = (raw as { session?: { spinsPlayed?: number; spinsRemaining?: number } }).session;
766
- if (!s) return null;
767
- // The bridge session counts ALL segments incl. the trigger (segment 0); the free-spins
768
- // counter is over FREE spins only, so drop the one trigger segment → 1/10, not 2/11.
769
- const played = s.spinsPlayed ?? 0;
770
- const current = Math.max(0, played - 1);
771
- const total = Math.max(0, played + (s.spinsRemaining ?? 0) - 1);
772
- return { current, total, totalWin };
773
- };
774
- let raw = firstRaw;
775
- let r = enrichRoundMeta(opts.normalize(raw), raw);
776
- let inBonus = false;
777
- let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
778
- const applySegment = async (): Promise<void> => {
779
- // A recovered open round with remaining segments is a bonus → show bonus mode + counter.
780
- if (!inBonus && !r.complete) {
781
- inBonus = true;
782
- shell!.setMode(bonusShellMode);
783
- }
784
- shell!.setWin(0, { animate: false }); // clear WIN before this segment animates (see playRound)
785
- if (animate) winReporter.open(); // a fast-forward drain doesn't present → no reports expected
786
- if (animate) await scene.onSpin(r, ctx);
787
- if (inBonus) {
788
- const v = fsView(raw, r.totalWin);
789
- if (v) applyBonusReadout(r, v, ctx.mode);
790
- }
791
- winReporter.close();
792
- shell!.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
793
- prevWin = r.totalWin;
794
- ps!.playAck(raw); // settles via /wallet/end-round on the FINAL segment
795
- };
796
- shell!.setBusy(true); // block input while the recovered round drains
797
- try {
798
- await applySegment();
799
- while (!r.complete && r.nextActions && r.nextActions.length > 0) {
800
- raw = (await ps.play({
801
- action: r.nextActions[0],
802
- bet: ctx.bet,
803
- roundId: r.roundId,
804
- })) as import('@energy8platform/platform-core').PlayResultData;
805
- r = enrichRoundMeta(opts.normalize(raw), raw);
806
- await applySegment();
807
- }
808
- if (inBonus) {
809
- shell!.setMode('base');
810
- // Same as playRound: on return to base the WIN readout must show the round's cumulative
811
- // total (r is the final drained segment), not the last segment's per-spin delta.
812
- shell!.setWin(r.totalWin);
813
- }
814
- } finally {
815
- winReporter.close(); // also closes the window when a drained segment threw
816
- shell!.setBusy(false);
817
- }
818
- };
773
+ // Drain a recovered open round (reload / dropped connection) to settlement — see resumeDrain.ts.
774
+ // `scene` folds in the old `!ps` guard: with no session there is nothing to play or ack.
775
+ const { createResumeDrain } = await import('./resumeDrain');
776
+ const resumeDrain = createResumeDrain<T>({
777
+ scene: () => (ps ? gameScene() : undefined),
778
+ play: (req) => ps!.play(req),
779
+ ack: (raw) => ps!.playAck(raw),
780
+ normalize: opts.normalize,
781
+ context: makeContext,
782
+ setWin: (amount, o) => shell!.setWin(amount, o),
783
+ setMode: (m) => shell!.setMode(m),
784
+ setBusy: (b) => shell!.setBusy(b),
785
+ winReporter,
786
+ applyBonusReadout,
787
+ bonusMode: bonusShellMode,
788
+ });
789
+ // A round interrupted by a dropped connection is finished the same way a round interrupted by a
790
+ // reload is — animated, through to settlement — except nothing asks the player first: it is
791
+ // their own round, and an extra confirmation screen is what the reconnect flow is meant to
792
+ // avoid. (The reload path keeps its Continue/Finish offer: there, the player has been away.)
793
+ recoverRound = (snapshot) => resumeDrain(snapshot, true);
819
794
 
820
795
  if (mode === 'base') {
821
796
  let activeFeature: string | null = null;
@@ -843,6 +818,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
843
818
  shell.on('spin', () => {
844
819
  const action = activeFeature ?? 'spin';
845
820
  if (!ensureAffordable(action)) return;
821
+ // Spinning by hand retires whatever a halted run left on the counter — the player moved on.
822
+ // (No-op unless a run was halted: `stop()` returns early when there is nothing to clear.)
823
+ stopAutoplay();
846
824
  void playRound(action);
847
825
  });
848
826
  shell.on('betChange', (bet: number) => {
@@ -851,6 +829,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
851
829
  });
852
830
  shell.on('buyBonusSelect', ({ id }: { id: string }) => {
853
831
  if (!ensureAffordable(id)) return;
832
+ stopAutoplay(); // as with a manual spin: a bought bonus retires a halted run's counter
854
833
  void playRound(id);
855
834
  });
856
835
 
@@ -867,6 +846,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
867
846
  },
868
847
  });
869
848
  stopAutoplay = () => autoplay.stop();
849
+ haltAutoplay = () => autoplay.halt();
870
850
  shell.on('autoplayStart', (o: { remaining?: number }) => autoplay.start(o?.remaining ?? 0));
871
851
  shell.on('autoplayStop', () => autoplay.stop());
872
852
 
@@ -16,8 +16,23 @@ export interface PlayErrorView {
16
16
  body: string;
17
17
  /** Offer a Reload action (the round can only be recovered by reloading). */
18
18
  reload: boolean;
19
+ /**
20
+ * The LINK failed, not the round: the bridge is already reconnecting, and the connection overlay
21
+ * (driven by `connectionStateChanged`) owns the screen. Such a failure gets NO modal of its own —
22
+ * a "reload the page" screen over a blip the bridge heals by itself is exactly the wrong answer,
23
+ * and it was the one the player used to get, because every autoplay round is a play in flight.
24
+ */
25
+ connection?: boolean;
19
26
  }
20
27
 
28
+ /**
29
+ * Codes that mean "the connection died", per bridge:
30
+ * - `ConnectionLost` / `ConnectionFailed` — Artube's WS client (a pending play on a dropped
31
+ * socket, or a play attempted while the socket is down);
32
+ * - `ERR_NET` — Stake's RGS client, after its retries are exhausted on a network-level failure.
33
+ */
34
+ const CONNECTION_CODES = new Set(['ConnectionLost', 'ConnectionFailed', 'ERR_NET']);
35
+
21
36
  /** Pull a Stake/SDK error code off an unknown thrown value. */
22
37
  export function errorCode(err: unknown): string | undefined {
23
38
  const code = (err as { code?: unknown })?.code;
@@ -27,6 +42,14 @@ export function errorCode(err: unknown): string | undefined {
27
42
  export function resolvePlayError(err: unknown): PlayErrorView {
28
43
  const code = errorCode(err);
29
44
  const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
45
+ if (code && CONNECTION_CODES.has(code)) {
46
+ return {
47
+ title: 'Connection lost',
48
+ body: 'Lost connection to the game server. Trying to reconnect…',
49
+ reload: false,
50
+ connection: true,
51
+ };
52
+ }
30
53
  switch (code) {
31
54
  case 'ACTIVE_SESSION_EXISTS':
32
55
  return {
@@ -0,0 +1,156 @@
1
+ // packages/game-engine/src/host/resumeDrain.ts
2
+
3
+ /**
4
+ * Play a recovered open round to completion and settle it.
5
+ *
6
+ * A round that was interrupted — the page reloaded, the socket dropped — is still open on the
7
+ * platform, and the player is owed both its remaining segments and the money at the end of them.
8
+ * This drains it: every remaining segment from where the snapshot left off (Continue animates each,
9
+ * Finish fast-forwards without animation), through to the final ack so the wallet credits the win.
10
+ *
11
+ * The original trigger is gone on a reload, so the free-spins counter here is rebuilt from the
12
+ * bridge's session counts rather than from the trigger's award — see `resumeBonusView`. Bonus mode
13
+ * is entered and exited around the drain, and the counter is painted the moment the bar enters it:
14
+ * a bonus bar with nothing in its counter reads as `0 / 0`, which is what a certification lab means
15
+ * by "the counter is reset while the previous FS round is active".
16
+ *
17
+ * Pure over injected deps — unit-testable, which `createSlotGame` itself is not.
18
+ */
19
+
20
+ import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
21
+ import type { PlayResultData } from '@energy8platform/platform-core';
22
+ import type { ShellMode } from '@energy8platform/shell/pixi';
23
+ import type { RenderContext, SlotSceneController } from './sceneController';
24
+ import type { FreeSpinsView } from './freeSpinsCounter';
25
+ import { enrichRoundMeta } from './slotPlay';
26
+
27
+ export interface ResumeDrainDeps<T extends SlotSpinResultBase> {
28
+ /** The scene to draw the recovered segments on; `undefined` aborts the drain. */
29
+ scene(): SlotSceneController<T> | undefined;
30
+ /** Play the next segment of the open round (PlatformSession.play). */
31
+ play(req: { action: string; bet: number; roundId?: string }): Promise<PlayResultData>;
32
+ /** Acknowledge a presented segment (PlatformSession.playAck) — settles on the final one. */
33
+ ack(raw: PlayResultData): void;
34
+ normalize(raw: unknown): T;
35
+ /** The signal-less RenderContext for an action (the host's `makeContext`). */
36
+ context(action: string): Omit<RenderContext, 'signal'>;
37
+ setWin(amount: number, opts?: { animate?: boolean }): void;
38
+ setMode(mode: ShellMode): void;
39
+ setBusy(busy: boolean): void;
40
+ /** The progressive-WIN window (open while a segment may report per-step wins). */
41
+ winReporter: { open(): void; close(): void };
42
+ /** Paint the bonus readout — the free-spins counter, or the game's own. */
43
+ applyBonusReadout(result: T, view: FreeSpinsView, mode: string): void;
44
+ /** The shell mode a bonus enters: 'bonus' when the game customises the readout, else 'freeSpins'. */
45
+ bonusMode: ShellMode;
46
+ }
47
+
48
+ /** The session counts a bridge attaches to every segment of an open round. */
49
+ interface ResumeSession {
50
+ spinsPlayed?: number;
51
+ spinsRemaining?: number;
52
+ }
53
+
54
+ /**
55
+ * Rebuild the free-spins counter from a resumed segment's session counts.
56
+ *
57
+ * The bridge session counts ALL segments including the trigger (segment 0); the free-spins counter
58
+ * is over FREE spins only, so one trigger segment is dropped → `1 / 10`, not `2 / 11`. `null` when
59
+ * the snapshot carries no session at all — there is nothing to count, and painting zeroes would be
60
+ * a claim, not a reading.
61
+ */
62
+ export function resumeBonusView(raw: unknown, totalWin: number): FreeSpinsView | null {
63
+ const s = (raw as { session?: ResumeSession | null } | null)?.session;
64
+ if (!s) return null;
65
+ const played = s.spinsPlayed ?? 0;
66
+ return {
67
+ current: Math.max(0, played - 1),
68
+ total: Math.max(0, played + (s.spinsRemaining ?? 0) - 1),
69
+ totalWin,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Fold a segment's own free-spins counts over the session-derived view.
75
+ *
76
+ * Same precedence live play uses (`overrideView` in createSlotGame): when the book states the count
77
+ * outright, it is the authority — the session's segment arithmetic is only a reconstruction, and it
78
+ * assumes a shape (exactly one trigger segment ahead of the free spins) that not every game has.
79
+ */
80
+ export function overrideWithBook(
81
+ view: FreeSpinsView | null,
82
+ fs: SlotSpinResultBase['freeSpins'],
83
+ ): FreeSpinsView | null {
84
+ if (!fs || (fs.total == null && fs.remaining == null)) return view;
85
+ const base = view ?? { current: 0, total: 0, totalWin: 0 };
86
+ const total = fs.total ?? base.total;
87
+ const current = fs.remaining != null ? Math.max(0, total - fs.remaining) : base.current;
88
+ return { current, total, totalWin: base.totalWin };
89
+ }
90
+
91
+ export function createResumeDrain<T extends SlotSpinResultBase>(
92
+ deps: ResumeDrainDeps<T>,
93
+ ): (firstRaw: PlayResultData, animate: boolean) => Promise<void> {
94
+ return async function drain(firstRaw: PlayResultData, animate: boolean): Promise<void> {
95
+ const scene = deps.scene();
96
+ if (!scene) return;
97
+ // A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
98
+ // never-aborted signal to satisfy onSpin's RenderContext. ctx carries the round identity (built
99
+ // once from the trigger action) — recovery drains a single flat bonus using the bridge session
100
+ // counts; the full per-level nesting is a LIVE-play concern (playRound).
101
+ const ctx: RenderContext = {
102
+ ...deps.context((firstRaw as { action?: string }).action ?? 'spin'),
103
+ signal: new AbortController().signal,
104
+ };
105
+ let raw: PlayResultData = firstRaw;
106
+ let r = enrichRoundMeta(deps.normalize(raw), raw);
107
+ let inBonus = false;
108
+ let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
109
+
110
+ /** Push the current segment's counts to the bar (book counts win over the session's). */
111
+ const paintCounter = (): void => {
112
+ const view = overrideWithBook(resumeBonusView(raw, r.totalWin), r.freeSpins);
113
+ if (view) deps.applyBonusReadout(r, view, ctx.mode);
114
+ };
115
+
116
+ const applySegment = async (): Promise<void> => {
117
+ // A recovered open round with remaining segments is a bonus → show bonus mode + counter.
118
+ if (!inBonus && !r.complete) {
119
+ inBonus = true;
120
+ deps.setMode(deps.bonusMode);
121
+ // BEFORE the segment animates, not after. The bar has just switched to its bonus face, and
122
+ // its counter still holds the zeroes it was born with; leaving it that way means the player
123
+ // watches a whole free spin play out under `0 / 0` and only then sees where the round
124
+ // actually stands. The snapshot already carries the counts — paint them.
125
+ paintCounter();
126
+ }
127
+ deps.setWin(0, { animate: false }); // clear WIN before this segment animates (see playRound)
128
+ if (animate) deps.winReporter.open(); // a fast-forward drain doesn't present → no reports expected
129
+ if (animate) await scene.onSpin(r, ctx);
130
+ if (inBonus) paintCounter();
131
+ deps.winReporter.close();
132
+ deps.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
133
+ prevWin = r.totalWin;
134
+ deps.ack(raw); // settles via /wallet/end-round on the FINAL segment
135
+ };
136
+
137
+ deps.setBusy(true); // block input while the recovered round drains
138
+ try {
139
+ await applySegment();
140
+ while (!r.complete && r.nextActions && r.nextActions.length > 0) {
141
+ raw = await deps.play({ action: r.nextActions[0], bet: ctx.bet, roundId: r.roundId });
142
+ r = enrichRoundMeta(deps.normalize(raw), raw);
143
+ await applySegment();
144
+ }
145
+ if (inBonus) {
146
+ deps.setMode('base');
147
+ // Same as playRound: on return to base the WIN readout must show the round's cumulative
148
+ // total (r is the final drained segment), not the last segment's per-spin delta.
149
+ deps.setWin(r.totalWin);
150
+ }
151
+ } finally {
152
+ deps.winReporter.close(); // also closes the window when a drained segment threw
153
+ deps.setBusy(false);
154
+ }
155
+ };
156
+ }