@energy8platform/platform-core 0.28.3 → 0.30.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 (56) hide show
  1. package/README.md +127 -150
  2. package/bin/simulate.ts +35 -98
  3. package/dist/dev-bridge.cjs.js +15 -12
  4. package/dist/dev-bridge.cjs.js.map +1 -1
  5. package/dist/dev-bridge.d.ts +4 -3
  6. package/dist/dev-bridge.esm.js +15 -12
  7. package/dist/dev-bridge.esm.js.map +1 -1
  8. package/dist/game-spec.cjs.js +59 -59
  9. package/dist/game-spec.cjs.js.map +1 -1
  10. package/dist/game-spec.d.ts +24 -23
  11. package/dist/game-spec.esm.js +57 -56
  12. package/dist/game-spec.esm.js.map +1 -1
  13. package/dist/index.cjs.js +15 -12
  14. package/dist/index.cjs.js.map +1 -1
  15. package/dist/index.d.ts +23 -3
  16. package/dist/index.esm.js +15 -12
  17. package/dist/index.esm.js.map +1 -1
  18. package/dist/lua.cjs.js +0 -1234
  19. package/dist/lua.cjs.js.map +1 -1
  20. package/dist/lua.d.ts +1 -206
  21. package/dist/lua.esm.js +0 -1225
  22. package/dist/lua.esm.js.map +1 -1
  23. package/dist/simulation.cjs.js +48 -179
  24. package/dist/simulation.cjs.js.map +1 -1
  25. package/dist/simulation.d.ts +26 -60
  26. package/dist/simulation.esm.js +48 -178
  27. package/dist/simulation.esm.js.map +1 -1
  28. package/dist/vite.cjs.js +329 -109
  29. package/dist/vite.cjs.js.map +1 -1
  30. package/dist/vite.d.ts +19 -9
  31. package/dist/vite.esm.js +328 -109
  32. package/dist/vite.esm.js.map +1 -1
  33. package/package.json +6 -5
  34. package/scripts/install-e8.mjs +113 -0
  35. package/src/dev-bridge/DevBridge.ts +39 -23
  36. package/src/game-spec/defineGame.ts +2 -2
  37. package/src/game-spec/derive.ts +39 -16
  38. package/src/game-spec/export.ts +23 -39
  39. package/src/game-spec/index.ts +3 -3
  40. package/src/game-spec/types.ts +2 -1
  41. package/src/index.ts +6 -12
  42. package/src/lua/index.ts +4 -11
  43. package/src/simulation/NativeSimulationRunner.ts +71 -45
  44. package/src/simulation/index.ts +2 -4
  45. package/src/vite/index.ts +4 -121
  46. package/src/vite/spinPlugin.ts +344 -0
  47. package/scripts/install-simulate.mjs +0 -101
  48. package/src/lua/ActionRouter.ts +0 -132
  49. package/src/lua/LuaEngine.ts +0 -520
  50. package/src/lua/LuaEngineAPI.ts +0 -314
  51. package/src/lua/PersistentState.ts +0 -80
  52. package/src/lua/SessionManager.ts +0 -249
  53. package/src/lua/SimulationRunner.ts +0 -190
  54. package/src/lua/fengari.d.ts +0 -10
  55. package/src/simulation/ParallelSimulationRunner.ts +0 -156
  56. package/src/simulation/SimulationWorker.ts +0 -44
@@ -81,11 +81,16 @@ function parseSessionTtl(ttl: string | undefined): number {
81
81
  if (!m) return DEFAULT_SESSION_TTL_MS;
82
82
  const n = parseFloat(m[1]);
83
83
  switch (m[2]) {
84
- case 'ms': return n;
85
- case 's': return n * 1000;
86
- case 'm': return n * 60 * 1000;
87
- case 'h': return n * 60 * 60 * 1000;
88
- default: return DEFAULT_SESSION_TTL_MS;
84
+ case 'ms':
85
+ return n;
86
+ case 's':
87
+ return n * 1000;
88
+ case 'm':
89
+ return n * 60 * 1000;
90
+ case 'h':
91
+ return n * 60 * 60 * 1000;
92
+ default:
93
+ return DEFAULT_SESSION_TTL_MS;
89
94
  }
90
95
  }
91
96
 
@@ -124,7 +129,8 @@ export interface DevBridgeConfig {
124
129
  currency?: string;
125
130
  /** Game config */
126
131
  gameConfig?: Partial<GameConfigData>;
127
- /** Base URL for assets (default: '/assets/') */
132
+ /** Base URL for assets (default: '/' — the site root; the folder lives in the asset paths, not
133
+ * the base, so a game isn't forced to name its folder `assets`). */
128
134
  assetsUrl?: string;
129
135
  /** Active session to resume (null = no active session) */
130
136
  session?: SessionData | null;
@@ -148,7 +154,10 @@ export interface DevBridgeConfig {
148
154
  replay?: ReplayConfig;
149
155
  }
150
156
 
151
- const DEFAULT_CONFIG: Omit<Required<DevBridgeConfig>, 'luaScript' | 'gameDefinition' | 'luaSeed' | 'replay'> = {
157
+ const DEFAULT_CONFIG: Omit<
158
+ Required<DevBridgeConfig>,
159
+ 'luaScript' | 'gameDefinition' | 'luaSeed' | 'replay'
160
+ > = {
152
161
  balance: 10000,
153
162
  currency: 'USD',
154
163
  gameConfig: {
@@ -158,7 +167,7 @@ const DEFAULT_CONFIG: Omit<Required<DevBridgeConfig>, 'luaScript' | 'gameDefinit
158
167
  viewport: { width: 1920, height: 1080 },
159
168
  betLevels: [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50],
160
169
  },
161
- assetsUrl: '/assets/',
170
+ assetsUrl: '/',
162
171
  session: null,
163
172
  onPlay: () => ({}),
164
173
  networkDelay: 200,
@@ -173,7 +182,7 @@ const DEFAULT_CONFIG: Omit<Required<DevBridgeConfig>, 'luaScript' | 'gameDefinit
173
182
  * the need for postMessage and iframes.
174
183
  *
175
184
  * When `luaScript` is set, play requests are sent to the Vite dev server
176
- * which runs LuaEngine in Node.js — no fengari in the browser.
185
+ * which runs LuaEngine in Node.js — no math in the browser.
177
186
  *
178
187
  * @example
179
188
  * ```ts
@@ -192,7 +201,20 @@ const DEFAULT_CONFIG: Omit<Required<DevBridgeConfig>, 'luaScript' | 'gameDefinit
192
201
  * ```
193
202
  */
194
203
  export class DevBridge {
195
- private _config: Required<Pick<DevBridgeConfig, 'balance' | 'currency' | 'gameConfig' | 'assetsUrl' | 'session' | 'onPlay' | 'networkDelay' | 'debug'>> & Pick<DevBridgeConfig, 'luaScript' | 'gameDefinition' | 'luaSeed' | 'replay'>;
204
+ private _config: Required<
205
+ Pick<
206
+ DevBridgeConfig,
207
+ | 'balance'
208
+ | 'currency'
209
+ | 'gameConfig'
210
+ | 'assetsUrl'
211
+ | 'session'
212
+ | 'onPlay'
213
+ | 'networkDelay'
214
+ | 'debug'
215
+ >
216
+ > &
217
+ Pick<DevBridgeConfig, 'luaScript' | 'gameDefinition' | 'luaSeed' | 'replay'>;
196
218
  private _balance: number;
197
219
  private _roundCounter = 0;
198
220
  private _bridge: Bridge | null = null;
@@ -346,9 +368,7 @@ export class DevBridge {
346
368
  private resolveReplayResults(): Promise<PlayResultData[]> {
347
369
  if (!this._replayResults) {
348
370
  const { mode, roundId } = this._replayLaunch ?? {};
349
- this._replayResults = Promise.resolve(
350
- this._config.replay!.resolve(mode, roundId),
351
- );
371
+ this._replayResults = Promise.resolve(this._config.replay!.resolve(mode, roundId));
352
372
  }
353
373
  return this._replayResults;
354
374
  }
@@ -382,10 +402,7 @@ export class DevBridge {
382
402
  });
383
403
  }
384
404
 
385
- private handlePlayRequest(
386
- payload: PlayParams,
387
- id?: string,
388
- ): void {
405
+ private handlePlayRequest(payload: PlayParams, id?: string): void {
389
406
  if (this._replayLaunch) {
390
407
  this.handleReplayPlay(id);
391
408
  return;
@@ -462,11 +479,9 @@ export class DevBridge {
462
479
  // Round id rules mirror server's playRound:
463
480
  // non-session → fresh UUID, client-supplied id is ignored
464
481
  // session-based → reuse the active session's round id
465
- const serverRoundId = actionDef.requires_session
466
- ? this._activeRoundId!
467
- : generateRoundId();
482
+ const serverRoundId = actionDef.requires_session ? this._activeRoundId! : generateRoundId();
468
483
 
469
- this.executeLuaOnServer({ action, bet, roundId: serverRoundId, params })
484
+ this.executeOnServer({ action, bet, roundId: serverRoundId, params })
470
485
  .then((result) => {
471
486
  this._lastPlayResult = result;
472
487
  this.updateSessionState(result);
@@ -481,7 +496,8 @@ export class DevBridge {
481
496
  // Fallback to onPlay callback
482
497
  const { roundId } = payload;
483
498
  const customResult = this._config.onPlay({ action, bet, roundId, params });
484
- const totalWin = customResult.totalWin ?? (Math.random() > 0.6 ? bet * (1 + Math.random() * 10) : 0);
499
+ const totalWin =
500
+ customResult.totalWin ?? (Math.random() > 0.6 ? bet * (1 + Math.random() * 10) : 0);
485
501
 
486
502
  this._balance += totalWin;
487
503
 
@@ -561,7 +577,7 @@ export class DevBridge {
561
577
  return bet;
562
578
  }
563
579
 
564
- private async executeLuaOnServer(params: PlayParams): Promise<PlayResultData> {
580
+ private async executeOnServer(params: PlayParams): Promise<PlayResultData> {
565
581
  const response = await fetch('/__lua-play', {
566
582
  method: 'POST',
567
583
  headers: { 'Content-Type': 'application/json' },
@@ -1,13 +1,13 @@
1
1
  import type { GameSpec, GameModel } from './types';
2
2
  import { validateSpec } from './validate';
3
- import { toGameDefinition, toLuaPrelude, toModeMap, toMathModes, toPaytableView } from './derive';
3
+ import { toGameDefinition, toSpinPrelude, toModeMap, toMathModes, toPaytableView } from './derive';
4
4
 
5
5
  export function defineGame(spec: GameSpec): GameModel {
6
6
  validateSpec(spec);
7
7
  return {
8
8
  spec,
9
9
  gameDefinition: toGameDefinition(spec),
10
- luaPrelude: toLuaPrelude(spec),
10
+ spinPrelude: toSpinPrelude(spec),
11
11
  modeMap: toModeMap(spec),
12
12
  mathModes: toMathModes(spec),
13
13
  paytable: toPaytableView(spec),
@@ -76,30 +76,53 @@ export function toGameDefinition(spec: GameSpec): GameDefinition {
76
76
  return def;
77
77
  }
78
78
 
79
- function luaTable(record: Record<number, number>): string {
80
- const parts = Object.entries(record).map(([k, v]) => `[${k}]=${v}`);
81
- return `{${parts.join(', ')}}`;
79
+ /** Число с гарантированной float-формой для SpinML (10 "10.0"). */
80
+ function spinF(n: number): string {
81
+ return Number.isInteger(n) ? `${n}.0` : String(n);
82
82
  }
83
83
 
84
- export function toLuaPrelude(spec: GameSpec): string {
84
+ /**
85
+ * The generated .spin prelude: SPEC/SYM as const groups,
86
+ * SYMBOLS as a str array, the paytable as indexable threshold/payout arrays
87
+ * (PAY_COUNTS + PAY_<ID>, 0.0 = no pay at that threshold), VALUES as
88
+ * VAL_<ID>. Prepended to the author's script.spin by buildSpinScript.
89
+ */
90
+ export function toSpinPrelude(spec: GameSpec): string {
85
91
  const lines: string[] = ['-- AUTO-GENERATED from game.spec.ts — do not edit'];
86
- lines.push(`SPEC = { cols = ${spec.grid.cols}, rows = ${spec.grid.rows}, max_win = ${spec.maxWin} }`);
92
+ lines.push(
93
+ `const SPEC = { cols: ${spec.grid.cols}, rows: ${spec.grid.rows}, cells: ${spec.grid.cols * spec.grid.rows}, max_win: ${spinF(spec.maxWin)} }`,
94
+ );
87
95
 
88
96
  const symNames = spec.symbols.map((s) => `"${s.id}"`).join(', ');
89
- lines.push(`SYMBOLS = { ${symNames} }`);
97
+ lines.push(`const SYMBOLS: [str; ${spec.symbols.length}] = [${symNames}]`);
98
+ lines.push(`const N_SYMBOLS: int = ${spec.symbols.length}`);
90
99
 
91
- const symIndex = spec.symbols.map((s, i) => `${s.id}=${i + 1}`).join(', ');
92
- lines.push(`SYM = { ${symIndex} }`);
100
+ const symIndex = spec.symbols.map((s, i) => `${s.id}: ${i + 1}`).join(', ');
101
+ lines.push(`-- 1-based индексы символов (порядок SYMBOLS)`);
102
+ lines.push(`const SYM = { ${symIndex} }`);
93
103
 
94
- const payEntries = spec.symbols
95
- .filter((s) => s.pay)
96
- .map((s) => ` ${s.id} = ${luaTable(s.pay as Record<number, number>)}`);
97
- lines.push(`PAYTABLE = {\n${payEntries.join(',\n')}\n}`);
104
+ const paying = spec.symbols.filter((s) => s.pay);
105
+ if (paying.length) {
106
+ const counts = Array.from(
107
+ new Set(paying.flatMap((s) => Object.keys(s.pay as Record<number, number>).map(Number))),
108
+ ).sort((a, b) => a - b);
109
+ lines.push(`-- пейтейбл: пороги count + выплата на порог (0.0 = нет выплаты)`);
110
+ lines.push(`const PAY_COUNTS: [int; ${counts.length}] = [${counts.join(', ')}]`);
111
+ for (const s of paying) {
112
+ const pay = s.pay as Record<number, number>;
113
+ const row = counts.map((c) => spinF(pay[c] ?? 0));
114
+ lines.push(`const PAY_${s.id}: [float; ${counts.length}] = [${row.join(', ')}]`);
115
+ }
116
+ }
98
117
 
99
- const valEntries = spec.symbols
100
- .filter((s) => s.value !== undefined)
101
- .map((s) => ` ${s.id} = ${Array.isArray(s.value) ? `{${s.value.join(', ')}}` : s.value}`);
102
- if (valEntries.length) lines.push(`VALUES = {\n${valEntries.join(',\n')}\n}`);
118
+ for (const s of spec.symbols) {
119
+ if (s.value === undefined) continue;
120
+ if (Array.isArray(s.value)) {
121
+ lines.push(`const VAL_${s.id}: [int; ${s.value.length}] = [${s.value.join(', ')}]`);
122
+ } else {
123
+ lines.push(`const VAL_${s.id}: int = ${s.value}`);
124
+ }
125
+ }
103
126
 
104
127
  return lines.join('\n') + '\n';
105
128
  }
@@ -1,53 +1,37 @@
1
1
  import type { GameSpec, GameModel } from './types';
2
2
  import { defineGame } from './defineGame';
3
3
 
4
- /** Compose the self-contained Lua the platform runs: generated prelude ⧺ author logic. */
5
- export function buildLuaScript(model: GameModel, logicLua: string): string {
6
- return model.luaPrelude + '\n' + logicLua;
4
+ /** Compose the self-contained .spin: generated const prelude ⧺ author math. */
5
+ export function buildSpinScript(model: GameModel, logicSpin: string): string {
6
+ return model.spinPrelude + '\n' + logicSpin;
7
7
  }
8
8
 
9
- /** The two E8-platform deliverables, keyed by their on-disk filenames. */
10
- export interface E8Bundle {
11
- /** GameDefinition JSON — uploaded to S3 as `games/{id}/config.json`. */
9
+ /** The spin-runtime platform deliverables, keyed by on-disk filenames. */
10
+ export interface E8SpinBundle {
11
+ /** GameDefinition JSON (engine_mode=spin) — S3: games/{id}/config.json. */
12
12
  'config.json': string;
13
- /** Self-contained Lua (prelude ⧺ logic) — uploaded to S3 as `games/{id}/script.lua`. */
14
- 'script.lua': string;
13
+ /** Self-contained SpinML (prelude ⧺ math) — S3: games/{id}/script.spin. */
14
+ 'script.spin': string;
15
15
  }
16
16
 
17
17
  /**
18
- * Produce the E8 platform deliverables from one spec + the author's `script.logic.lua`.
19
- * The config carries `script_path` so the platform can locate the uploaded script; the script is
20
- * the prelude-prepended, self-contained source. Structurally validated before returning.
18
+ * Produce the spin-runtime deliverables from one spec + the author's
19
+ * `script.spin`. The platform routes engine_mode="spin" games to the e8
20
+ * engine; script_path points at the uploaded .spin.
21
21
  */
22
- export function exportGame(spec: GameSpec, opts: { logicLua: string }): E8Bundle {
22
+ export function exportGameSpin(spec: GameSpec, opts: { logicSpin: string }): E8SpinBundle {
23
23
  const model = defineGame(spec);
24
- const bundle: E8Bundle = {
25
- 'config.json': JSON.stringify(model.gameDefinition, null, 2),
26
- 'script.lua': buildLuaScript(model, opts.logicLua),
24
+ const config = {
25
+ ...(model.gameDefinition as unknown as Record<string, unknown>),
26
+ engine_mode: 'spin',
27
+ script_path: 'script.spin',
27
28
  };
28
- validateE8Bundle(bundle);
29
- return bundle;
30
- }
31
-
32
- /**
33
- * Structural (fengari-free) checks that catch the obvious ways an export is unusable before it
34
- * reaches the platform: malformed config, missing `script_path`/`actions`, or a script with no
35
- * `execute` entry point. A full boot check (running the script in a LuaEngine) is the caller's job.
36
- */
37
- export function validateE8Bundle(bundle: E8Bundle): void {
38
- let config: Record<string, unknown>;
39
- try {
40
- config = JSON.parse(bundle['config.json']);
41
- } catch (e) {
42
- throw new Error(`E8 export: config.json is not valid JSON — ${(e as Error).message}`);
43
- }
44
- if (!config.id || typeof config.id !== 'string') throw new Error('E8 export: config.json missing "id"');
45
- if (config.type !== 'SLOT' && config.type !== 'TABLE') throw new Error('E8 export: config.json "type" must be SLOT or TABLE');
46
- if (!config.script_path) throw new Error('E8 export: config.json missing "script_path"');
47
- if (!config.actions || typeof config.actions !== 'object' || Object.keys(config.actions as object).length === 0) {
48
- throw new Error('E8 export: config.json has no actions');
49
- }
50
- if (!/function\s+execute\s*\(/.test(bundle['script.lua'])) {
51
- throw new Error('E8 export: script.lua does not define a global `execute(state)` function');
29
+ const script = buildSpinScript(model, opts.logicSpin);
30
+ if (!/fn\s+execute\s*\(/.test(script)) {
31
+ throw new Error('E8 spin export: script.spin does not define `fn execute(...)`');
52
32
  }
33
+ return {
34
+ 'config.json': JSON.stringify(config, null, 2),
35
+ 'script.spin': script,
36
+ };
53
37
  }
@@ -1,7 +1,7 @@
1
1
  export * from './types';
2
2
  export { validateSpec, GameSpecError } from './validate';
3
- export { toGameDefinition, toLuaPrelude, toModeMap, toMathModes, toPaytableView } from './derive';
3
+ export { toGameDefinition, toSpinPrelude, toModeMap, toMathModes, toPaytableView } from './derive';
4
4
  export { defineGame } from './defineGame';
5
- export { buildLuaScript, exportGame, validateE8Bundle } from './export';
6
- export type { E8Bundle } from './export';
5
+ export { buildSpinScript, exportGameSpin } from './export';
6
+ export type { E8SpinBundle } from './export';
7
7
  export type { GameDefinition, ActionDefinition, TransitionRule, MaxWinConfig } from '../lua/types';
@@ -85,7 +85,8 @@ export interface PaytableView {
85
85
  export interface GameModel {
86
86
  spec: GameSpec;
87
87
  gameDefinition: GameDefinition;
88
- luaPrelude: string;
88
+ /** Generated .spin prelude (SPEC/SYM/PAYTABLE consts) for the spin runtime. */
89
+ spinPrelude: string;
89
90
  modeMap: Record<string, string>;
90
91
  mathModes: MathModeSpec[];
91
92
  paytable: PaytableView;
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * engine.
6
6
  *
7
7
  * Sub-paths for fine-grained imports:
8
- * - `@energy8platform/platform-core/lua` — Lua engine + simulation
8
+ * - `@energy8platform/platform-core/lua` — shared game-definition types
9
9
  * - `@energy8platform/platform-core/dev-bridge` — DevBridge mock host
10
10
  * - `@energy8platform/platform-core/vite` — Vite plugins
11
11
  */
@@ -21,17 +21,11 @@ export type {
21
21
  SDKOptions,
22
22
  } from './PlatformSession';
23
23
 
24
- // ─── Lua ────────────────────────────────────────────────
25
- // LuaEngine and friends are available only via the /lua sub-path. We
26
- // don't re-export them as runtime values from the main entry because
27
- // `fengari` (the underlying Lua VM) is a CommonJS module — pulling it
28
- // in unconditionally breaks Vite dev-mode ESM resolution for any
29
- // consumer that doesn't actually use Lua in the browser. Use:
30
- //
31
- // import { LuaEngine } from '@energy8platform/platform-core/lua';
32
- //
33
- // For Node-only RTP simulation (Go binary, worker_threads), import from
34
- // '@energy8platform/platform-core/simulation' instead.
24
+ // ─── Math runtime ───────────────────────────────────────
25
+ // The math runtime is SpinML (the Rust e8 engine): dev rounds via the
26
+ // `spinPlugin` vite plugin + e8-server, simulation via `e8 simulate`
27
+ // (see '@energy8platform/platform-core/simulation'). The fengari Lua
28
+ // engine was removed legacy Lua games stay on <= 0.28.x.
35
29
 
36
30
  // ─── DevBridge ──────────────────────────────────────────
37
31
  export { DevBridge } from './dev-bridge';
package/src/lua/index.ts CHANGED
@@ -1,14 +1,7 @@
1
- // Browser-safe Lua engine surface. Only depends on `fengari`, no Node built-ins.
2
- //
3
- // For the Node-only runners (NativeSimulationRunner backed by a Go binary,
4
- // ParallelSimulationRunner backed by worker_threads), import from
5
- // `@energy8platform/platform-core/simulation` instead.
6
- export { LuaEngine } from './LuaEngine';
7
- export { LuaEngineAPI, createSeededRng } from './LuaEngineAPI';
8
- export { ActionRouter, evaluateCondition } from './ActionRouter';
9
- export { SessionManager } from './SessionManager';
10
- export { PersistentState } from './PersistentState';
11
- export { SimulationRunner, formatSimulationResult } from './SimulationRunner';
1
+ // Shared game-definition types. The fengari Lua engine that used to live
2
+ // here is GONE — the math runtime is SpinML (e8): dev rounds via spinPlugin
3
+ // + e8-server, simulation via `e8 simulate` (stake-math-tools). Legacy Lua
4
+ // games stay on platform-core <= 0.28.x.
12
5
  export type {
13
6
  GameDefinition,
14
7
  ActionDefinition,
@@ -28,6 +28,17 @@ export interface NativeSimulationConfig {
28
28
  binaryPath: string;
29
29
  /** Lua script source code */
30
30
  script: string;
31
+ /**
32
+ * Extension of the temp script file (default 'lua'). The e8 SpinML engine
33
+ * takes 'spin' — the runner is otherwise runtime-agnostic: it writes the
34
+ * script, points config.script_path at it and spawns the binary.
35
+ */
36
+ scriptExt?: 'lua' | 'spin';
37
+ /**
38
+ * Args prepended before the flag args (default none). The e8 binary is a
39
+ * multi-command CLI — its Go-compatible mode is the `simulate` subcommand.
40
+ */
41
+ argsPrefix?: string[];
31
42
  /** Platform game definition */
32
43
  gameDefinition: GameDefinition;
33
44
  /** Number of iterations */
@@ -100,6 +111,14 @@ export interface NativeSimulationResult extends SimulationResult {
100
111
  workerSeeds?: string[];
101
112
  /** Echo of replay params when the run was in replay mode. */
102
113
  replay?: NativeReplayParams;
114
+ /** Round-win standard deviation (currency units). */
115
+ stddev?: number;
116
+ /** Coefficient of variation (stddev / mean round win). */
117
+ cv?: number;
118
+ /** Volatility score 0..10 (casino_platform classifyVolatility buckets). */
119
+ volatility?: number;
120
+ /** Volatility tier label (Low … Extreme). */
121
+ volatilityLabel?: string;
103
122
  }
104
123
 
105
124
  // ─── Go JSON output shape (snake_case) ──────────────────
@@ -136,6 +155,10 @@ interface GoSimulationOutput {
136
155
  rng_kind?: NativeRNGKind;
137
156
  master_seed?: string;
138
157
  worker_seeds?: string[];
158
+ stddev?: number;
159
+ cv?: number;
160
+ volatility?: number;
161
+ volatility_label?: string;
139
162
  replay?: {
140
163
  server_seed: string;
141
164
  client_seed: string;
@@ -190,7 +213,7 @@ export class NativeSimulationRunner {
190
213
  }
191
214
 
192
215
  async run(): Promise<NativeSimulationResult> {
193
- const { binaryPath, script, gameDefinition, iterations, bet, action, params, seed, rng, replay, dump } = this.config;
216
+ const { binaryPath, script, scriptExt, argsPrefix, gameDefinition, iterations, bet, action, params, seed, rng, replay, dump } = this.config;
194
217
 
195
218
  if (replay && rng && rng !== 'provably-fair') {
196
219
  throw new Error(`Replay mode requires rng="provably-fair" (got rng="${rng}")`);
@@ -198,28 +221,28 @@ export class NativeSimulationRunner {
198
221
 
199
222
  const id = randomBytes(8).toString('hex');
200
223
  const tmpDir = tmpdir();
201
- const luaPath = join(tmpDir, `sim-${id}.lua`);
224
+ const scriptPath = join(tmpDir, `sim-${id}.${scriptExt ?? 'lua'}`);
202
225
  const configPath = join(tmpDir, `sim-${id}.json`);
203
226
 
204
227
  try {
205
228
  // Write temp files
206
229
  await Promise.all([
207
- writeFile(luaPath, script, 'utf-8'),
208
- writeFile(configPath, JSON.stringify({ ...gameDefinition, script_path: luaPath }), 'utf-8'),
230
+ writeFile(scriptPath, script, 'utf-8'),
231
+ writeFile(configPath, JSON.stringify({ ...gameDefinition, script_path: scriptPath }), 'utf-8'),
209
232
  ]);
210
233
 
211
234
  // Build CLI args
212
235
  const args = buildNativeArgs({ configPath, iterations, bet, action, params, rng, seed, dump, replay });
213
236
 
214
237
  // Execute binary
215
- const output = await this.exec(binaryPath, args);
238
+ const output = await this.exec(binaryPath, [...(argsPrefix ?? []), ...args]);
216
239
 
217
240
  // Parse JSON output
218
241
  const json: GoSimulationOutput = JSON.parse(output);
219
242
  return mapGoResult(json);
220
243
  } finally {
221
244
  // Cleanup temp files
222
- await Promise.allSettled([unlink(luaPath), unlink(configPath)]);
245
+ await Promise.allSettled([unlink(scriptPath), unlink(configPath)]);
223
246
  }
224
247
  }
225
248
 
@@ -298,6 +321,10 @@ function mapGoResult(json: GoSimulationOutput): NativeSimulationResult {
298
321
  rngKind: json.rng_kind,
299
322
  masterSeed: json.master_seed,
300
323
  workerSeeds: json.worker_seeds,
324
+ stddev: json.stddev,
325
+ cv: json.cv,
326
+ volatility: json.volatility,
327
+ volatilityLabel: json.volatility_label,
301
328
  replay: json.replay
302
329
  ? {
303
330
  serverSeed: json.replay.server_seed,
@@ -318,42 +345,40 @@ function mapGoResult(json: GoSimulationOutput): NativeSimulationResult {
318
345
  // ─── Binary discovery ───────────────────────────────────
319
346
 
320
347
  /**
321
- * Search for a native simulation binary in standard locations.
322
- * Returns the absolute path if found, null otherwise.
348
+ * Search for the e8 SpinML engine binary (Rust) same lookup discipline as
349
+ * findNativeBinary: env override <pkg>/bin/e8-<platform>-<arch> $PATH.
350
+ * Fetched by scripts/install-e8.mjs on postinstall.
323
351
  */
324
- export function findNativeBinary(baseDir?: string): string | null {
325
- // 1. Explicit env var
326
- const envPath = process.env.SIMULATE_BINARY;
352
+ export function findE8Binary(baseDir?: string): string | null {
353
+ const envPath = process.env.E8_BINARY;
327
354
  if (envPath && isExecutable(envPath)) {
328
355
  return envPath;
329
356
  }
330
357
 
331
- const platform = process.platform; // darwin, linux, win32
332
- const nodeArch = process.arch; // arm64, x64
358
+ const platform = process.platform;
359
+ const nodeArch = process.arch;
333
360
  const goArch = nodeArch === 'x64' ? 'amd64' : nodeArch;
334
361
  const goPlatform = platform === 'win32' ? 'windows' : platform;
335
362
  const ext = platform === 'win32' ? '.exe' : '';
336
363
 
337
- const names = [
338
- `simulate-${goPlatform}-${goArch}${ext}`,
339
- `simulation-${goPlatform}-${goArch}${ext}`,
340
- `simulate${ext}`,
341
- `simulation${ext}`,
342
- ];
364
+ const names = [`e8-${goPlatform}-${goArch}${ext}`, `e8${ext}`];
343
365
 
344
- // Search directories: user's project first, then this package's bin/
345
366
  const searchDirs: string[] = [];
346
367
  if (baseDir) searchDirs.push(baseDir);
347
-
348
- // This package's root (where postinstall downloads the binary)
368
+ // Раскладки различаются: dist/simulation.esm.js → ../bin,
369
+ // src/simulation/*.ts (vitest/tsx) ../../bin. Пробуем оба уровня.
349
370
  try {
350
- const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
351
- if (!searchDirs.includes(pkgRoot)) searchDirs.push(pkgRoot);
371
+ const here = dirname(fileURLToPath(import.meta.url));
372
+ for (const up of ['..', '../..']) {
373
+ const root = join(here, up);
374
+ if (!searchDirs.includes(root)) searchDirs.push(root);
375
+ }
352
376
  } catch {
353
- // fallback for CJS
354
377
  if (typeof __dirname !== 'undefined') {
355
- const pkgRoot = join(__dirname, '..');
356
- if (!searchDirs.includes(pkgRoot)) searchDirs.push(pkgRoot);
378
+ for (const up of ['..', '../..']) {
379
+ const root = join(__dirname, up);
380
+ if (!searchDirs.includes(root)) searchDirs.push(root);
381
+ }
357
382
  }
358
383
  }
359
384
 
@@ -364,32 +389,24 @@ export function findNativeBinary(baseDir?: string): string | null {
364
389
  }
365
390
  }
366
391
 
367
- // Check $PATH
368
- for (const bin of ['simulate', 'simulation']) {
369
- try {
370
- const cmd = platform === 'win32' ? `where ${bin}` : `which ${bin}`;
371
- const result = execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
372
- if (result) return result.split('\n')[0];
373
- } catch {
374
- // not found
375
- }
392
+ try {
393
+ const cmd = platform === 'win32' ? 'where e8' : 'which e8';
394
+ const result = execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
395
+ if (result) return result.split('\n')[0];
396
+ } catch {
397
+ // not found
376
398
  }
377
399
 
378
400
  return null;
379
401
  }
380
402
 
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 {
403
+ /** Return the e8 binary path or throw an install-guiding error. */
404
+ export function requireE8Binary(finder: () => string | null = findE8Binary): string {
388
405
  const bin = finder();
389
406
  if (!bin) {
390
407
  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).',
408
+ 'e8 engine binary not found — run `npm install` (platform-core fetches it via install-e8), ' +
409
+ 'or point E8_BINARY at a local build (casino-platform/e8/target/release/e8).',
393
410
  );
394
411
  }
395
412
  return bin;
@@ -445,6 +462,15 @@ export function formatNativeResult(result: NativeSimulationResult): string {
445
462
  `Max Win Cap Hits: ${result.maxWinHits}`,
446
463
  );
447
464
 
465
+ if (result.volatilityLabel !== undefined && result.cv !== undefined) {
466
+ lines.push(
467
+ '',
468
+ '--- Volatility ---',
469
+ `Volatility: ${result.volatility}/10 (${result.volatilityLabel})`,
470
+ `StdDev: ${(result.stddev ?? 0).toFixed(2)} CV: ${result.cv.toFixed(2)}`,
471
+ );
472
+ }
473
+
448
474
  if (result.bonusTriggered > 0) {
449
475
  const frequency = Math.round(result.iterations / result.bonusTriggered);
450
476
  lines.push(
@@ -8,10 +8,10 @@
8
8
 
9
9
  export {
10
10
  NativeSimulationRunner,
11
- findNativeBinary,
12
11
  formatNativeResult,
13
12
  buildNativeArgs,
14
- requireNativeBinary,
13
+ findE8Binary,
14
+ requireE8Binary,
15
15
  } from './NativeSimulationRunner';
16
16
  export type {
17
17
  NativeSimulationConfig,
@@ -22,5 +22,3 @@ export type {
22
22
  DistributionBucket,
23
23
  NativeArgsInput,
24
24
  } from './NativeSimulationRunner';
25
-
26
- export { ParallelSimulationRunner } from './ParallelSimulationRunner';