@energy8platform/platform-core 0.24.6 → 0.25.1

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.
Files changed (57) hide show
  1. package/dist/game-spec.cjs.js +209 -0
  2. package/dist/game-spec.cjs.js.map +1 -0
  3. package/dist/game-spec.d.ts +164 -0
  4. package/dist/game-spec.esm.js +198 -0
  5. package/dist/game-spec.esm.js.map +1 -0
  6. package/dist/index.cjs.js +67 -1
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +44 -1
  9. package/dist/index.esm.js +67 -1
  10. package/dist/index.esm.js.map +1 -1
  11. package/dist/loading.cjs.js +10 -1
  12. package/dist/loading.cjs.js.map +1 -1
  13. package/dist/loading.esm.js +10 -1
  14. package/dist/loading.esm.js.map +1 -1
  15. package/dist/lua.cjs.js +5 -2
  16. package/dist/lua.cjs.js.map +1 -1
  17. package/dist/lua.d.ts +9 -0
  18. package/dist/lua.esm.js +5 -2
  19. package/dist/lua.esm.js.map +1 -1
  20. package/dist/shell.cjs.js +35 -0
  21. package/dist/shell.cjs.js.map +1 -1
  22. package/dist/shell.d.ts +16 -1
  23. package/dist/shell.esm.js +35 -1
  24. package/dist/shell.esm.js.map +1 -1
  25. package/dist/simulation.cjs.js +40 -22
  26. package/dist/simulation.cjs.js.map +1 -1
  27. package/dist/simulation.d.ts +35 -2
  28. package/dist/simulation.esm.js +39 -23
  29. package/dist/simulation.esm.js.map +1 -1
  30. package/dist/slot-result.cjs.js +17 -0
  31. package/dist/slot-result.cjs.js.map +1 -0
  32. package/dist/slot-result.d.ts +26 -0
  33. package/dist/slot-result.esm.js +14 -0
  34. package/dist/slot-result.esm.js.map +1 -0
  35. package/package.json +12 -1
  36. package/scripts/gen-version.mjs +21 -0
  37. package/src/PlatformSession.ts +28 -0
  38. package/src/game-spec/defineGame.ts +16 -0
  39. package/src/game-spec/derive.ts +135 -0
  40. package/src/game-spec/export.ts +17 -0
  41. package/src/game-spec/index.ts +6 -0
  42. package/src/game-spec/types.ts +81 -0
  43. package/src/game-spec/validate.ts +49 -0
  44. package/src/loading/CSSPreloader.ts +14 -1
  45. package/src/lua/LuaEngine.ts +5 -2
  46. package/src/lua/types.ts +8 -0
  47. package/src/shell/GameShell.ts +19 -1
  48. package/src/shell/components/GameInfo.ts +13 -0
  49. package/src/shell/index.ts +1 -0
  50. package/src/shell/shell.css.ts +2 -0
  51. package/src/shell/types.ts +3 -0
  52. package/src/shell/version.ts +3 -0
  53. package/src/simulation/NativeSimulationRunner.ts +62 -26
  54. package/src/simulation/index.ts +3 -0
  55. package/src/slot-result/coerce.ts +11 -0
  56. package/src/slot-result/index.ts +2 -0
  57. package/src/slot-result/types.ts +19 -0
@@ -17,6 +17,10 @@ const LOGO_SVG = buildLogoSVG({
17
17
 
18
18
  interface PreloaderState {
19
19
  container: HTMLElement;
20
+ /** The container's inline `position` before we overrode it — restored on removal so we don't
21
+ * permanently clobber the host page's layout (e.g. a `#game { position: fixed; inset: 0 }`
22
+ * stylesheet rule, which an inline `relative` would otherwise defeat, collapsing its height). */
23
+ prevPosition: string;
20
24
  overlay: HTMLDivElement;
21
25
  styleEl: HTMLStyleElement;
22
26
  rectEl: SVGRectElement;
@@ -137,6 +141,10 @@ export function createCSSPreloader(
137
141
  }
138
142
  `;
139
143
 
144
+ // The absolute overlay needs a positioned ancestor. Only override a STATIC container, and
145
+ // remember the prior inline value so removeCSSPreloader can restore it (an inline `relative`
146
+ // left behind would beat the game's `#game { position: fixed; inset: 0 }` and collapse it).
147
+ const prevPosition = container.style.position;
140
148
  container.style.position = container.style.position || 'relative';
141
149
  container.appendChild(styleEl);
142
150
  container.appendChild(overlay);
@@ -148,6 +156,7 @@ export function createCSSPreloader(
148
156
  // We still record state so removeCSSPreloader works.
149
157
  state = {
150
158
  container,
159
+ prevPosition,
151
160
  overlay,
152
161
  styleEl,
153
162
  rectEl: null as unknown as SVGRectElement,
@@ -167,6 +176,7 @@ export function createCSSPreloader(
167
176
 
168
177
  state = {
169
178
  container,
179
+ prevPosition,
170
180
  overlay,
171
181
  styleEl,
172
182
  rectEl,
@@ -251,7 +261,7 @@ export function removeCSSPreloader(_container: HTMLElement): Promise<void> {
251
261
  }
252
262
 
253
263
  state.removed = true;
254
- const { overlay, styleEl } = state;
264
+ const { overlay, styleEl, container, prevPosition } = state;
255
265
  overlay.classList.add('ge-preloader-hidden');
256
266
 
257
267
  return new Promise<void>((resolve) => {
@@ -261,6 +271,9 @@ export function removeCSSPreloader(_container: HTMLElement): Promise<void> {
261
271
  settled = true;
262
272
  overlay.remove();
263
273
  styleEl.remove();
274
+ // Restore the container's original inline position so the game's own layout
275
+ // (e.g. `#game { position: fixed; inset: 0 }`) is no longer defeated by our inline override.
276
+ container.style.position = prevPosition;
264
277
  state = null;
265
278
  resolve();
266
279
  };
@@ -36,6 +36,7 @@ export class LuaEngine {
36
36
  private gameDefinition: GameDefinition;
37
37
  private variables: Record<string, number> = {};
38
38
  private simulationMode: boolean;
39
+ private allowSessionlessActions: boolean;
39
40
  /** Reusable state objects to avoid per-iteration allocation */
40
41
  private _stateVars: Record<string, number> = {};
41
42
  private _stateParams: Record<string, unknown> = {};
@@ -43,6 +44,7 @@ export class LuaEngine {
43
44
  constructor(config: LuaEngineConfig) {
44
45
  this.gameDefinition = config.gameDefinition;
45
46
  this.simulationMode = config.simulationMode ?? false;
47
+ this.allowSessionlessActions = config.allowSessionlessActions ?? false;
46
48
 
47
49
  const rng = config.seed !== undefined
48
50
  ? createSeededRng(config.seed)
@@ -93,10 +95,11 @@ export class LuaEngine {
93
95
  execute(params: PlayParams): LuaPlayResult {
94
96
  const { action: actionName, params: clientParams } = params;
95
97
 
96
- // 1. Resolve action
98
+ // 1. Resolve action. In the harness, `allowSessionlessActions` lets a standalone free_spin
99
+ // (replayed after a books-path bonus buy that never created an engine session) run anyway.
97
100
  const action = this.actionRouter.resolveAction(
98
101
  actionName,
99
- this.sessionManager.isActive,
102
+ this.sessionManager.isActive || this.allowSessionlessActions,
100
103
  );
101
104
 
102
105
  // 2. Determine bet — server uses session bet for session actions
package/src/lua/types.ts CHANGED
@@ -88,6 +88,14 @@ export interface LuaEngineConfig {
88
88
  logger?: (level: string, msg: string) => void;
89
89
  /** Skip marshalling data fields (matrix, wins, etc.) for faster simulation */
90
90
  simulationMode?: boolean;
91
+ /**
92
+ * Allow `requires_session` actions (e.g. `free_spin`) to run even with no active session.
93
+ * Default false (server-faithful). The dev harness sets this true: when a bonus is bought
94
+ * through the BOOKS path the LuaEngine never created a session, yet the scaffold then replays
95
+ * `free_spin` (which has no books) via the Lua fallback — without this it would throw
96
+ * "Action free_spin requires an active session".
97
+ */
98
+ allowSessionlessActions?: boolean;
91
99
  }
92
100
 
93
101
  export interface LuaPlayResult {
@@ -64,6 +64,10 @@ export class GameShell extends EventEmitter<ShellEvents> {
64
64
  this.observeLayout();
65
65
  if (typeof document !== 'undefined') {
66
66
  document.addEventListener('keydown', this.handleKeyDown);
67
+ // Stake serves the game in an iframe; on first paint focus is on the HOST page, so a `document`
68
+ // keydown never fires and Space scrolls the parent. Pull window focus into the iframe on the
69
+ // first pointer interaction so the spacebar shortcut works. Harmless on full-page Energy8.
70
+ document.addEventListener('pointerdown', this.pullFocus, true);
67
71
  this.keysBound = true;
68
72
  }
69
73
  this.render();
@@ -139,6 +143,10 @@ export class GameShell extends EventEmitter<ShellEvents> {
139
143
  * false, while a spin is running, while autoplay is active, outside base mode, when an
140
144
  * overlay/modal is open, or when an editable element is focused. `repeat` (held key) is
141
145
  * ignored so it can't spam. */
146
+ /** Pull window focus into the iframe on first pointer interaction so `document` keydown (the
147
+ * spacebar shortcut) fires. No-op / harmless when already focused or full-page. */
148
+ private pullFocus = (): void => { try { window.focus(); } catch { /* cross-origin / non-browser */ } };
149
+
142
150
  private handleKeyDown = (e: KeyboardEvent): void => {
143
151
  if (this.destroyed || e.code !== 'Space' || e.repeat) return;
144
152
  if (this.config.features.spacebar === false) return; // shortcut disabled (e.g. jurisdiction)
@@ -209,6 +217,9 @@ export class GameShell extends EventEmitter<ShellEvents> {
209
217
  setBusy(busy: boolean): void { this.state.busy = busy; this.render(); }
210
218
  setAutoplay(a: AutoplayOptions): void { this.state.autoplay = a; this.render(); }
211
219
  setTurbo(level: number): void { this.state.turbo = level; this.render(); }
220
+ /** Currency-aware money formatter for WIN amounts (variable decimals: 0.0041 stays 0.0041, not
221
+ * 0.00). The host hands this to a scene so games format money without knowing the currency. */
222
+ formatWin(value: number): string { return formatCurrency(value, this.config.currency, true); }
212
223
  setBuyBonusEnabled(enabled: boolean): void { this.state.buyBonusEnabled = enabled; this.render(); }
213
224
  setFreeSpins(fs: FreeSpinsState): void { this.state.freeSpins = fs; this.render(); }
214
225
 
@@ -276,6 +287,9 @@ export class GameShell extends EventEmitter<ShellEvents> {
276
287
  /** Open a generic, externally-driven modal (title + body + optional action buttons).
277
288
  * Each action runs its `on` then closes; the ✕ shows when `availableClose` is true. */
278
289
  openModal(opts: ModalOptions): void { this.showModal(buildModal(opts)); }
290
+ /** Programmatically dismiss whatever modal/overlay is currently shown (e.g. auto-close the
291
+ * reconnect overlay once the link is restored). No-op when nothing is open. */
292
+ closeModal(): void { this.modalHost.innerHTML = ''; }
279
293
  /** Open the non-dismissable replay summary modal (START REPLAY → onReplay → reopen). */
280
294
  openReplay(opts: ReplayModalOptions): void {
281
295
  if (this.destroyed) return;
@@ -292,7 +306,11 @@ export class GameShell extends EventEmitter<ShellEvents> {
292
306
  this.destroyed = true;
293
307
  this.ro?.disconnect();
294
308
  this.ro = null;
295
- if (this.keysBound) { document.removeEventListener('keydown', this.handleKeyDown); this.keysBound = false; }
309
+ if (this.keysBound) {
310
+ document.removeEventListener('keydown', this.handleKeyDown);
311
+ document.removeEventListener('pointerdown', this.pullFocus, true);
312
+ this.keysBound = false;
313
+ }
296
314
  this.cancelMoneyAnims();
297
315
  this.removeAllListeners();
298
316
  this.root.classList.add('ge-shell-hidden');
@@ -2,6 +2,7 @@ import type { GameShell } from '../GameShell';
2
2
  import type { CellRef, GameInfoSection, GameMode, PaytableRow, PaylineDef, ShapeDef, WinSection } from '../types';
3
3
  import { createOverlay, twoLine } from './primitives';
4
4
  import { icon } from './icons';
5
+ import { PACKAGE_VERSION } from '../version';
5
6
 
6
7
  const SVG_NS = 'http://www.w3.org/2000/svg';
7
8
 
@@ -23,9 +24,21 @@ export function openGameInfoModal(shell: GameShell): HTMLElement {
23
24
  .sort((a, b) => a.k - b.k || a.i - b.i)
24
25
  .forEach(({ s }) => body.appendChild(renderSection(shell, s)));
25
26
 
27
+ body.appendChild(versionFooter(shell));
26
28
  return root;
27
29
  }
28
30
 
31
+ /** A muted version stamp pinned to the bottom of the game-info modal:
32
+ * `${config.version ?? '1.0.0'}.${engine version without dots}` (e.g. '1.0.0.0246'). */
33
+ function versionFooter(shell: GameShell): HTMLElement {
34
+ const gameVersion = shell.config.version ?? '1.0.0';
35
+ const el = document.createElement('div');
36
+ el.dataset.ge = 'info-version';
37
+ el.className = 'ge-gi-version';
38
+ el.textContent = `${gameVersion}.${PACKAGE_VERSION.replaceAll('.', '')}`;
39
+ return el;
40
+ }
41
+
29
42
  function renderSection(shell: GameShell, s: GameInfoSection): HTMLElement {
30
43
  switch (s.type) {
31
44
  case 'modes': return sectionModes(shell, s.modes, sec('info-modes', s.title, shell.t('Modes')));
@@ -18,3 +18,4 @@ export function removeGameShell(): Promise<void> {
18
18
  }
19
19
 
20
20
  export { GameShell };
21
+ export { socialize } from './i18n';
@@ -148,6 +148,8 @@ export const SHELL_CSS = SHELL_FONT_CSS + `
148
148
  #${SHELL_ROOT_ID} .ge-gi-sec h3 { color:var(--shell-plaque-label); font-size:11px; letter-spacing:.14em;
149
149
  text-transform:uppercase; margin:0 0 12px; }
150
150
  #${SHELL_ROOT_ID} .ge-gi-sec p { color:rgba(255,255,255,.88); font-size:15px; line-height:1.6; margin:0; }
151
+ #${SHELL_ROOT_ID} .ge-gi-version { text-align:center; color:var(--shell-muted); font-size:11px;
152
+ letter-spacing:.08em; opacity:.7; margin:4px 0 2px; }
151
153
 
152
154
  /* controls — two blocks (gameplay / menu & info), icon/name/description per control */
153
155
  #${SHELL_ROOT_ID} .ge-gi-ctl-block + .ge-gi-ctl-block { margin-top:16px; padding-top:4px; border-top:1px solid var(--shell-plaque-line); }
@@ -194,6 +194,9 @@ export interface ShellConfig {
194
194
  theme?: ThemeConfig;
195
195
  gameInfo: GameInfoContent;
196
196
  language: string;
197
+ /** Game version shown in the game-info footer (e.g. '1.2.0'). Defaults to '1.0.0'. The footer
198
+ * stamp is `${version}.${engineVersionWithoutDots}` — e.g. game 1.0.0 on engine 0.24.6 → '1.0.0.0246'. */
199
+ version?: string;
197
200
  /** When true, all built-in shell text is shown in the social-casino vocabulary (derived from
198
201
  * English via word-swap rules), regardless of `language`. Game-supplied content is untouched. */
199
202
  isSocial?: boolean;
@@ -0,0 +1,3 @@
1
+ // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
2
+ /** The @energy8platform/platform-core package version, stamped at build time. */
3
+ export const PACKAGE_VERSION = '0.25.1';
@@ -52,6 +52,12 @@ export interface NativeSimulationConfig {
52
52
  rng?: NativeRNGKind;
53
53
  /** Replay mode: requires `rng: 'provably-fair'` (or default). */
54
54
  replay?: NativeReplayParams;
55
+ /**
56
+ * Path to write per-round JSONL book dump. When set, the binary writes one
57
+ * JSON object per line (one per round) to this file, enabling post-run
58
+ * analysis of the full round log.
59
+ */
60
+ dump?: string;
55
61
  /** Progress callback */
56
62
  onProgress?: (completed: number, total: number) => void;
57
63
  }
@@ -137,6 +143,43 @@ interface GoSimulationOutput {
137
143
  };
138
144
  }
139
145
 
146
+ // ─── Pure arg builder ────────────────────────────────────
147
+
148
+ export interface NativeArgsInput {
149
+ configPath: string;
150
+ iterations: number;
151
+ bet: number;
152
+ action?: string;
153
+ params?: unknown;
154
+ rng?: string;
155
+ seed?: string;
156
+ dump?: string;
157
+ replay?: { serverSeed: string; clientSeed: string; nonceStart: number };
158
+ }
159
+
160
+ /**
161
+ * Build the CLI args array for the native simulation binary.
162
+ * Pure function — no I/O, easily testable.
163
+ */
164
+ export function buildNativeArgs(a: NativeArgsInput): string[] {
165
+ const args = ['-config', a.configPath, '-iterations', String(a.iterations), '-bet', String(a.bet), '-format', 'json'];
166
+ if (a.action) args.push('-action', a.action);
167
+ if (a.params !== undefined && a.params !== null && (typeof a.params !== 'object' || Object.keys(a.params as object).length > 0)) {
168
+ args.push('-params', JSON.stringify(a.params));
169
+ }
170
+ if (a.rng) args.push('-rng', a.rng);
171
+ if (a.seed) args.push('-seed', a.seed);
172
+ if (a.dump) args.push('-dump', a.dump);
173
+ if (a.replay) {
174
+ args.push(
175
+ '-replay-server-seed', a.replay.serverSeed,
176
+ '-replay-client-seed', a.replay.clientSeed,
177
+ '-replay-nonce-start', String(a.replay.nonceStart),
178
+ );
179
+ }
180
+ return args;
181
+ }
182
+
140
183
  // ─── Runner ─────────────────────────────────────────────
141
184
 
142
185
  export class NativeSimulationRunner {
@@ -147,7 +190,7 @@ export class NativeSimulationRunner {
147
190
  }
148
191
 
149
192
  async run(): Promise<NativeSimulationResult> {
150
- const { binaryPath, script, gameDefinition, iterations, bet, action, params, seed, rng, replay } = this.config;
193
+ const { binaryPath, script, gameDefinition, iterations, bet, action, params, seed, rng, replay, dump } = this.config;
151
194
 
152
195
  if (replay && rng && rng !== 'provably-fair') {
153
196
  throw new Error(`Replay mode requires rng="provably-fair" (got rng="${rng}")`);
@@ -166,31 +209,7 @@ export class NativeSimulationRunner {
166
209
  ]);
167
210
 
168
211
  // Build CLI args
169
- const args = [
170
- '-config', configPath,
171
- '-iterations', String(iterations),
172
- '-bet', String(bet),
173
- '-format', 'json',
174
- ];
175
- if (action) {
176
- args.push('-action', action);
177
- }
178
- if (params && Object.keys(params).length > 0) {
179
- args.push('-params', JSON.stringify(params));
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
- }
212
+ const args = buildNativeArgs({ configPath, iterations, bet, action, params, rng, seed, dump, replay });
194
213
 
195
214
  // Execute binary
196
215
  const output = await this.exec(binaryPath, args);
@@ -359,6 +378,23 @@ export function findNativeBinary(baseDir?: string): string | null {
359
378
  return null;
360
379
  }
361
380
 
381
+ /**
382
+ * Return the native binary path or throw a clear, install-guiding error.
383
+ * The math pipeline is go-native only — NO JS fallback.
384
+ *
385
+ * @param finder - injectable finder for testability; defaults to findNativeBinary.
386
+ */
387
+ export function requireNativeBinary(finder: () => string | null = findNativeBinary): string {
388
+ const bin = finder();
389
+ if (!bin) {
390
+ throw new Error(
391
+ 'native simulation binary not found — run `npm install` (platform-core fetches it via install-simulate). ' +
392
+ 'The math pipeline is go-native only (no JS fallback).',
393
+ );
394
+ }
395
+ return bin;
396
+ }
397
+
362
398
  function isExecutable(path: string): boolean {
363
399
  try {
364
400
  accessSync(path, fsConstants.X_OK);
@@ -10,6 +10,8 @@ export {
10
10
  NativeSimulationRunner,
11
11
  findNativeBinary,
12
12
  formatNativeResult,
13
+ buildNativeArgs,
14
+ requireNativeBinary,
13
15
  } from './NativeSimulationRunner';
14
16
  export type {
15
17
  NativeSimulationConfig,
@@ -18,6 +20,7 @@ export type {
18
20
  NativeReplayParams,
19
21
  StageStats,
20
22
  DistributionBucket,
23
+ NativeArgsInput,
21
24
  } from './NativeSimulationRunner';
22
25
 
23
26
  export { ParallelSimulationRunner } from './ParallelSimulationRunner';
@@ -0,0 +1,11 @@
1
+ /** Lua empty tables decode as {} — turn a possibly-{} value into a real array. */
2
+ export function asArray<T = unknown>(value: unknown): T[] {
3
+ return Array.isArray(value) ? (value as T[]) : [];
4
+ }
5
+
6
+ /** Apply asArray() to the named fields of a record (Lua {} → []). Optional helper for a game's normalizer. */
7
+ export function coerceLuaArrays<T extends Record<string, unknown>>(obj: T, fields: string[]): T {
8
+ const out: Record<string, unknown> = { ...obj };
9
+ for (const f of fields) out[f] = asArray(out[f]);
10
+ return out as T;
11
+ }
@@ -0,0 +1,2 @@
1
+ export type { SlotSpinResultBase, SlotResultNormalizer } from './types';
2
+ export { asArray, coerceLuaArrays } from './coerce';
@@ -0,0 +1,19 @@
1
+ /** Minimal, renderer-agnostic base every normalized slot result satisfies. */
2
+ export interface SlotSpinResultBase {
3
+ /** Currency win amount for this play (PlatformSession.play() already applied the bet). */
4
+ totalWin: number;
5
+ freeSpins?: { awarded?: number; total?: number; remaining?: number };
6
+ // ── Round-continuation metadata (Stake segment-drain) ──────────────────
7
+ // A round may be split into several SEGMENTS (e.g. a base trigger + every free spin of one
8
+ // bonus). The host fills these from the raw play result so a scene can drain the remaining
9
+ // segments by replaying the SAME round; the game's normalizer never sets them.
10
+ /** Round id to pass back to `play(action, bet, roundId)` for the next segment of THIS round. */
11
+ roundId?: string;
12
+ /** Actions valid for the next segment; `nextActions[0]` is what to play to advance the round. */
13
+ nextActions?: string[];
14
+ /** True once the round has no further segments to drain (single spin, or final bonus spin). */
15
+ complete?: boolean;
16
+ }
17
+
18
+ /** A game declares one of these; the host invokes it on every play. Generic over the game's result type. */
19
+ export type SlotResultNormalizer<T extends SlotSpinResultBase> = (raw: unknown) => T;