@energy8platform/platform-core 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.
@@ -1,25 +1,44 @@
1
1
  import type { LoadingScreenConfig } from '../types';
2
- import { buildLogoSVG } from './logo';
2
+ import { buildLogoSVG, LOADER_BAR_MAX_WIDTH } from './logo';
3
3
 
4
4
  const PRELOADER_ID = '__ge-css-preloader__';
5
+ const RECT_ID = 'ge-pl-loader-rect';
6
+ const TEXT_ID = 'ge-pl-loader-text';
7
+ const REMOVE_FADE_TIMEOUT_MS = 600;
5
8
 
6
- /**
7
- * Inline SVG logo with animated loader bar.
8
- * The `#loader` path acts as the progress fill — animated via clipPath.
9
- */
10
9
  const LOGO_SVG = buildLogoSVG({
11
10
  idPrefix: 'pl',
12
11
  svgClass: 'ge-logo-svg',
13
12
  clipRectClass: 'ge-clip-rect',
13
+ clipRectId: RECT_ID,
14
14
  textClass: 'ge-preloader-svg-text',
15
+ textId: TEXT_ID,
15
16
  });
16
17
 
17
- /**
18
- * Creates a lightweight CSS-only preloader that appears instantly,
19
- * BEFORE PixiJS/WebGL is initialized.
20
- *
21
- * Displays the Energy8 logo SVG with an animated loader bar.
22
- */
18
+ interface PreloaderState {
19
+ container: HTMLElement;
20
+ overlay: HTMLDivElement;
21
+ styleEl: HTMLStyleElement;
22
+ rectEl: SVGRectElement;
23
+ textEl: SVGTextElement;
24
+ showPercentage: boolean;
25
+ tapToStart: boolean;
26
+ tapToStartText: string;
27
+ driven: boolean;
28
+ tapState: 'idle' | 'waiting' | 'resolved';
29
+ tapPromise: Promise<void> | null;
30
+ tapResolve: (() => void) | null;
31
+ tapHandler: ((e: Event) => void) | null;
32
+ removed: boolean;
33
+ }
34
+
35
+ let state: PreloaderState | null = null;
36
+
37
+ function clampProgress(p: number): number {
38
+ if (!Number.isFinite(p)) return 0;
39
+ return Math.max(0, Math.min(1, p));
40
+ }
41
+
23
42
  export function createCSSPreloader(
24
43
  container: HTMLElement,
25
44
  config?: LoadingScreenConfig,
@@ -33,20 +52,21 @@ export function createCSSPreloader(
33
52
  ? `#${config.backgroundColor.toString(16).padStart(6, '0')}`
34
53
  : '#0a0a1a';
35
54
 
36
- const bgGradient = config?.backgroundGradient ?? `linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
55
+ const bgGradient =
56
+ config?.backgroundGradient ?? `linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
37
57
 
38
58
  const customHTML = config?.cssPreloaderHTML ?? '';
39
59
 
40
- const el = document.createElement('div');
41
- el.id = PRELOADER_ID;
42
- el.innerHTML = customHTML || `
60
+ const overlay = document.createElement('div');
61
+ overlay.id = PRELOADER_ID;
62
+ overlay.innerHTML = customHTML || `
43
63
  <div class="ge-preloader-content">
44
64
  ${LOGO_SVG}
45
65
  </div>
46
66
  `;
47
67
 
48
- const style = document.createElement('style');
49
- style.textContent = `
68
+ const styleEl = document.createElement('style');
69
+ styleEl.textContent = `
50
70
  #${PRELOADER_ID} {
51
71
  position: absolute;
52
72
  top: 0; left: 0;
@@ -99,31 +119,153 @@ export function createCSSPreloader(
99
119
  0%, 100% { opacity: 0.4; }
100
120
  50% { opacity: 1; }
101
121
  }
122
+
123
+ /* Stop shimmer once JS-driven progress takes over. */
124
+ .ge-clip-rect.driven {
125
+ animation: none;
126
+ }
127
+
128
+ /* Tap-to-start CTA pulse. Compound selector outweighs the ambient
129
+ .ge-preloader-svg-text rule, swapping the animation cleanly. */
130
+ .ge-preloader-svg-text.ge-svg-pulse {
131
+ animation: ge-tap-pulse 1.2s ease-in-out infinite;
132
+ }
133
+
134
+ @keyframes ge-tap-pulse {
135
+ 0%, 100% { opacity: 0.5; }
136
+ 50% { opacity: 1; }
137
+ }
102
138
  `;
103
139
 
104
140
  container.style.position = container.style.position || 'relative';
105
- container.appendChild(style);
106
- container.appendChild(el);
141
+ container.appendChild(styleEl);
142
+ container.appendChild(overlay);
143
+
144
+ const rectEl = overlay.querySelector(`#${RECT_ID}`) as SVGRectElement | null;
145
+ const textEl = overlay.querySelector(`#${TEXT_ID}`) as SVGTextElement | null;
146
+ if (!rectEl || !textEl) {
147
+ // Custom HTML mode — no logo SVG, lifecycle API becomes mostly inert.
148
+ // We still record state so removeCSSPreloader works.
149
+ state = {
150
+ container,
151
+ overlay,
152
+ styleEl,
153
+ rectEl: null as unknown as SVGRectElement,
154
+ textEl: null as unknown as SVGTextElement,
155
+ showPercentage: false,
156
+ tapToStart: config?.tapToStart !== false,
157
+ tapToStartText: config?.tapToStartText ?? 'TAP TO START',
158
+ driven: false,
159
+ tapState: 'idle',
160
+ tapPromise: null,
161
+ tapResolve: null,
162
+ tapHandler: null,
163
+ removed: false,
164
+ };
165
+ return;
166
+ }
167
+
168
+ state = {
169
+ container,
170
+ overlay,
171
+ styleEl,
172
+ rectEl,
173
+ textEl,
174
+ showPercentage: config?.showPercentage === true,
175
+ tapToStart: config?.tapToStart !== false,
176
+ tapToStartText: config?.tapToStartText ?? 'TAP TO START',
177
+ driven: false,
178
+ tapState: 'idle',
179
+ tapPromise: null,
180
+ tapResolve: null,
181
+ tapHandler: null,
182
+ removed: false,
183
+ };
107
184
  }
108
185
 
109
- /**
110
- * Remove the CSS preloader with a smooth fade-out transition.
111
- */
112
- export function removeCSSPreloader(container: HTMLElement): void {
113
- const el = document.getElementById(PRELOADER_ID);
114
- if (!el) return;
115
-
116
- el.classList.add('ge-preloader-hidden');
117
-
118
- // Remove after transition
119
- el.addEventListener('transitionend', () => {
120
- el.remove();
121
- // Also remove the style element
122
- const styles = container.querySelectorAll('style');
123
- for (const style of styles) {
124
- if (style.textContent?.includes(PRELOADER_ID)) {
125
- style.remove();
126
- }
127
- }
186
+ export function setCSSPreloaderProgress(progress: number): void {
187
+ if (!state || state.removed) return;
188
+ if (state.tapState === 'waiting' || state.tapState === 'resolved') return;
189
+ if (!state.rectEl) return;
190
+
191
+ const p = clampProgress(progress);
192
+
193
+ if (!state.driven) {
194
+ state.rectEl.classList.add('driven');
195
+ state.driven = true;
196
+ }
197
+
198
+ state.rectEl.setAttribute('width', String(p * LOADER_BAR_MAX_WIDTH));
199
+
200
+ if (state.showPercentage && state.textEl) {
201
+ state.textEl.textContent = `${Math.round(p * 100)}%`;
202
+ }
203
+ }
204
+
205
+ export function waitCSSPreloaderTap(): Promise<void> {
206
+ if (!state) {
207
+ throw new Error(
208
+ 'CSS preloader not initialized — call createCSSPreloader first',
209
+ );
210
+ }
211
+ if (state.removed) return Promise.resolve();
212
+ if (!state.tapToStart) return Promise.resolve();
213
+ if (state.tapPromise) return state.tapPromise;
214
+
215
+ if (state.textEl) {
216
+ state.textEl.textContent = state.tapToStartText;
217
+ state.textEl.classList.add('ge-svg-pulse');
218
+ }
219
+ state.overlay.style.cursor = 'pointer';
220
+
221
+ state.tapState = 'waiting';
222
+ state.tapPromise = new Promise<void>((resolve) => {
223
+ state!.tapResolve = resolve;
224
+ const handler = (_e: Event) => {
225
+ if (!state) return;
226
+ state.overlay.removeEventListener('pointerdown', handler);
227
+ state.tapHandler = null;
228
+ state.tapState = 'resolved';
229
+ state.tapResolve = null;
230
+ resolve();
231
+ };
232
+ state!.tapHandler = handler;
233
+ state!.overlay.addEventListener('pointerdown', handler);
234
+ });
235
+
236
+ return state.tapPromise;
237
+ }
238
+
239
+ export function removeCSSPreloader(_container: HTMLElement): Promise<void> {
240
+ if (!state || state.removed) return Promise.resolve();
241
+
242
+ // Detach the pending pointer listener (if any) and resolve a pending tap.
243
+ if (state.tapHandler) {
244
+ state.overlay.removeEventListener('pointerdown', state.tapHandler);
245
+ state.tapHandler = null;
246
+ }
247
+ if (state.tapState === 'waiting' && state.tapResolve) {
248
+ state.tapState = 'resolved';
249
+ state.tapResolve();
250
+ state.tapResolve = null;
251
+ }
252
+
253
+ state.removed = true;
254
+ const { overlay, styleEl } = state;
255
+ overlay.classList.add('ge-preloader-hidden');
256
+
257
+ return new Promise<void>((resolve) => {
258
+ let settled = false;
259
+ const finish = () => {
260
+ if (settled) return;
261
+ settled = true;
262
+ overlay.remove();
263
+ styleEl.remove();
264
+ state = null;
265
+ resolve();
266
+ };
267
+
268
+ overlay.addEventListener('transitionend', finish, { once: true });
269
+ setTimeout(finish, REMOVE_FADE_TIMEOUT_MS);
128
270
  });
129
271
  }
@@ -1,3 +1,8 @@
1
- export { createCSSPreloader, removeCSSPreloader } from './CSSPreloader';
1
+ export {
2
+ createCSSPreloader,
3
+ setCSSPreloaderProgress,
4
+ waitCSSPreloaderTap,
5
+ removeCSSPreloader,
6
+ } from './CSSPreloader';
2
7
  export { buildLogoSVG, LOADER_BAR_MAX_WIDTH } from './logo';
3
8
  export type { LoadingScreenConfig, AssetManifest, AssetBundle, AssetEntry } from '../types';
@@ -10,6 +10,19 @@ import type { GameDefinition, SimulationResult } from '../lua/types';
10
10
 
11
11
  // ─── Types ──────────────────────────────────────────────
12
12
 
13
+ export type NativeRNGKind = 'provably-fair' | 'fast';
14
+
15
+ /**
16
+ * Replay mode parameters. Forces single-worker deterministic execution over a
17
+ * specific (server_seed, client_seed, nonce-start) triple — used to reproduce a
18
+ * production round captured in `provably_fair_rounds`.
19
+ */
20
+ export interface NativeReplayParams {
21
+ serverSeed: string;
22
+ clientSeed: string;
23
+ nonceStart: number;
24
+ }
25
+
13
26
  export interface NativeSimulationConfig {
14
27
  /** Path to native simulation binary */
15
28
  binaryPath: string;
@@ -25,6 +38,20 @@ export interface NativeSimulationConfig {
25
38
  action?: string;
26
39
  /** Action params (buy_bonus, ante_bet, etc.) */
27
40
  params?: Record<string, unknown>;
41
+ /**
42
+ * Hex-encoded master seed for reproducible runs. The binary derives per-worker
43
+ * server_seeds via sha256(seed || ":" || worker_idx). When omitted, the binary
44
+ * generates one and returns it on the result so the run can be reproduced via
45
+ * `seed: result.masterSeed`. Ignored when `rng === 'fast'`.
46
+ */
47
+ seed?: string;
48
+ /**
49
+ * RNG backend: `'provably-fair'` (default, matches production) or `'fast'`
50
+ * (math/rand PCG — local iteration only, do NOT publish those RTP numbers).
51
+ */
52
+ rng?: NativeRNGKind;
53
+ /** Replay mode: requires `rng: 'provably-fair'` (or default). */
54
+ replay?: NativeReplayParams;
28
55
  /** Progress callback */
29
56
  onProgress?: (completed: number, total: number) => void;
30
57
  }
@@ -55,6 +82,18 @@ export interface NativeSimulationResult extends SimulationResult {
55
82
  perStage?: Record<string, StageStats>;
56
83
  /** Win distribution histogram */
57
84
  winDistribution?: DistributionBucket[];
85
+ /** RNG backend that produced these numbers. */
86
+ rngKind?: NativeRNGKind;
87
+ /**
88
+ * Hex master seed that drove worker-seed derivation. Always set for
89
+ * `provably-fair` runs (supplied or auto-generated). Pass back via `seed`
90
+ * to reproduce the run bit-for-bit.
91
+ */
92
+ masterSeed?: string;
93
+ /** Per-worker server_seed sequence (lets support reproduce any individual spin). */
94
+ workerSeeds?: string[];
95
+ /** Echo of replay params when the run was in replay mode. */
96
+ replay?: NativeReplayParams;
58
97
  }
59
98
 
60
99
  // ─── Go JSON output shape (snake_case) ──────────────────
@@ -88,6 +127,14 @@ interface GoSimulationOutput {
88
127
  count: number;
89
128
  pct: number;
90
129
  }>;
130
+ rng_kind?: NativeRNGKind;
131
+ master_seed?: string;
132
+ worker_seeds?: string[];
133
+ replay?: {
134
+ server_seed: string;
135
+ client_seed: string;
136
+ nonce_start: number;
137
+ };
91
138
  }
92
139
 
93
140
  // ─── Runner ─────────────────────────────────────────────
@@ -100,7 +147,12 @@ export class NativeSimulationRunner {
100
147
  }
101
148
 
102
149
  async run(): Promise<NativeSimulationResult> {
103
- const { binaryPath, script, gameDefinition, iterations, bet, action, params } = this.config;
150
+ const { binaryPath, script, gameDefinition, iterations, bet, action, params, seed, rng, replay } = this.config;
151
+
152
+ if (replay && rng && rng !== 'provably-fair') {
153
+ throw new Error(`Replay mode requires rng="provably-fair" (got rng="${rng}")`);
154
+ }
155
+
104
156
  const id = randomBytes(8).toString('hex');
105
157
  const tmpDir = tmpdir();
106
158
  const luaPath = join(tmpDir, `sim-${id}.lua`);
@@ -126,6 +178,19 @@ export class NativeSimulationRunner {
126
178
  if (params && Object.keys(params).length > 0) {
127
179
  args.push('-params', JSON.stringify(params));
128
180
  }
181
+ if (rng) {
182
+ args.push('-rng', rng);
183
+ }
184
+ if (seed) {
185
+ args.push('-seed', seed);
186
+ }
187
+ if (replay) {
188
+ args.push(
189
+ '-replay-server-seed', replay.serverSeed,
190
+ '-replay-client-seed', replay.clientSeed,
191
+ '-replay-nonce-start', String(replay.nonceStart),
192
+ );
193
+ }
129
194
 
130
195
  // Execute binary
131
196
  const output = await this.exec(binaryPath, args);
@@ -211,6 +276,16 @@ function mapGoResult(json: GoSimulationOutput): NativeSimulationResult {
211
276
  workersUsed: json.workers_used,
212
277
  perStage,
213
278
  winDistribution: json.win_distribution,
279
+ rngKind: json.rng_kind,
280
+ masterSeed: json.master_seed,
281
+ workerSeeds: json.worker_seeds,
282
+ replay: json.replay
283
+ ? {
284
+ serverSeed: json.replay.server_seed,
285
+ clientSeed: json.replay.client_seed,
286
+ nonceStart: json.replay.nonce_start,
287
+ }
288
+ : undefined,
214
289
  _raw: {
215
290
  totalWagered: json.total_bet,
216
291
  totalWon: json.total_win,
@@ -311,6 +386,17 @@ export function formatNativeResult(result: NativeSimulationResult): string {
311
386
  if (result.workersUsed) {
312
387
  lines.push(`Workers: ${result.workersUsed}`);
313
388
  }
389
+ if (result.rngKind) {
390
+ lines.push(`RNG: ${result.rngKind}`);
391
+ }
392
+ if (result.masterSeed) {
393
+ lines.push(`Master seed: ${result.masterSeed} (pass --seed=${result.masterSeed} to reproduce)`);
394
+ }
395
+ if (result.replay) {
396
+ lines.push(
397
+ `Replay: server_seed=${result.replay.serverSeed} client_seed=${result.replay.clientSeed} nonce_start=${result.replay.nonceStart}`,
398
+ );
399
+ }
314
400
 
315
401
  lines.push(
316
402
  '',
@@ -14,6 +14,8 @@ export {
14
14
  export type {
15
15
  NativeSimulationConfig,
16
16
  NativeSimulationResult,
17
+ NativeRNGKind,
18
+ NativeReplayParams,
17
19
  StageStats,
18
20
  DistributionBucket,
19
21
  } from './NativeSimulationRunner';
package/src/types.ts CHANGED
@@ -55,9 +55,15 @@ export interface LoadingScreenConfig {
55
55
  showPercentage?: boolean;
56
56
  /** Custom progress text formatter */
57
57
  progressTextFormatter?: (progress: number) => string;
58
- /** Show "Tap to start" after loading (needed for mobile audio unlock) */
58
+ /**
59
+ * If true (default), `waitCSSPreloaderTap()` blocks until the user
60
+ * clicks the preloader. Set to `false` to make `waitCSSPreloaderTap()`
61
+ * resolve immediately (skip-flag for games that don't want a manual
62
+ * gate). Useful for mobile audio unlock — the click satisfies the
63
+ * browser's user-gesture requirement.
64
+ */
59
65
  tapToStart?: boolean;
60
- /** "Tap to start" label text */
66
+ /** Label shown in the SVG text element while waiting for tap. Default: 'TAP TO START'. */
61
67
  tapToStartText?: string;
62
68
  /** Minimum display time in ms (so the user sees the brand, even if loading is fast) */
63
69
  minDisplayTime?: number;