@energy8platform/platform-core 0.24.5 → 0.25.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.
Files changed (54) 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 -3
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +52 -1
  9. package/dist/index.esm.js +67 -3
  10. package/dist/index.esm.js.map +1 -1
  11. package/dist/lua.cjs.js +5 -2
  12. package/dist/lua.cjs.js.map +1 -1
  13. package/dist/lua.d.ts +9 -0
  14. package/dist/lua.esm.js +5 -2
  15. package/dist/lua.esm.js.map +1 -1
  16. package/dist/shell.cjs.js +45 -3
  17. package/dist/shell.cjs.js.map +1 -1
  18. package/dist/shell.d.ts +24 -1
  19. package/dist/shell.esm.js +45 -4
  20. package/dist/shell.esm.js.map +1 -1
  21. package/dist/simulation.cjs.js +40 -22
  22. package/dist/simulation.cjs.js.map +1 -1
  23. package/dist/simulation.d.ts +35 -2
  24. package/dist/simulation.esm.js +39 -23
  25. package/dist/simulation.esm.js.map +1 -1
  26. package/dist/slot-result.cjs.js +17 -0
  27. package/dist/slot-result.cjs.js.map +1 -0
  28. package/dist/slot-result.d.ts +26 -0
  29. package/dist/slot-result.esm.js +14 -0
  30. package/dist/slot-result.esm.js.map +1 -0
  31. package/package.json +12 -1
  32. package/scripts/gen-version.mjs +21 -0
  33. package/src/PlatformSession.ts +28 -0
  34. package/src/game-spec/defineGame.ts +16 -0
  35. package/src/game-spec/derive.ts +135 -0
  36. package/src/game-spec/export.ts +17 -0
  37. package/src/game-spec/index.ts +6 -0
  38. package/src/game-spec/types.ts +81 -0
  39. package/src/game-spec/validate.ts +49 -0
  40. package/src/lua/LuaEngine.ts +5 -2
  41. package/src/lua/types.ts +8 -0
  42. package/src/shell/GameShell.ts +24 -2
  43. package/src/shell/components/BottomBar.ts +3 -2
  44. package/src/shell/components/GameInfo.ts +13 -0
  45. package/src/shell/index.ts +1 -0
  46. package/src/shell/shell.css.ts +2 -0
  47. package/src/shell/state.ts +1 -0
  48. package/src/shell/types.ts +11 -0
  49. package/src/shell/version.ts +3 -0
  50. package/src/simulation/NativeSimulationRunner.ts +62 -26
  51. package/src/simulation/index.ts +3 -0
  52. package/src/slot-result/coerce.ts +11 -0
  53. package/src/slot-result/index.ts +2 -0
  54. package/src/slot-result/types.ts +19 -0
@@ -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)
@@ -201,10 +209,17 @@ export class GameShell extends EventEmitter<ShellEvents> {
201
209
  setBalance(n: number): void { this.state.balance = n; this.render(); }
202
210
  setWin(n: number): void { this.state.win = n; this.render(); }
203
211
  setBet(n: number): void { this.state.bet = n; this.render(); }
204
- setMode(mode: ShellMode): void { this.state.mode = mode; this.render(); }
212
+ setMode(mode: ShellMode): void {
213
+ if (mode === 'replay') this.state.replay = true; // sticky: a replay stays a replay across modes
214
+ this.state.mode = mode;
215
+ this.render();
216
+ }
205
217
  setBusy(busy: boolean): void { this.state.busy = busy; this.render(); }
206
218
  setAutoplay(a: AutoplayOptions): void { this.state.autoplay = a; this.render(); }
207
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); }
208
223
  setBuyBonusEnabled(enabled: boolean): void { this.state.buyBonusEnabled = enabled; this.render(); }
209
224
  setFreeSpins(fs: FreeSpinsState): void { this.state.freeSpins = fs; this.render(); }
210
225
 
@@ -272,6 +287,9 @@ export class GameShell extends EventEmitter<ShellEvents> {
272
287
  /** Open a generic, externally-driven modal (title + body + optional action buttons).
273
288
  * Each action runs its `on` then closes; the ✕ shows when `availableClose` is true. */
274
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 = ''; }
275
293
  /** Open the non-dismissable replay summary modal (START REPLAY → onReplay → reopen). */
276
294
  openReplay(opts: ReplayModalOptions): void {
277
295
  if (this.destroyed) return;
@@ -288,7 +306,11 @@ export class GameShell extends EventEmitter<ShellEvents> {
288
306
  this.destroyed = true;
289
307
  this.ro?.disconnect();
290
308
  this.ro = null;
291
- 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
+ }
292
314
  this.cancelMoneyAnims();
293
315
  this.removeAllListeners();
294
316
  this.root.classList.add('ge-shell-hidden');
@@ -52,8 +52,9 @@ export function renderBottomBar(shell: GameShell): HTMLElement {
52
52
  // only when it's a free-spins replay (freeSpins.total > 0).
53
53
  const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
54
54
 
55
- // Replay is a read-only historical round — there's no real balance to show, so hide it.
56
- const balance = state.mode === 'replay'
55
+ // Replay is a read-only historical round — there's no real balance to show, so hide it. Keyed on
56
+ // the sticky `replay` flag (not `mode`) so it stays hidden through a replay's free-spins phase.
57
+ const balance = state.replay
57
58
  ? null
58
59
  : readout('balance', shell.t('Balance'), fmt(state.balance));
59
60
  // With a feature active (e.g. Ante) the BET readout shows the effective stake, tinted with
@@ -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); }
@@ -3,6 +3,7 @@ import type { ShellConfig, ShellState } from './types';
3
3
  export function createInitialState(config: ShellConfig): ShellState {
4
4
  return {
5
5
  mode: config.mode,
6
+ replay: config.replay ?? config.mode === 'replay',
6
7
  balance: config.balance,
7
8
  win: config.win,
8
9
  bet: config.currentBet ?? config.defaultBet,
@@ -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;
@@ -204,6 +207,10 @@ export interface ShellConfig {
204
207
  balance: number;
205
208
  win: number;
206
209
  mode: ShellMode;
210
+ /** Mark this shell as a read-only historical-round replay. A replay never shows the player's
211
+ * balance (there's no live wallet), even while its free-spins phase runs in `freeSpins` mode.
212
+ * Defaults to `mode === 'replay'`; set explicitly when a replay starts in another mode. */
213
+ replay?: boolean;
207
214
  features: ShellFeatures;
208
215
  /** Override the BUY BONUS bar button's action: when set, tapping it calls this instead of
209
216
  * opening the built-in buy-bonus overlay (e.g. the game shows its own bonus UI). The button
@@ -213,6 +220,10 @@ export interface ShellConfig {
213
220
 
214
221
  export interface ShellState {
215
222
  mode: ShellMode;
223
+ /** Sticky replay marker — true for a historical-round replay, regardless of the current
224
+ * `mode`. Set once (from config or when `mode` becomes 'replay') and never cleared, since a
225
+ * shell instance is either a live game or a replay viewer for its whole lifetime. */
226
+ replay: boolean;
216
227
  balance: number;
217
228
  win: number;
218
229
  bet: number;
@@ -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.0';
@@ -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;