@energy8platform/platform-core 0.18.0 → 0.20.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.
@@ -93,6 +93,16 @@ export class PlatformSession extends EventEmitter<PlatformSessionEvents> {
93
93
  return this.sdk?.currency ?? 'USD';
94
94
  }
95
95
 
96
+ /**
97
+ * `true` when launched as a historical-round replay (the host set
98
+ * `config.replayMode`). Games read this to hide balance/bet/autoplay UI
99
+ * and surface a "Play / Play Again" CTA only. Falls back to the handshake
100
+ * config when no SDK is present.
101
+ */
102
+ get isReplay(): boolean {
103
+ return this.sdk?.isReplay ?? this.initData?.config?.replayMode ?? false;
104
+ }
105
+
96
106
  /**
97
107
  * Send a play request through the SDK and resolve with the host result.
98
108
  * Throws if the session was constructed with `sdk: false`.
@@ -10,6 +10,64 @@ import {
10
10
  } from '@energy8platform/game-sdk';
11
11
  import type { GameDefinition, BetLevelsConfig } from '../lua/types';
12
12
 
13
+ /**
14
+ * A detected replay launch — the host wants to re-play a historical round
15
+ * instead of placing live bets. `mode`/`roundId` are forwarded verbatim to
16
+ * {@link ReplayConfig.resolve}.
17
+ */
18
+ export interface ReplayLaunch {
19
+ /** Game mode the round was recorded in (e.g. "BASE", "BONUS"). */
20
+ mode?: string;
21
+ /** Identifier of the recorded round to replay. */
22
+ roundId?: string;
23
+ }
24
+
25
+ /**
26
+ * Opt-in replay support for the DevBridge mock host.
27
+ *
28
+ * In production the casino backend serves a recorded round; in dev the
29
+ * DevBridge IS the host. Provide a `resolve` callback that returns the
30
+ * recorded rounds — DevBridge stays agnostic about where they come from
31
+ * (fetch, static fixtures, localStorage, …). When a replay launch is
32
+ * detected, the bridge flips `config.replayMode = true` and feeds the
33
+ * recorded `PlayResultData[]` back on each play request, without touching
34
+ * the wallet.
35
+ */
36
+ export interface ReplayConfig {
37
+ /**
38
+ * Resolve the recorded rounds for a replay launch. Receives the `mode`
39
+ * and `roundId` from {@link detect}. May be async (e.g. a fetch).
40
+ */
41
+ resolve: (
42
+ mode: string | undefined,
43
+ roundId: string | undefined,
44
+ ) => PlayResultData[] | Promise<PlayResultData[]>;
45
+
46
+ /**
47
+ * Decide whether this launch is a replay and extract its params.
48
+ * Defaults to reading `?replay=1&mode=…&event=…` from the browser URL.
49
+ * Return `null` for a normal (non-replay) launch.
50
+ */
51
+ detect?: () => ReplayLaunch | null;
52
+ }
53
+
54
+ /**
55
+ * Default replay detector — reads `?replay=…&mode=…&event=…` from the
56
+ * browser URL. Returns `null` outside the browser or when `replay` is absent
57
+ * or falsy, so the bridge falls back to normal (live) play.
58
+ */
59
+ function defaultReplayDetect(): ReplayLaunch | null {
60
+ const loc = (globalThis as { location?: { search?: string } }).location;
61
+ if (!loc?.search) return null;
62
+ const params = new URLSearchParams(loc.search);
63
+ const replay = params.get('replay');
64
+ if (!replay || replay === '0' || replay === 'false') return null;
65
+ return {
66
+ mode: params.get('mode') ?? undefined,
67
+ roundId: params.get('event') ?? params.get('roundId') ?? undefined,
68
+ };
69
+ }
70
+
13
71
  /** Default session TTL when GameDefinition.session_ttl is omitted (24h). */
14
72
  const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
15
73
 
@@ -82,9 +140,15 @@ export interface DevBridgeConfig {
82
140
  gameDefinition?: GameDefinition;
83
141
  /** RNG seed for deterministic Lua execution */
84
142
  luaSeed?: number;
143
+ /**
144
+ * Opt-in historical-round replay. When the detector reports a replay
145
+ * launch, the bridge serves recorded rounds instead of running Lua/onPlay.
146
+ * See {@link ReplayConfig}.
147
+ */
148
+ replay?: ReplayConfig;
85
149
  }
86
150
 
87
- const DEFAULT_CONFIG: Omit<Required<DevBridgeConfig>, 'luaScript' | 'gameDefinition' | 'luaSeed'> = {
151
+ const DEFAULT_CONFIG: Omit<Required<DevBridgeConfig>, 'luaScript' | 'gameDefinition' | 'luaSeed' | 'replay'> = {
88
152
  balance: 10000,
89
153
  currency: 'USD',
90
154
  gameConfig: {
@@ -128,7 +192,7 @@ const DEFAULT_CONFIG: Omit<Required<DevBridgeConfig>, 'luaScript' | 'gameDefinit
128
192
  * ```
129
193
  */
130
194
  export class DevBridge {
131
- private _config: Required<Pick<DevBridgeConfig, 'balance' | 'currency' | 'gameConfig' | 'assetsUrl' | 'session' | 'onPlay' | 'networkDelay' | 'debug'>> & Pick<DevBridgeConfig, 'luaScript' | 'gameDefinition' | 'luaSeed'>;
195
+ private _config: Required<Pick<DevBridgeConfig, 'balance' | 'currency' | 'gameConfig' | 'assetsUrl' | 'session' | 'onPlay' | 'networkDelay' | 'debug'>> & Pick<DevBridgeConfig, 'luaScript' | 'gameDefinition' | 'luaSeed' | 'replay'>;
132
196
  private _balance: number;
133
197
  private _roundCounter = 0;
134
198
  private _bridge: Bridge | null = null;
@@ -141,12 +205,27 @@ export class DevBridge {
141
205
  private _sessionExpiresAt: number | null = null;
142
206
  /** Pre-parsed session TTL from gameDefinition.session_ttl. */
143
207
  private _sessionTtlMs: number;
208
+ /** Detected replay launch, or null when not replaying. */
209
+ private _replayLaunch: ReplayLaunch | null = null;
210
+ /** Recorded rounds for the active replay (resolved lazily once). */
211
+ private _replayResults: Promise<PlayResultData[]> | null = null;
212
+ /** Cursor into the recorded rounds; wraps to 0 on "Play Again". */
213
+ private _replayCursor = 0;
144
214
 
145
215
  constructor(config: DevBridgeConfig = {}) {
146
216
  this._config = { ...DEFAULT_CONFIG, ...config };
147
217
  this._balance = this._config.balance;
148
218
  this._useLuaServer = !!(this._config.luaScript && this._config.gameDefinition);
149
219
  this._sessionTtlMs = parseSessionTtl(this._config.gameDefinition?.session_ttl);
220
+ if (this._config.replay) {
221
+ const detect = this._config.replay.detect ?? defaultReplayDetect;
222
+ this._replayLaunch = detect();
223
+ }
224
+ }
225
+
226
+ /** True when this bridge was launched as a historical-round replay. */
227
+ get isReplay(): boolean {
228
+ return this._replayLaunch !== null;
150
229
  }
151
230
 
152
231
  /** Current mock balance */
@@ -218,6 +297,11 @@ export class DevBridge {
218
297
  // ─── Message Handling ──────────────────────────────────
219
298
 
220
299
  private handleGameReady(id?: string): void {
300
+ if (this._replayLaunch) {
301
+ this.handleReplayGameReady(id);
302
+ return;
303
+ }
304
+
221
305
  const initData: InitData = {
222
306
  balance: this._balance,
223
307
  currency: this._config.currency,
@@ -229,10 +313,84 @@ export class DevBridge {
229
313
  this.delayedSend('INIT', initData, id);
230
314
  }
231
315
 
316
+ /**
317
+ * Replay INIT: flip `config.replayMode = true` and take balance/currency
318
+ * from the recorded results (the wallet is never touched in replay).
319
+ */
320
+ private handleReplayGameReady(id?: string): void {
321
+ this.resolveReplayResults()
322
+ .then((results) => {
323
+ const first = results[0];
324
+ if (first) {
325
+ this._balance = first.balanceAfter;
326
+ }
327
+ const initData: InitData = {
328
+ balance: this._balance,
329
+ currency: first?.currency ?? this._config.currency,
330
+ config: {
331
+ ...this._config.gameConfig,
332
+ replayMode: true,
333
+ } as GameConfigData,
334
+ session: null,
335
+ assetsUrl: this._config.assetsUrl,
336
+ };
337
+ this.delayedSend('INIT', initData, id);
338
+ })
339
+ .catch((err) => {
340
+ console.error('[DevBridge] Replay resolve failed:', err);
341
+ this.sendError(id, 'ENGINE_ERROR', err?.message ?? 'replay resolve failed');
342
+ });
343
+ }
344
+
345
+ /** Resolve (and cache) the recorded rounds for the active replay launch. */
346
+ private resolveReplayResults(): Promise<PlayResultData[]> {
347
+ if (!this._replayResults) {
348
+ const { mode, roundId } = this._replayLaunch ?? {};
349
+ this._replayResults = Promise.resolve(
350
+ this._config.replay!.resolve(mode, roundId),
351
+ );
352
+ }
353
+ return this._replayResults;
354
+ }
355
+
356
+ /**
357
+ * Replay PLAY_REQUEST: serve the recorded round at the cursor and advance.
358
+ * No wallet movement, no bet/session validation. The first spin past the
359
+ * end resets the cursor to 0 ("Play Again"). An empty record list behaves
360
+ * like an exhausted live session.
361
+ */
362
+ private handleReplayPlay(id?: string): void {
363
+ this.resolveReplayResults()
364
+ .then((results) => {
365
+ if (!results || results.length === 0) {
366
+ this.sendError(id, 'NO_ACTIVE_SESSION', 'replay has no recorded rounds');
367
+ return;
368
+ }
369
+ // Play Again: a spin past the end wraps back to the first round.
370
+ if (this._replayCursor >= results.length) {
371
+ this._replayCursor = 0;
372
+ }
373
+ const next = results[this._replayCursor++];
374
+ this._lastPlayResult = next;
375
+ // Mirror the recorded balance so GET_BALANCE / HUD stay consistent.
376
+ this._balance = next.balanceAfter;
377
+ this.delayedSend('PLAY_RESULT', next, id);
378
+ })
379
+ .catch((err) => {
380
+ console.error('[DevBridge] Replay resolve failed:', err);
381
+ this.sendError(id, 'ENGINE_ERROR', err?.message ?? 'replay resolve failed');
382
+ });
383
+ }
384
+
232
385
  private handlePlayRequest(
233
386
  payload: PlayParams,
234
387
  id?: string,
235
388
  ): void {
389
+ if (this._replayLaunch) {
390
+ this.handleReplayPlay(id);
391
+ return;
392
+ }
393
+
236
394
  const { action, bet, params } = payload;
237
395
  this._roundCounter++;
238
396
 
@@ -1,2 +1,2 @@
1
1
  export { DevBridge } from './DevBridge';
2
- export type { DevBridgeConfig } from './DevBridge';
2
+ export type { DevBridgeConfig, ReplayConfig, ReplayLaunch } from './DevBridge';
package/src/index.ts CHANGED
@@ -35,7 +35,7 @@ export type {
35
35
 
36
36
  // ─── DevBridge ──────────────────────────────────────────
37
37
  export { DevBridge } from './dev-bridge';
38
- export type { DevBridgeConfig } from './dev-bridge';
38
+ export type { DevBridgeConfig, ReplayConfig, ReplayLaunch } from './dev-bridge';
39
39
 
40
40
  // ─── Branded loading screen ─────────────────────────────
41
41
  // Renderer-agnostic CSS preloader showing the Energy8 platform logo.