@energy8platform/game-engine 0.43.4 → 0.43.5

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.
@@ -10,6 +10,12 @@
10
10
  * it (a lost connection cut the run short; the spins are still owed, and the shell shows them so
11
11
  * the player can resume).
12
12
  *
13
+ * `hold()`/`release()` are a THIRD thing, and not an ending at all: the tab went to the background,
14
+ * so the host freezes the ticker and no new round may start — but the player did nothing, and the
15
+ * run must come back by itself when they return. A hold is forgotten the moment anything else ends
16
+ * the run (`stop`, `halt`, a new `start`), so a run killed by a lost connection or a failed round
17
+ * while the tab was hidden does not spring back to life on the way in.
18
+ *
13
19
  * Sequential by construction: it awaits each round (incl. its bonus drain) before the next, so
14
20
  * there is never more than one round in flight. Pure over injected deps — unit-testable.
15
21
  */
@@ -38,6 +44,18 @@ export interface Autoplay {
38
44
  * "autoplay stops, and after reconnection the counter is displayed correctly".
39
45
  */
40
46
  halt(): void;
47
+ /**
48
+ * Hold the run for a pause the player did not ask for — the tab went to the background. Halts it
49
+ * (counter kept) and remembers that it is owed a resume. No-op when no run is active.
50
+ *
51
+ * A certification lab reads "the autoplay sequence stops when the Stop button is pressed" as
52
+ * *only* the Stop button: switching tabs must not end a run. The host still freezes everything
53
+ * while hidden — this is what makes that freeze temporary rather than terminal.
54
+ */
55
+ hold(): void;
56
+ /** Resume a run `hold()` put on ice, with the spins it still had. No-op unless a hold is
57
+ * outstanding — anything that ended the run in the meantime cancels it. */
58
+ release(): void;
41
59
  readonly active: boolean;
42
60
  readonly remaining: number;
43
61
  }
@@ -46,8 +64,10 @@ export function createAutoplayLoop(deps: AutoplayDeps): Autoplay {
46
64
  let active = false;
47
65
  let remaining = 0;
48
66
  let running = false; // guards against a second concurrent loop
67
+ let held = false; // a hold() is outstanding — the tab is hidden and the run is owed a resume
49
68
 
50
69
  const stop = (): void => {
70
+ held = false;
51
71
  if (!active && remaining === 0) return;
52
72
  active = false;
53
73
  remaining = 0;
@@ -55,6 +75,7 @@ export function createAutoplayLoop(deps: AutoplayDeps): Autoplay {
55
75
  };
56
76
 
57
77
  const halt = (): void => {
78
+ held = false; // whatever is halting the run outranks a pending hold — see hold()'s doc comment
58
79
  if (!active) return; // nothing running — nothing to preserve
59
80
  active = false;
60
81
  deps.onState({ active: false, remaining });
@@ -66,7 +87,10 @@ export function createAutoplayLoop(deps: AutoplayDeps): Autoplay {
66
87
  try {
67
88
  while (active && remaining > 0) {
68
89
  const action = deps.resolveAction();
69
- if (!deps.canAfford(action)) { stop(); return; }
90
+ if (!deps.canAfford(action)) {
91
+ stop();
92
+ return;
93
+ }
70
94
  // Decrement at spin START (so the spin in flight is `total − remaining`), then play it out.
71
95
  remaining -= 1;
72
96
  deps.onState({ active: true, remaining });
@@ -85,24 +109,41 @@ export function createAutoplayLoop(deps: AutoplayDeps): Autoplay {
85
109
  }
86
110
  }
87
111
 
112
+ const start = (count: number): void => {
113
+ if (active || count <= 0) return;
114
+ held = false; // a run that is starting is no longer one that owes a resume
115
+ active = true;
116
+ remaining = count;
117
+ deps.onState({ active: true, remaining });
118
+ // `running` bars a SECOND loop, not a new run. After stop(), the round already in flight keeps
119
+ // animating and the loop stays parked on `await playRound`, so `running` is still true for as
120
+ // long as that takes — several seconds through a win. Bailing here made the restart a silent
121
+ // no-op, and silent is what hurt: ShellController.startAutoplay has already lit the disc with
122
+ // the chosen count, so the player saw autoplay running with a counter that never moved.
123
+ // The parked loop re-reads `active`/`remaining` on its next turn and simply carries on with
124
+ // the new budget — no second loop, nothing dropped.
125
+ if (!running) void loop();
126
+ };
127
+
88
128
  return {
89
- start(count: number): void {
90
- if (active || count <= 0) return;
91
- active = true;
92
- remaining = count;
93
- deps.onState({ active: true, remaining });
94
- // `running` bars a SECOND loop, not a new run. After stop(), the round already in flight keeps
95
- // animating and the loop stays parked on `await playRound`, so `running` is still true for as
96
- // long as that takes — several seconds through a win. Bailing here made the restart a silent
97
- // no-op, and silent is what hurt: ShellController.startAutoplay has already lit the disc with
98
- // the chosen count, so the player saw autoplay running with a counter that never moved.
99
- // The parked loop re-reads `active`/`remaining` on its next turn and simply carries on with
100
- // the new budget — no second loop, nothing dropped.
101
- if (!running) void loop();
102
- },
129
+ start,
103
130
  stop,
104
131
  halt,
105
- get active() { return active; },
106
- get remaining() { return remaining; },
132
+ hold(): void {
133
+ if (!active) return; // nothing running — nothing to hold, and nothing to bring back
134
+ halt(); // clears `held`; we set it again below, so only a hold() ever leaves it set
135
+ held = true;
136
+ },
137
+ release(): void {
138
+ if (!held) return;
139
+ held = false;
140
+ start(remaining); // no-op when the remainder is gone (something retired it while hidden)
141
+ },
142
+ get active() {
143
+ return active;
144
+ },
145
+ get remaining() {
146
+ return remaining;
147
+ },
107
148
  };
108
149
  }
@@ -11,14 +11,21 @@
11
11
  * cleared to zero on the way. Reconnecting healed the link but nothing took the screen back down.
12
12
  *
13
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.
14
+ * - **lost** — halt autoplay (the counter survives the drop, see `autoplay.halt()`) and put up
15
+ * the reconnect overlay. Repeated `lost` (one per failed reconnect attempt) doesn't restack it.
16
16
  * - **lost + `ConnectionGone`** — the bridge gave up retrying. That is terminal and honest about
17
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.
18
+ * - **restored** — reset autoplay, take the overlay down, and finish what the drop interrupted:
19
+ * if the platform still holds an open round, play it out silently to settlement. No modal asks
20
+ * the player to confirm this — it is their own round, they already paid for it, and an extra
21
+ * screen here is the very thing the remark objected to.
22
+ *
23
+ * Why halt on the way down but reset on the way up: while the link is down the run is stopped yet
24
+ * its remaining spins are still the player's, so the counter must keep showing them rather than
25
+ * snap to zero (the lab's original finding was exactly "the autoplay counter is reset"). Once the
26
+ * link is back, Artube wants the run **over**, not parked one tap from resuming: a run the player
27
+ * ordered before a network drop must not carry on across it, and the player re-orders autoplay
28
+ * deliberately. So `restored` clears the counter and the disc goes back to a plain SPIN.
22
29
  *
23
30
  * A `restored` that never followed a `lost` does nothing at all: it must not close a modal that
24
31
  * belongs to someone else.
@@ -35,8 +42,12 @@ export interface ConnectionState {
35
42
  }
36
43
 
37
44
  export interface ConnectionRecoveryDeps {
38
- /** Stop an autoplay run WITHOUT clearing its counter (`Autoplay.halt`). */
45
+ /** Stop an autoplay run WITHOUT clearing its counter (`Autoplay.halt`) — used while the link
46
+ * is down, so the spins still owed stay on screen. */
39
47
  haltAutoplay(): void;
48
+ /** End the run and clear its counter (`Autoplay.stop`) — used once the link is back: autoplay
49
+ * does not survive a reconnect. */
50
+ resetAutoplay(): void;
40
51
  /** Put up the blocking "Reconnecting…" overlay. */
41
52
  showReconnecting(): void;
42
53
  /** Put up the terminal "the connection is gone — reload" modal. */
@@ -106,6 +117,9 @@ export function createConnectionRecovery(deps: ConnectionRecoveryDeps): Connecti
106
117
 
107
118
  if (!linkLost) return; // nothing of ours is on screen; nothing of ours to recover
108
119
  linkLost = false;
120
+ // The run does not resume across a reconnect. Like the halt above, this runs even when a
121
+ // modal owns the screen — the counter must not outlive the drop that ended the run.
122
+ deps.resetAutoplay();
109
123
  if (!blocked()) deps.dismiss();
110
124
  await recover();
111
125
  },
@@ -506,6 +506,8 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
506
506
  let playErrorOpen = false;
507
507
  let stopAutoplay: () => void = () => {}; // wired to the autoplay loop once it's created (below)
508
508
  let haltAutoplay: () => void = () => {}; // ditto — stops the run but KEEPS its counter
509
+ let holdAutoplay: () => void = () => {}; // ditto — pauses the run, owing it a resume
510
+ let releaseAutoplay: () => void = () => {}; // ditto — gives back a run that hold() paused
509
511
  const reload = () => {
510
512
  try {
511
513
  window.location.reload();
@@ -549,6 +551,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
549
551
  const { createConnectionRecovery } = await import('./connectionRecovery');
550
552
  const connection = createConnectionRecovery({
551
553
  haltAutoplay: () => haltAutoplay(),
554
+ // A reconnect ends the run for good (Artube): the counter the halt preserved while the link
555
+ // was down is cleared here, and the disc goes back to a plain SPIN.
556
+ resetAutoplay: () => stopAutoplay(),
552
557
  showReconnecting: () =>
553
558
  shell!.openModal({
554
559
  availableClose: false,
@@ -595,8 +600,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
595
600
  game.scenes.root.on('pointertap', () => skip.tap(performance.now()));
596
601
 
597
602
  // Full auto-pause: on tab blur, freeze the ticker (stops tweens/onUpdate/in-flight onSpin),
598
- // duck music to silence, hold autoplay, and notify the scene. On focus, reverse it all.
599
- // `stopAutoplay` is reassigned in the base-mode block below — the closure reads it live.
603
+ // duck music to silence, hold autoplay, and notify the scene. On focus, reverse it all
604
+ // autoplay included: a tab switch is not the player pressing STOP.
605
+ // `holdAutoplay`/`releaseAutoplay` are reassigned in the base-mode block below — the closures
606
+ // read them live.
600
607
  const { createPauseController } = await import('./pauseController');
601
608
  createPauseController({
602
609
  isHidden: () => typeof document !== 'undefined' && document.hidden,
@@ -608,12 +615,19 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
608
615
  onHidden: () => {
609
616
  game.app.ticker.stop(); // freezes tweens, onUpdate, in-flight onSpin animation
610
617
  game.audio.duckMusic(0); // silence music (ducked to 0; restored on resume)
611
- stopAutoplay(); // hold autoplay — don't start the next auto-round
618
+ // Hold, don't stop. No auto-round may start while the tab is hidden (the ticker is frozen,
619
+ // so an animation would never finish, and a play fired here spends the player's money out
620
+ // of sight) — but the run is owed back: the lab reads "the autoplay sequence stops when the
621
+ // Stop button is pressed" as ONLY the Stop button, and stopAutoplay() here ended the run and
622
+ // zeroed its counter on every alt-tab. `hold()` forgets itself if anything else ends the run
623
+ // meanwhile (a lost connection, a failed round), so those don't come back on the way in.
624
+ holdAutoplay();
612
625
  gameScene()?.onPause?.();
613
626
  },
614
627
  onVisible: () => {
615
628
  game.app.ticker.start();
616
629
  game.audio.unduckMusic();
630
+ releaseAutoplay(); // the run the blur paused picks up where it left off
617
631
  gameScene()?.onResume?.();
618
632
  },
619
633
  });
@@ -849,6 +863,8 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
849
863
  });
850
864
  stopAutoplay = () => autoplay.stop();
851
865
  haltAutoplay = () => autoplay.halt();
866
+ holdAutoplay = () => autoplay.hold();
867
+ releaseAutoplay = () => autoplay.release();
852
868
  shell.on('autoplayStart', (o: { remaining?: number }) => autoplay.start(o?.remaining ?? 0));
853
869
  shell.on('autoplayStop', () => autoplay.stop());
854
870