@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/README.md +82 -2
- package/dist/devtools.cjs.js +36 -4
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.d.ts +67 -0
- package/dist/devtools.esm.js +36 -4
- package/dist/devtools.esm.js.map +1 -1
- package/dist/host.cjs.js +276 -109
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +5 -0
- package/dist/host.esm.js +276 -109
- package/dist/host.esm.js.map +1 -1
- package/dist/reel-panel-client.cjs.js +36 -4
- package/dist/reel-panel-client.cjs.js.map +1 -1
- package/dist/reel-panel-client.esm.js +36 -4
- package/dist/reel-panel-client.esm.js.map +1 -1
- package/dist/slot.cjs.js +229 -43
- package/dist/slot.cjs.js.map +1 -1
- package/dist/slot.d.ts +123 -8
- package/dist/slot.esm.js +229 -44
- package/dist/slot.esm.js.map +1 -1
- package/package.json +2 -2
- package/src/host/autoplay.ts +25 -3
- package/src/host/connectionRecovery.ts +113 -0
- package/src/host/createSlotGame.ts +83 -103
- package/src/host/playError.ts +23 -0
- package/src/host/resumeDrain.ts +156 -0
- package/src/host/shellConfig.ts +9 -0
- package/src/slot/config/ReelSystemConfig.ts +96 -4
- package/src/slot/devtools/fieldSchema.ts +7 -0
- package/src/slot/index.ts +5 -0
- package/src/slot/motion/AnticipationController.ts +62 -17
- package/src/slot/motion/SpinEngine.ts +151 -23
- package/src/slot/system/ReelSystem.ts +59 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@energy8platform/game-engine",
|
|
3
|
-
"version": "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.
|
|
97
|
+
"@energy8platform/shell": ">=0.9.0"
|
|
98
98
|
},
|
|
99
99
|
"peerDependencies": {
|
|
100
100
|
"@energy8platform/game-sdk": "^2.9.0",
|
package/src/host/autoplay.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
494
|
-
// (ACTIVE_SESSION_EXISTS → Reload, etc.)
|
|
495
|
-
// overlay
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
|
package/src/host/playError.ts
CHANGED
|
@@ -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
|
+
}
|