@energy8platform/game-engine 0.17.0 → 0.18.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/core.cjs.js +62 -1
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +33 -2
- package/dist/core.esm.js +63 -3
- package/dist/core.esm.js.map +1 -1
- package/dist/game-spec.cjs.js +13 -0
- package/dist/game-spec.cjs.js.map +1 -0
- package/dist/game-spec.d.ts +1 -0
- package/dist/game-spec.esm.js +2 -0
- package/dist/game-spec.esm.js.map +1 -0
- package/dist/host.cjs.js +3346 -0
- package/dist/host.cjs.js.map +1 -0
- package/dist/host.d.ts +915 -0
- package/dist/host.esm.js +3337 -0
- package/dist/host.esm.js.map +1 -0
- package/dist/index.cjs.js +13 -1
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.esm.js +13 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/react.cjs.js.map +1 -1
- package/dist/react.d.ts +6 -1
- package/dist/react.esm.js.map +1 -1
- package/dist/shell.cjs.js +19 -0
- package/dist/shell.cjs.js.map +1 -0
- package/dist/shell.d.ts +1 -0
- package/dist/shell.esm.js +2 -0
- package/dist/shell.esm.js.map +1 -0
- package/dist/slot.cjs.js +998 -0
- package/dist/slot.cjs.js.map +1 -0
- package/dist/slot.d.ts +333 -0
- package/dist/slot.esm.js +985 -0
- package/dist/slot.esm.js.map +1 -0
- package/package.json +26 -1
- package/src/core/GameApplication.ts +16 -1
- package/src/core/index.ts +2 -0
- package/src/game-spec/index.ts +1 -0
- package/src/host/autoplay.ts +78 -0
- package/src/host/balanceGate.ts +46 -0
- package/src/host/buildConfig.ts +28 -0
- package/src/host/createSlotGame.ts +423 -0
- package/src/host/fatalError.ts +104 -0
- package/src/host/freeSpinsCounter.ts +44 -0
- package/src/host/index.ts +18 -0
- package/src/host/playError.ts +64 -0
- package/src/host/preboot.ts +25 -0
- package/src/host/replay.ts +9 -0
- package/src/host/runRound.ts +63 -0
- package/src/host/sceneController.ts +31 -0
- package/src/host/sceneStart.ts +25 -0
- package/src/host/shellConfig.ts +379 -0
- package/src/host/slotPlay.ts +62 -0
- package/src/host/types.ts +71 -0
- package/src/scenes/IntroScene.ts +66 -0
- package/src/shell/index.ts +20 -0
- package/src/slot/anim/CascadeController.ts +102 -0
- package/src/slot/anim/ReelSpinController.ts +81 -0
- package/src/slot/anim/easing-map.ts +14 -0
- package/src/slot/freeSpins/FreeSpinsSession.ts +40 -0
- package/src/slot/grid/AnimatedSymbol.ts +68 -0
- package/src/slot/grid/ReelGrid.ts +92 -0
- package/src/slot/grid/SymbolCell.ts +127 -0
- package/src/slot/grid/SymbolView.ts +13 -0
- package/src/slot/index.ts +21 -0
- package/src/slot/multiplier/MultiplierAccumulator.ts +29 -0
- package/src/slot/overlay/BigWinOverlay.ts +89 -0
- package/src/slot/overlay/CountUpDisplay.ts +56 -0
- package/src/slot/overlay/tiers.ts +29 -0
- package/src/types.ts +3 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// packages/game-engine/src/host/createSlotGame.ts
|
|
2
|
+
import { GameApplication } from '../core';
|
|
3
|
+
import { buildAppConfig } from './buildConfig';
|
|
4
|
+
import { loadFonts, applyTextureDefaults, bootGuard } from './preboot';
|
|
5
|
+
import { showFatalError, installGlobalErrorHandlers } from './fatalError';
|
|
6
|
+
import type { CreateSlotGameOptions, SlotGameHandle } from './types';
|
|
7
|
+
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
8
|
+
import type { ShellMode } from '@energy8platform/platform-core/shell';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
|
|
12
|
+
* → register scene → start. Collapses the per-game main.ts boilerplate.
|
|
13
|
+
*
|
|
14
|
+
* Not unit-tested: GameApplication.init() drives Pixi, which hangs in headless
|
|
15
|
+
* environments. The pure helpers it sequences are unit-tested individually.
|
|
16
|
+
*/
|
|
17
|
+
export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResultBase>(
|
|
18
|
+
opts: CreateSlotGameOptions<T>,
|
|
19
|
+
): Promise<SlotGameHandle> {
|
|
20
|
+
if (!bootGuard()) throw new Error('createSlotGame() called more than once');
|
|
21
|
+
|
|
22
|
+
if (opts.textureDefaults) applyTextureDefaults();
|
|
23
|
+
await loadFonts(opts.fonts);
|
|
24
|
+
|
|
25
|
+
// Declared up front so `fatal` can route errors through the shell's own modal once it exists.
|
|
26
|
+
let shell: SlotGameHandle['shell'] = null;
|
|
27
|
+
|
|
28
|
+
const fatal = (message: string) => {
|
|
29
|
+
if (opts.onFatalError) return opts.onFatalError(message);
|
|
30
|
+
// Once the shell is up, use ITS branded modal (consistent chrome, social vocabulary, fit
|
|
31
|
+
// scaling) rather than the bare DOM fallback. Errors thrown before the shell boots (asset
|
|
32
|
+
// load, SDK handshake) still get the standalone overlay.
|
|
33
|
+
if (shell) {
|
|
34
|
+
shell.openModal({
|
|
35
|
+
availableClose: false,
|
|
36
|
+
title: shell.t('Something went wrong'),
|
|
37
|
+
body: shell.t(message),
|
|
38
|
+
actions: [{ title: shell.t('Reload'), on: () => { try { location.reload(); } catch { /* non-browser */ } } }],
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
showFatalError(opts.container ?? '#game', message);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Global safety net: surface ANY uncaught error / unhandled rejection (e.g. an
|
|
46
|
+
// `Uncaught (in promise) SDKError` on spin) through the same fatal modal so games
|
|
47
|
+
// don't have to handle errors themselves. Honours the onFatalError override.
|
|
48
|
+
installGlobalErrorHandlers(opts.container ?? '#game', fatal);
|
|
49
|
+
|
|
50
|
+
let stakeBridge: SlotGameHandle['stakeBridge'] = null;
|
|
51
|
+
let isStakeNow = false;
|
|
52
|
+
if (opts.stake) {
|
|
53
|
+
const { isStakeLaunch } = await import('@energy8platform/stake-bridge/detect');
|
|
54
|
+
isStakeNow = isStakeLaunch(location.href);
|
|
55
|
+
if (isStakeNow) {
|
|
56
|
+
try {
|
|
57
|
+
const { StakeBridge } = await import('@energy8platform/stake-bridge');
|
|
58
|
+
stakeBridge = new StakeBridge({
|
|
59
|
+
devMode: true,
|
|
60
|
+
// In the dev harness the iframe is served over http and the dev-RGS
|
|
61
|
+
// lives at the same (http) origin; force the matching scheme so
|
|
62
|
+
// RGSClient can reach it. Prod (https) is unaffected.
|
|
63
|
+
protocol: location.protocol === 'http:' ? 'http' : 'https',
|
|
64
|
+
adapter: opts.stake.adapter,
|
|
65
|
+
modeMap: opts.model.modeMap,
|
|
66
|
+
gameId: opts.model.spec.id,
|
|
67
|
+
url: location.href,
|
|
68
|
+
});
|
|
69
|
+
await stakeBridge.ready();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
fatal('Could not connect to the game server. Please reload.');
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const game = new GameApplication(buildAppConfig(opts, isStakeNow));
|
|
78
|
+
|
|
79
|
+
// Register EVERY scene up front so any of them can navigate to any other.
|
|
80
|
+
for (const { key, scene } of opts.scenes) game.scenes.register(key, scene);
|
|
81
|
+
|
|
82
|
+
// Navigation injected into the start data of every scene: a scene reads `goto`
|
|
83
|
+
// from its `onEnter(data)` and calls it to switch scenes (intro → game, etc.).
|
|
84
|
+
const goto = (key: string, data?: unknown) => {
|
|
85
|
+
void game.scenes.goto(key, { ...(data as object), goto });
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Pick the start scene from the ordered list + launch mode: a replay launch skips any leading
|
|
89
|
+
// `skipOnReplay` scene (the intro) and starts directly on the game scene.
|
|
90
|
+
const { resolveStartScene } = await import('./sceneStart');
|
|
91
|
+
const startScene = resolveStartScene(opts.scenes, !!stakeBridge?.isReplay, opts.startScene);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await game.start(startScene, { ...(opts.startData as object), goto });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
fatal('Could not start the game.');
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let currentBet = opts.model.spec.defaultBet ?? opts.model.spec.betLevels[0];
|
|
101
|
+
|
|
102
|
+
// Build slotPlay FIRST — bindGameScene() needs it to be in scope.
|
|
103
|
+
const { createSlotPlay, enrichRoundMeta } = await import('./slotPlay');
|
|
104
|
+
|
|
105
|
+
/** The current scene IFF it implements the SlotSceneController contract (duck-typed on
|
|
106
|
+
* `present`). The host drives the play loop against whichever scene is current. */
|
|
107
|
+
const gameScene = () => {
|
|
108
|
+
const s = game.scenes.current?.scene as
|
|
109
|
+
| Partial<import('./sceneController').SlotSceneController<T>>
|
|
110
|
+
| undefined;
|
|
111
|
+
return typeof s?.present === 'function'
|
|
112
|
+
? (s as import('./sceneController').SlotSceneController<T>)
|
|
113
|
+
: undefined;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const { runRound } = await import('./runRound');
|
|
117
|
+
const { createBalanceGate } = await import('./balanceGate');
|
|
118
|
+
const { createFreeSpinsCounter } = await import('./freeSpinsCounter');
|
|
119
|
+
const { resolvePlayError } = await import('./playError');
|
|
120
|
+
|
|
121
|
+
// slotPlay references shell via closure — define it after shell is assigned below.
|
|
122
|
+
// We use a late-binding wrapper so the closure captures the variable, not null.
|
|
123
|
+
const slotPlay = createSlotPlay<T>({
|
|
124
|
+
play: (p) => game.platformSession!.play(p),
|
|
125
|
+
normalize: opts.normalize,
|
|
126
|
+
// ACK the result AFTER the scene animates it (the scene calls host.ack()). On Stake this
|
|
127
|
+
// triggers /wallet/end-round so a winning round settles post-animation instead of staying
|
|
128
|
+
// open and blocking the next spin.
|
|
129
|
+
ack: (raw) => game.platformSession!.playAck(raw as import('@energy8platform/platform-core').PlayResultData),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (opts.shell) {
|
|
133
|
+
const { createGameShell } = await import('@energy8platform/platform-core/shell');
|
|
134
|
+
const { buildShellConfig } = await import('./shellConfig');
|
|
135
|
+
const { resolveReplayBonusId } = await import('./replay');
|
|
136
|
+
|
|
137
|
+
const ps = game.platformSession;
|
|
138
|
+
const balance = (game.initData?.balance as number | undefined) ?? 0;
|
|
139
|
+
const isReplay = !!stakeBridge?.isReplay;
|
|
140
|
+
const mode: ShellMode = isReplay ? 'replay' : 'base';
|
|
141
|
+
// initData.config carries the Stake bridge's currency/social/disclaimer surface (GameConfigData);
|
|
142
|
+
// all are absent in non-stake/dev launches → graceful fallbacks downstream.
|
|
143
|
+
const initData = game.initData as {
|
|
144
|
+
config?: {
|
|
145
|
+
socialMode?: boolean;
|
|
146
|
+
disclaimerLines?: string[];
|
|
147
|
+
currency?: { code: string; symbol: string; decimals: number; symbolAfter?: boolean };
|
|
148
|
+
jurisdiction?: import('./shellConfig').JurisdictionRestrictions;
|
|
149
|
+
betLevels?: number[];
|
|
150
|
+
defaultBet?: number;
|
|
151
|
+
stake?: { defaultBetLevel?: number };
|
|
152
|
+
};
|
|
153
|
+
lang?: string;
|
|
154
|
+
} | null;
|
|
155
|
+
const config = initData?.config;
|
|
156
|
+
const { resolveCurrency } = await import('./shellConfig');
|
|
157
|
+
// SINGLE source of truth for the symbol: the Stake bridge already puts a full CurrencyMetaData
|
|
158
|
+
// (symbol + placement) on initData.config.currency. In the non-stake/devBridge path that meta
|
|
159
|
+
// is absent and we only have the spec's currency CODE — resolve it through the SAME table
|
|
160
|
+
// (stake-bridge's lookupCurrency) so e.g. 'EUR' renders as '€', not the literal text "EUR".
|
|
161
|
+
// stake-bridge ships with every scaffold; if it's somehow absent we degrade to the code.
|
|
162
|
+
let currencyMeta = config?.currency;
|
|
163
|
+
if (!currencyMeta?.symbol && opts.model.spec.currency) {
|
|
164
|
+
try {
|
|
165
|
+
const { lookupCurrency } = await import('@energy8platform/stake-bridge');
|
|
166
|
+
currencyMeta = lookupCurrency(opts.model.spec.currency);
|
|
167
|
+
} catch { /* stake-bridge not installed — resolveCurrency falls back to the code */ }
|
|
168
|
+
}
|
|
169
|
+
const runtime = {
|
|
170
|
+
balance,
|
|
171
|
+
currency: resolveCurrency(currencyMeta, opts.model.spec.currency),
|
|
172
|
+
language: initData?.lang,
|
|
173
|
+
mode,
|
|
174
|
+
social: config?.socialMode,
|
|
175
|
+
disclaimerLines: config?.disclaimerLines,
|
|
176
|
+
jurisdiction: config?.jurisdiction,
|
|
177
|
+
// Currency-specific ladder + per-currency default from /wallet/authenticate (Stake);
|
|
178
|
+
// absent on dev/devBridge → buildShellConfig falls back to the spec.
|
|
179
|
+
betLevels: config?.betLevels,
|
|
180
|
+
defaultBet: config?.stake?.defaultBetLevel ?? config?.defaultBet,
|
|
181
|
+
};
|
|
182
|
+
if (opts.dev) {
|
|
183
|
+
// Dev-only diagnostic. Logged as PLAIN STRINGS (not collapsed objects) so the values are
|
|
184
|
+
// readable in the console without expanding. If the shown symbol is a bare code ("EUR")
|
|
185
|
+
// instead of a glyph ("€"), paste this whole line.
|
|
186
|
+
const cc = config?.currency as { code?: string; symbol?: string } | undefined;
|
|
187
|
+
console.info(
|
|
188
|
+
`[e8] currency → bridge.code=${cc?.code ?? '∅'} bridge.symbol=${cc?.symbol ?? '∅'} ` +
|
|
189
|
+
`| spec=${opts.model.spec.currency ?? '∅'} ` +
|
|
190
|
+
`| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
shell = createGameShell(buildShellConfig(opts.shell, opts.model, runtime));
|
|
194
|
+
// The gate tracks the live wallet (for the affordability guard) but only PAINTS the balance per
|
|
195
|
+
// the HUD-timing rule: the debit is buffered during play→present and shown at afterPresent; the
|
|
196
|
+
// async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
|
|
197
|
+
// is the single source for both the displayed balance and `ensureAffordable`.
|
|
198
|
+
const balanceGate = createBalanceGate((b) => shell!.setBalance(b), balance);
|
|
199
|
+
ps?.on('balanceUpdate', (d: { balance: number }) => { balanceGate.onBalance(d.balance); });
|
|
200
|
+
|
|
201
|
+
// Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
|
|
202
|
+
let currentTurbo = shell.state.turbo;
|
|
203
|
+
shell.on('turboChange', (level: number) => { currentTurbo = level; });
|
|
204
|
+
|
|
205
|
+
const roleOf = (action: string) => opts.model.spec.actions[action]?.role;
|
|
206
|
+
const makeContext = (action: string): import('./sceneController').RenderContext => ({
|
|
207
|
+
bet: currentBet,
|
|
208
|
+
action,
|
|
209
|
+
mode: opts.model.modeMap[action] ?? action.toUpperCase(),
|
|
210
|
+
formatAmount: (v) => shell!.formatWin(v),
|
|
211
|
+
get turbo() { return currentTurbo; },
|
|
212
|
+
});
|
|
213
|
+
// Play-error + connection handling. A play rejection is classified into a player-facing modal
|
|
214
|
+
// (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
|
|
215
|
+
// overlay is suppressed while a play-error modal owns the screen.
|
|
216
|
+
let playErrorOpen = false;
|
|
217
|
+
let stopAutoplay: () => void = () => {}; // wired to the autoplay loop once it's created (below)
|
|
218
|
+
const showPlayError = (err: unknown): void => {
|
|
219
|
+
stopAutoplay(); // a play error halts an autoplay run (the .catch swallows, so stop explicitly)
|
|
220
|
+
const v = resolvePlayError(err);
|
|
221
|
+
playErrorOpen = true;
|
|
222
|
+
shell!.openModal({
|
|
223
|
+
availableClose: !v.reload,
|
|
224
|
+
title: shell!.t(v.title),
|
|
225
|
+
body: shell!.t(v.body),
|
|
226
|
+
actions: v.reload
|
|
227
|
+
? [{ title: shell!.t('Reload'), on: () => { try { window.location.reload(); } catch { /* non-browser */ } } }]
|
|
228
|
+
: [{ title: shell!.t('OK'), on: () => { playErrorOpen = false; } }],
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
ps?.on('connectionStateChanged', (s: { status: string }) => {
|
|
232
|
+
if (s.status === 'restored') { if (!playErrorOpen) shell!.closeModal(); return; }
|
|
233
|
+
if (playErrorOpen) return; // a play-error modal owns the screen — don't mask it with "reconnecting"
|
|
234
|
+
shell!.openModal({
|
|
235
|
+
availableClose: false,
|
|
236
|
+
title: shell!.t('Reconnecting…'),
|
|
237
|
+
body: shell!.t('Lost connection to the game server. Trying to reconnect…'),
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
/** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
|
|
242
|
+
* update only AFTER each present(), per the HUD-timing requirement. */
|
|
243
|
+
const playRound = (action: string) => {
|
|
244
|
+
const scene = gameScene();
|
|
245
|
+
if (!scene) return;
|
|
246
|
+
// Per-round free-spins state: the shell enters FS mode on bonus-enter and shows current/total
|
|
247
|
+
// (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
|
|
248
|
+
// the trigger segment (presented before onBonusEnter) doesn't count as a free spin.
|
|
249
|
+
let inBonus = false;
|
|
250
|
+
let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
|
|
251
|
+
const fsCounter = createFreeSpinsCounter();
|
|
252
|
+
shell!.setBusy(true); // block re-spin / spacebar while the round plays out
|
|
253
|
+
// RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
|
|
254
|
+
// animation has finished — returning void would reopen it instantly, over a running animation.
|
|
255
|
+
return runRound<T>(
|
|
256
|
+
{
|
|
257
|
+
// Suppress the debit paint from play() until this segment's afterPresent (HUD timing).
|
|
258
|
+
play: (a, b, rid) => { balanceGate.beginPlay(); return slotPlay.play(a, b, rid); },
|
|
259
|
+
ack: slotPlay.ack,
|
|
260
|
+
scene,
|
|
261
|
+
context: makeContext,
|
|
262
|
+
roleOf,
|
|
263
|
+
afterPresent: (r) => {
|
|
264
|
+
// WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
|
|
265
|
+
// free-spins counter (totalWin) below, not the WIN readout.
|
|
266
|
+
shell!.setWin(r.totalWin - prevWin);
|
|
267
|
+
prevWin = r.totalWin;
|
|
268
|
+
balanceGate.afterPresent();
|
|
269
|
+
if (inBonus) shell!.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
|
|
270
|
+
},
|
|
271
|
+
onBonusEnter: async (trigger, ctx) => {
|
|
272
|
+
inBonus = true;
|
|
273
|
+
shell!.setMode('freeSpins');
|
|
274
|
+
shell!.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
|
|
275
|
+
await scene.onBonusEnter?.(trigger, ctx);
|
|
276
|
+
},
|
|
277
|
+
onBonusExit: async (last, ctx) => {
|
|
278
|
+
inBonus = false;
|
|
279
|
+
await scene.onBonusExit?.(last, ctx);
|
|
280
|
+
shell!.setMode('base');
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
action,
|
|
284
|
+
).catch(showPlayError).finally(() => shell!.setBusy(false));
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
|
|
289
|
+
* the bonus start (Continue animates each; Finish fast-forwards without animation), reaching the
|
|
290
|
+
* final ack so /wallet/end-round credits the win — fixing the old resume that presented one
|
|
291
|
+
* snapshot and never settled. The original trigger is gone on reload, so the FS counter here uses
|
|
292
|
+
* the bridge's session counts; FS mode is entered/exited around the drain.
|
|
293
|
+
*/
|
|
294
|
+
const resumeDrain = async (
|
|
295
|
+
firstRaw: import('@energy8platform/platform-core').PlayResultData,
|
|
296
|
+
animate: boolean,
|
|
297
|
+
): Promise<void> => {
|
|
298
|
+
const scene = gameScene();
|
|
299
|
+
if (!scene || !ps) return;
|
|
300
|
+
const ctx = makeContext((firstRaw as { action?: string }).action ?? 'spin');
|
|
301
|
+
const fsView = (raw: unknown, totalWin: number) => {
|
|
302
|
+
const s = (raw as { session?: { spinsPlayed?: number; spinsRemaining?: number } }).session;
|
|
303
|
+
if (!s) return null;
|
|
304
|
+
// The bridge session counts ALL segments incl. the trigger (segment 0); the free-spins
|
|
305
|
+
// counter is over FREE spins only, so drop the one trigger segment → 1/10, not 2/11.
|
|
306
|
+
const played = s.spinsPlayed ?? 0;
|
|
307
|
+
const current = Math.max(0, played - 1);
|
|
308
|
+
const total = Math.max(0, played + (s.spinsRemaining ?? 0) - 1);
|
|
309
|
+
return { current, total, totalWin };
|
|
310
|
+
};
|
|
311
|
+
let raw = firstRaw;
|
|
312
|
+
let r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
313
|
+
let inBonus = false;
|
|
314
|
+
let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
|
|
315
|
+
const applySegment = async (): Promise<void> => {
|
|
316
|
+
// A recovered open round with remaining segments is a bonus → show FS mode + counter.
|
|
317
|
+
if (!inBonus && !r.complete) { inBonus = true; shell!.setMode('freeSpins'); }
|
|
318
|
+
if (animate) await scene.present(r, ctx);
|
|
319
|
+
if (inBonus) { const v = fsView(raw, r.totalWin); if (v) shell!.setFreeSpins(v); }
|
|
320
|
+
shell!.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
|
|
321
|
+
prevWin = r.totalWin;
|
|
322
|
+
ps!.playAck(raw); // settles via /wallet/end-round on the FINAL segment
|
|
323
|
+
};
|
|
324
|
+
shell!.setBusy(true); // block input while the recovered round drains
|
|
325
|
+
try {
|
|
326
|
+
await applySegment();
|
|
327
|
+
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
328
|
+
raw = (await ps.play({ action: r.nextActions[0], bet: ctx.bet, roundId: r.roundId })) as
|
|
329
|
+
import('@energy8platform/platform-core').PlayResultData;
|
|
330
|
+
r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
331
|
+
await applySegment();
|
|
332
|
+
}
|
|
333
|
+
if (inBonus) shell!.setMode('base');
|
|
334
|
+
} finally {
|
|
335
|
+
shell!.setBusy(false);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
if (mode === 'base') {
|
|
340
|
+
let activeFeature: string | null = null;
|
|
341
|
+
shell.on('featureActivate', ({ id }: { id: string }) => { activeFeature = id; });
|
|
342
|
+
shell.on('featureDeactivate', ({ id: _id }: { id: string }) => { activeFeature = null; });
|
|
343
|
+
|
|
344
|
+
const { stakeForAction } = await import('./shellConfig');
|
|
345
|
+
// Guard a play: if the stake exceeds the balance, show a shell modal and DON'T play.
|
|
346
|
+
const ensureAffordable = (action: string): boolean => {
|
|
347
|
+
if (stakeForAction(opts.model, action, currentBet) <= balanceGate.balance + 1e-9) return true;
|
|
348
|
+
shell!.openModal({
|
|
349
|
+
availableClose: true,
|
|
350
|
+
title: shell!.t('Insufficient balance'),
|
|
351
|
+
body: shell!.t('You don’t have enough balance for this bet. Lower your bet or top up.'),
|
|
352
|
+
actions: [{ title: shell!.t('OK') }],
|
|
353
|
+
});
|
|
354
|
+
return false;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
shell.on('spin', () => {
|
|
358
|
+
const action = activeFeature ?? 'spin';
|
|
359
|
+
if (!ensureAffordable(action)) return;
|
|
360
|
+
void playRound(action);
|
|
361
|
+
});
|
|
362
|
+
shell.on('betChange', (bet: number) => { currentBet = bet; });
|
|
363
|
+
shell.on('buyBonusSelect', ({ id }: { id: string }) => {
|
|
364
|
+
if (!ensureAffordable(id)) return;
|
|
365
|
+
void playRound(id);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Autoplay: the shell owns the picker/confirm/STOP/counter/lockout (all driven by state.autoplay);
|
|
369
|
+
// the host just runs the loop and pushes the per-spin remaining back via setAutoplay.
|
|
370
|
+
const { createAutoplayLoop } = await import('./autoplay');
|
|
371
|
+
const autoplay = createAutoplayLoop({
|
|
372
|
+
resolveAction: () => activeFeature ?? 'spin',
|
|
373
|
+
canAfford: (a) => ensureAffordable(a),
|
|
374
|
+
playRound: (a) => Promise.resolve(playRound(a)),
|
|
375
|
+
onState: (s) => shell!.setAutoplay(s),
|
|
376
|
+
});
|
|
377
|
+
stopAutoplay = () => autoplay.stop();
|
|
378
|
+
shell.on('autoplayStart', (o: { remaining?: number }) => autoplay.start(o?.remaining ?? 0));
|
|
379
|
+
shell.on('autoplayStop', () => autoplay.stop());
|
|
380
|
+
|
|
381
|
+
// Resume offer: when the game scene is (or becomes) current on a reload, ask the host whether
|
|
382
|
+
// a round is still open. If so, offer Continue (replay its animation, then settle) or Finish
|
|
383
|
+
// (settle now). Settlement is the same playAck path a normal spin uses. Runs at most once.
|
|
384
|
+
let resumeOffered = false;
|
|
385
|
+
const offerResume = async () => {
|
|
386
|
+
if (resumeOffered || !shell || !gameScene()) return;
|
|
387
|
+
resumeOffered = true;
|
|
388
|
+
let snap: import('@energy8platform/platform-core').PlayResultData | null = null;
|
|
389
|
+
try { snap = await ps?.getState() ?? null; } catch { snap = null; }
|
|
390
|
+
if (!snap) return;
|
|
391
|
+
shell.openModal({
|
|
392
|
+
availableClose: false,
|
|
393
|
+
title: shell.t('Unfinished round'),
|
|
394
|
+
body: shell.t('You have an unfinished round. Continue it or finish it now?'),
|
|
395
|
+
actions: [
|
|
396
|
+
// Continue: replay the round from the start with animation, then settle.
|
|
397
|
+
{ title: shell.t('Continue'), on: () => { void resumeDrain(snap!, true); } },
|
|
398
|
+
// Finish: fast-forward the remaining segments (no animation) to settle the win now.
|
|
399
|
+
{ title: shell.t('Finish'), on: () => { void resumeDrain(snap!, false); } },
|
|
400
|
+
],
|
|
401
|
+
});
|
|
402
|
+
};
|
|
403
|
+
game.scenes.on('change', () => { void offerResume(); });
|
|
404
|
+
void offerResume();
|
|
405
|
+
} else {
|
|
406
|
+
const stakeMode = stakeBridge?.replayMode ?? 'BASE';
|
|
407
|
+
const bonusId = resolveReplayBonusId(opts.model, stakeMode);
|
|
408
|
+
// The replayed round's OWN bet + payout (fetched up front per Stake rules), not the spec's
|
|
409
|
+
// default bet — otherwise the replay modal always shows bet 1.
|
|
410
|
+
const replayBet = stakeBridge?.replayBet || currentBet;
|
|
411
|
+
currentBet = replayBet;
|
|
412
|
+
// onReplay only spins — the shell reopens the modal after it resolves; never call openReplay inside onReplay (double-open).
|
|
413
|
+
shell.openReplay({
|
|
414
|
+
bonusId,
|
|
415
|
+
bet: replayBet,
|
|
416
|
+
payoutMultiplier: stakeBridge?.replayPayoutMultiplier ?? 0,
|
|
417
|
+
onReplay: () => playRound(bonusId),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return { game, stakeBridge, shell };
|
|
423
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// packages/game-engine/src/host/fatalError.ts
|
|
2
|
+
|
|
3
|
+
/** Marker id so the modal is idempotent (first one wins; later calls replace its message). */
|
|
4
|
+
const FATAL_ID = 'e8-fatal-error';
|
|
5
|
+
|
|
6
|
+
/** Pure: extract a human-readable message from any thrown value / event reason. */
|
|
7
|
+
export function fatalMessage(input: unknown): string {
|
|
8
|
+
if (input == null) return 'Something went wrong.';
|
|
9
|
+
if (typeof input === 'string') return input;
|
|
10
|
+
if (input instanceof Error) return input.message || input.name || 'Something went wrong.';
|
|
11
|
+
const anyIn = input as { message?: unknown; reason?: unknown };
|
|
12
|
+
if (typeof anyIn.message === 'string' && anyIn.message) return anyIn.message;
|
|
13
|
+
if (anyIn.reason != null) return fatalMessage(anyIn.reason);
|
|
14
|
+
try {
|
|
15
|
+
return String(input);
|
|
16
|
+
} catch {
|
|
17
|
+
return 'Something went wrong.';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Pure: build the modal overlay element (error text + a Reload button). */
|
|
22
|
+
export function buildFatalErrorModal(message: string, onReload: () => void): HTMLElement {
|
|
23
|
+
const overlay = document.createElement('div');
|
|
24
|
+
overlay.id = FATAL_ID;
|
|
25
|
+
overlay.setAttribute('role', 'alertdialog');
|
|
26
|
+
overlay.style.cssText =
|
|
27
|
+
'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;' +
|
|
28
|
+
'background:rgba(10,5,4,0.92);z-index:99999;font-family:system-ui,sans-serif;padding:24px';
|
|
29
|
+
|
|
30
|
+
const card = document.createElement('div');
|
|
31
|
+
card.style.cssText =
|
|
32
|
+
'max-width:420px;width:100%;background:#1a0f0a;border:1px solid #5a3a1e;border-radius:12px;' +
|
|
33
|
+
'padding:28px 24px;text-align:center;box-shadow:0 12px 40px rgba(0,0,0,0.6)';
|
|
34
|
+
|
|
35
|
+
const text = document.createElement('div');
|
|
36
|
+
text.className = 'e8-fatal-message';
|
|
37
|
+
text.style.cssText = 'color:#f0c98a;font:600 17px/1.4 system-ui,sans-serif;margin-bottom:22px';
|
|
38
|
+
text.textContent = message;
|
|
39
|
+
|
|
40
|
+
const button = document.createElement('button');
|
|
41
|
+
button.type = 'button';
|
|
42
|
+
button.className = 'e8-fatal-reload';
|
|
43
|
+
button.textContent = 'Reload';
|
|
44
|
+
button.style.cssText =
|
|
45
|
+
'cursor:pointer;border:none;border-radius:8px;padding:12px 28px;font:600 15px system-ui,sans-serif;' +
|
|
46
|
+
'color:#1a0f0a;background:#f0c98a';
|
|
47
|
+
button.addEventListener('click', onReload);
|
|
48
|
+
|
|
49
|
+
card.appendChild(text);
|
|
50
|
+
card.appendChild(button);
|
|
51
|
+
overlay.appendChild(card);
|
|
52
|
+
return overlay;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render a blocking fatal-error modal with a Reload button. Idempotent: if a modal is already
|
|
57
|
+
* shown, its message is replaced instead of stacking a second overlay.
|
|
58
|
+
*/
|
|
59
|
+
export function showFatalError(container: HTMLElement | string, message: string): void {
|
|
60
|
+
if (typeof document === 'undefined') return;
|
|
61
|
+
const host =
|
|
62
|
+
typeof container === 'string'
|
|
63
|
+
? document.querySelector<HTMLElement>(container) ?? document.body
|
|
64
|
+
: container;
|
|
65
|
+
|
|
66
|
+
const existing = document.getElementById(FATAL_ID);
|
|
67
|
+
if (existing) {
|
|
68
|
+
const msg = existing.querySelector<HTMLElement>('.e8-fatal-message');
|
|
69
|
+
if (msg) msg.textContent = message;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const overlay = buildFatalErrorModal(message, () => {
|
|
74
|
+
try {
|
|
75
|
+
location.reload();
|
|
76
|
+
} catch {
|
|
77
|
+
/* no-op in non-browser environments */
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
host.appendChild(overlay);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Install global handlers so ANY uncaught error or unhandled promise rejection surfaces the
|
|
85
|
+
* fatal modal (game devs don't have to handle errors themselves). `fatal` defaults to the
|
|
86
|
+
* built-in modal targeting `container`. Returns a disposer that removes the listeners.
|
|
87
|
+
*/
|
|
88
|
+
export function installGlobalErrorHandlers(
|
|
89
|
+
container: HTMLElement | string,
|
|
90
|
+
fatal: (message: string) => void = (m) => showFatalError(container, m),
|
|
91
|
+
): () => void {
|
|
92
|
+
if (typeof window === 'undefined') return () => {};
|
|
93
|
+
|
|
94
|
+
const onError = (e: ErrorEvent) => fatal(fatalMessage(e.error ?? e.message));
|
|
95
|
+
const onRejection = (e: PromiseRejectionEvent) => fatal(fatalMessage(e.reason));
|
|
96
|
+
|
|
97
|
+
window.addEventListener('error', onError);
|
|
98
|
+
window.addEventListener('unhandledrejection', onRejection);
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
window.removeEventListener('error', onError);
|
|
102
|
+
window.removeEventListener('unhandledrejection', onRejection);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free-spins counter for the shell readout (current / total / totalWin), with RETRIGGER support.
|
|
3
|
+
*
|
|
4
|
+
* A bonus awards an initial pool of spins; a retrigger mid-bonus awards MORE. The full-event book
|
|
5
|
+
* already contains every segment (incl. retriggered spins), but the player-facing counter must grow
|
|
6
|
+
* dynamically: start at the awarded total, and each spin that awards extra bumps the total. The
|
|
7
|
+
* remaining spins the shell shows are `total - current`.
|
|
8
|
+
*
|
|
9
|
+
* Example (the canonical case): enter(10) → 0/10. After two spins → 2/10. The third spin retriggers
|
|
10
|
+
* +5 → 3/15 (i.e. 12 remaining). `awarded` per spin is the spins granted by THAT spin (0 normally,
|
|
11
|
+
* the retrigger amount on a retrigger). Pure + unit-testable; the host feeds it `result.freeSpins`.
|
|
12
|
+
*/
|
|
13
|
+
export interface FreeSpinsView {
|
|
14
|
+
/** Free spins played so far (1-based once spinning). */
|
|
15
|
+
current: number;
|
|
16
|
+
/** Total free spins awarded so far (initial + every retrigger). */
|
|
17
|
+
total: number;
|
|
18
|
+
/** Cumulative bonus win (the host passes the round's cumulative totalWin). */
|
|
19
|
+
totalWin: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FreeSpinsCounter {
|
|
23
|
+
/** Bonus start: seed the total with the trigger's awarded spins. Resets current + totalWin. */
|
|
24
|
+
enter(awarded: number): FreeSpinsView;
|
|
25
|
+
/** One free spin presented: count it, fold in any retrigger `awarded`, carry the cumulative win. */
|
|
26
|
+
spin(awarded: number, totalWin: number): FreeSpinsView;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createFreeSpinsCounter(): FreeSpinsCounter {
|
|
30
|
+
let total = 0;
|
|
31
|
+
let current = 0;
|
|
32
|
+
return {
|
|
33
|
+
enter(awarded: number): FreeSpinsView {
|
|
34
|
+
total = awarded;
|
|
35
|
+
current = 0;
|
|
36
|
+
return { current, total, totalWin: 0 };
|
|
37
|
+
},
|
|
38
|
+
spin(awarded: number, totalWin: number): FreeSpinsView {
|
|
39
|
+
current += 1;
|
|
40
|
+
total += awarded; // a retrigger grows the pool
|
|
41
|
+
return { current, total, totalWin };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// packages/game-engine/src/host/index.ts
|
|
2
|
+
export { createSlotGame } from './createSlotGame';
|
|
3
|
+
export type {
|
|
4
|
+
CreateSlotGameOptions,
|
|
5
|
+
SlotGameHandle,
|
|
6
|
+
StakeIntegration,
|
|
7
|
+
SceneRegistration,
|
|
8
|
+
SceneNavData,
|
|
9
|
+
SceneEntry,
|
|
10
|
+
} from './types';
|
|
11
|
+
export { buildShellConfig, stakeForAction } from './shellConfig';
|
|
12
|
+
export type { SlotShellOptions } from './shellConfig';
|
|
13
|
+
export { resolveReplayBonusId } from './replay';
|
|
14
|
+
export { resolveStartScene } from './sceneStart';
|
|
15
|
+
export type { SlotSceneController, RenderContext } from './sceneController';
|
|
16
|
+
// Social-casino word-swap. The shell auto-socializes all gameInfo/buyBonus text in social mode;
|
|
17
|
+
// authors only need this to socialize strings they render themselves (e.g. inside a custom DOM node).
|
|
18
|
+
export { socialize } from '@energy8platform/platform-core/shell';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a play/settle error into a player-facing modal payload.
|
|
3
|
+
*
|
|
4
|
+
* The SDK rejects `play()` with an `SDKError` carrying a `.code` (e.g. `ACTIVE_SESSION_EXISTS`,
|
|
5
|
+
* `INSUFFICIENT_FUNDS`, `TIMEOUT`). The bridge ALSO emits a `connectionStateChanged: 'lost'` for
|
|
6
|
+
* some of these, which would otherwise surface a misleading "Reconnecting…" overlay while the real
|
|
7
|
+
* fix is "reload to resume". The host routes every play error through this classifier so the player
|
|
8
|
+
* sees the right message + action, and suppresses the connection overlay while a play-error modal
|
|
9
|
+
* is up.
|
|
10
|
+
*
|
|
11
|
+
* `reload: true` → the round must be recovered by reloading (an unfinished round blocks new plays);
|
|
12
|
+
* the modal offers a Reload button. Otherwise it's a dismissible OK.
|
|
13
|
+
*/
|
|
14
|
+
export interface PlayErrorView {
|
|
15
|
+
title: string;
|
|
16
|
+
body: string;
|
|
17
|
+
/** Offer a Reload action (the round can only be recovered by reloading). */
|
|
18
|
+
reload: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Pull a Stake/SDK error code off an unknown thrown value. */
|
|
22
|
+
export function errorCode(err: unknown): string | undefined {
|
|
23
|
+
const code = (err as { code?: unknown })?.code;
|
|
24
|
+
return typeof code === 'string' ? code : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolvePlayError(err: unknown): PlayErrorView {
|
|
28
|
+
const code = errorCode(err);
|
|
29
|
+
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
|
30
|
+
switch (code) {
|
|
31
|
+
case 'ACTIVE_SESSION_EXISTS':
|
|
32
|
+
return {
|
|
33
|
+
title: 'Round in progress',
|
|
34
|
+
body: 'You have an unfinished round. Reload to resume it.',
|
|
35
|
+
reload: true,
|
|
36
|
+
};
|
|
37
|
+
case 'NO_ACTIVE_SESSION':
|
|
38
|
+
return {
|
|
39
|
+
title: 'Round expired',
|
|
40
|
+
body: 'This round is no longer active. Reload to continue.',
|
|
41
|
+
reload: true,
|
|
42
|
+
};
|
|
43
|
+
case 'INSUFFICIENT_FUNDS':
|
|
44
|
+
return {
|
|
45
|
+
title: 'Insufficient balance',
|
|
46
|
+
body: 'You don’t have enough balance for this bet. Lower your bet or top up.',
|
|
47
|
+
reload: false,
|
|
48
|
+
};
|
|
49
|
+
case 'TIMEOUT':
|
|
50
|
+
return {
|
|
51
|
+
title: 'Connection timed out',
|
|
52
|
+
body: 'The game server did not respond in time. Please try again.',
|
|
53
|
+
reload: false,
|
|
54
|
+
};
|
|
55
|
+
default:
|
|
56
|
+
// Unknown code: surface the server message verbatim under a generic heading (never the
|
|
57
|
+
// connection overlay), so an operator can diagnose without a code change.
|
|
58
|
+
return {
|
|
59
|
+
title: 'Game error',
|
|
60
|
+
body: message || 'Something went wrong. Please reload the game.',
|
|
61
|
+
reload: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|