@energy8platform/game-engine 0.15.2 → 0.16.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 (55) hide show
  1. package/README.md +22 -12
  2. package/dist/assets.d.ts +2 -14
  3. package/dist/core.cjs.js +16 -201
  4. package/dist/core.cjs.js.map +1 -1
  5. package/dist/core.d.ts +4 -35
  6. package/dist/core.esm.js +12 -197
  7. package/dist/core.esm.js.map +1 -1
  8. package/dist/debug.cjs.js +5 -233
  9. package/dist/debug.cjs.js.map +1 -1
  10. package/dist/debug.d.ts +2 -140
  11. package/dist/debug.esm.js +2 -233
  12. package/dist/debug.esm.js.map +1 -1
  13. package/dist/index.cjs.js +21 -432
  14. package/dist/index.cjs.js.map +1 -1
  15. package/dist/index.d.ts +10 -195
  16. package/dist/index.esm.js +14 -429
  17. package/dist/index.esm.js.map +1 -1
  18. package/dist/lua.cjs.js +7 -1493
  19. package/dist/lua.cjs.js.map +1 -1
  20. package/dist/lua.d.ts +1 -403
  21. package/dist/lua.esm.js +1 -1483
  22. package/dist/lua.esm.js.map +1 -1
  23. package/dist/react.cjs.js.map +1 -1
  24. package/dist/react.d.ts +4 -35
  25. package/dist/react.esm.js.map +1 -1
  26. package/dist/vite.cjs.js +19 -175
  27. package/dist/vite.cjs.js.map +1 -1
  28. package/dist/vite.d.ts +8 -3
  29. package/dist/vite.esm.js +10 -173
  30. package/dist/vite.esm.js.map +1 -1
  31. package/package.json +6 -16
  32. package/src/core/GameApplication.ts +14 -18
  33. package/src/debug/index.ts +4 -2
  34. package/src/index.ts +3 -3
  35. package/src/loading/LoadingScene.ts +1 -1
  36. package/src/loading/index.ts +9 -2
  37. package/src/lua/index.ts +3 -31
  38. package/src/types.ts +16 -41
  39. package/src/vite/index.ts +13 -196
  40. package/bin/simulate.ts +0 -139
  41. package/scripts/install-simulate.mjs +0 -101
  42. package/src/debug/DevBridge.ts +0 -305
  43. package/src/loading/CSSPreloader.ts +0 -129
  44. package/src/loading/logo.ts +0 -95
  45. package/src/lua/ActionRouter.ts +0 -132
  46. package/src/lua/LuaEngine.ts +0 -412
  47. package/src/lua/LuaEngineAPI.ts +0 -314
  48. package/src/lua/NativeSimulationRunner.ts +0 -367
  49. package/src/lua/ParallelSimulationRunner.ts +0 -156
  50. package/src/lua/PersistentState.ts +0 -80
  51. package/src/lua/SessionManager.ts +0 -227
  52. package/src/lua/SimulationRunner.ts +0 -192
  53. package/src/lua/SimulationWorker.ts +0 -44
  54. package/src/lua/fengari.d.ts +0 -10
  55. package/src/lua/types.ts +0 -149
@@ -1,314 +0,0 @@
1
- import type { GameDefinition } from './types';
2
- import fengari from 'fengari';
3
-
4
- const { lua, lauxlib } = fengari;
5
- const { to_luastring, to_jsstring } = fengari;
6
-
7
- export type RngFunction = () => number;
8
-
9
- /** Cache for to_luastring() results — avoids re-encoding the same keys every iteration */
10
- const luaStringCache = new Map<string, Uint8Array>();
11
-
12
- export function cachedToLuastring(s: string): Uint8Array {
13
- let cached = luaStringCache.get(s);
14
- if (!cached) {
15
- cached = to_luastring(s);
16
- luaStringCache.set(s, cached);
17
- }
18
- return cached;
19
- }
20
-
21
- /**
22
- * Seeded xoshiro128** PRNG for deterministic simulation/replay.
23
- * Period: 2^128 - 1
24
- */
25
- export function createSeededRng(seed: number): RngFunction {
26
- let s0 = (seed >>> 0) | 1;
27
- let s1 = (seed * 1103515245 + 12345) >>> 0;
28
- let s2 = (seed * 6364136223846793005 + 1442695040888963407) >>> 0;
29
- let s3 = (seed * 1442695040888963407 + 6364136223846793005) >>> 0;
30
-
31
- return (): number => {
32
- const result = (((s1 * 5) << 7) * 9) >>> 0;
33
- const t = s1 << 9;
34
-
35
- s2 ^= s0;
36
- s3 ^= s1;
37
- s1 ^= s2;
38
- s0 ^= s3;
39
- s2 ^= t;
40
- s3 = ((s3 << 11) | (s3 >>> 21)) >>> 0;
41
-
42
- return result / 4294967296;
43
- };
44
- }
45
-
46
- /**
47
- * Implements and registers all platform `engine.*` functions into a Lua state.
48
- */
49
- export class LuaEngineAPI {
50
- private rng: RngFunction;
51
- private logger: (level: string, msg: string) => void;
52
- private gameDefinition: GameDefinition;
53
-
54
- constructor(
55
- gameDefinition: GameDefinition,
56
- rng?: RngFunction,
57
- logger?: (level: string, msg: string) => void,
58
- ) {
59
- this.gameDefinition = gameDefinition;
60
- this.rng = rng ?? Math.random;
61
- this.logger = logger ?? ((level, msg) => {
62
- const fn = level === 'error' ? console.error
63
- : level === 'warn' ? console.warn
64
- : level === 'debug' ? console.debug
65
- : console.log;
66
- fn(`[Lua:${level}] ${msg}`);
67
- });
68
- }
69
-
70
- /** Register `engine` global table on the Lua state */
71
- register(L: any): void {
72
- // Create the `engine` table
73
- lua.lua_newtable(L);
74
-
75
- this.registerFunction(L, 'random', (LS: any) => {
76
- const min = lauxlib.luaL_checkinteger(LS, 1);
77
- const max = lauxlib.luaL_checkinteger(LS, 2);
78
- const result = this.random(Number(min), Number(max));
79
- lua.lua_pushinteger(LS, result);
80
- return 1;
81
- });
82
-
83
- this.registerFunction(L, 'random_float', (LS: any) => {
84
- lua.lua_pushnumber(LS, this.randomFloat());
85
- return 1;
86
- });
87
-
88
- this.registerFunction(L, 'random_weighted', (LS: any) => {
89
- lauxlib.luaL_checktype(LS, 1, lua.LUA_TTABLE);
90
- const weights: number[] = [];
91
- const len = lua.lua_rawlen(LS, 1);
92
- for (let i = 1; i <= len; i++) {
93
- lua.lua_rawgeti(LS, 1, i);
94
- weights.push(lua.lua_tonumber(LS, -1));
95
- lua.lua_pop(LS, 1);
96
- }
97
- const result = this.randomWeighted(weights);
98
- lua.lua_pushinteger(LS, result);
99
- return 1;
100
- });
101
-
102
- this.registerFunction(L, 'shuffle', (LS: any) => {
103
- lauxlib.luaL_checktype(LS, 1, lua.LUA_TTABLE);
104
- const arr: unknown[] = [];
105
- const len = lua.lua_rawlen(LS, 1);
106
- for (let i = 1; i <= len; i++) {
107
- lua.lua_rawgeti(LS, 1, i);
108
- arr.push(luaToJS(LS, -1));
109
- lua.lua_pop(LS, 1);
110
- }
111
- const shuffled = this.shuffle(arr);
112
- pushJSArray(LS, shuffled);
113
- return 1;
114
- });
115
-
116
- this.registerFunction(L, 'log', (LS: any) => {
117
- const level = to_jsstring(lauxlib.luaL_checkstring(LS, 1));
118
- const msg = to_jsstring(lauxlib.luaL_checkstring(LS, 2));
119
- this.logger(level, msg);
120
- return 0;
121
- });
122
-
123
- this.registerFunction(L, 'get_config', (LS: any) => {
124
- const config = this.getConfig();
125
- pushJSObject(LS, config);
126
- return 1;
127
- });
128
-
129
- // Set the table as global `engine`
130
- lua.lua_setglobal(L, to_luastring('engine'));
131
- }
132
-
133
- // ─── engine.* implementations ─────────────────────────
134
-
135
- random(min: number, max: number): number {
136
- return Math.floor(this.rng() * (max - min + 1)) + min;
137
- }
138
-
139
- randomFloat(): number {
140
- return this.rng();
141
- }
142
-
143
- randomWeighted(weights: number[]): number {
144
- const totalWeight = weights.reduce((a, b) => a + b, 0);
145
- let roll = this.rng() * totalWeight;
146
- for (let i = 0; i < weights.length; i++) {
147
- roll -= weights[i];
148
- if (roll < 0) return i + 1; // 1-based index
149
- }
150
- return weights.length; // fallback to last
151
- }
152
-
153
- shuffle<T>(arr: T[]): T[] {
154
- const copy = [...arr];
155
- for (let i = copy.length - 1; i > 0; i--) {
156
- const j = Math.floor(this.rng() * (i + 1));
157
- [copy[i], copy[j]] = [copy[j], copy[i]];
158
- }
159
- return copy;
160
- }
161
-
162
- getConfig(): Record<string, unknown> {
163
- const def = this.gameDefinition;
164
- let betLevels: number[] = [];
165
- if (Array.isArray(def.bet_levels)) {
166
- betLevels = def.bet_levels;
167
- } else if (def.bet_levels && 'levels' in def.bet_levels && def.bet_levels.levels) {
168
- betLevels = def.bet_levels.levels;
169
- }
170
- return {
171
- id: def.id,
172
- type: def.type,
173
- bet_levels: betLevels,
174
- };
175
- }
176
-
177
- // ─── Helpers ──────────────────────────────────────────
178
-
179
- private registerFunction(L: any, name: string, fn: (L: any) => number): void {
180
- lua.lua_pushcfunction(L, fn);
181
- lua.lua_setfield(L, -2, to_luastring(name));
182
- }
183
- }
184
-
185
- // ─── Lua ↔ JS marshalling ───────────────────────────────
186
-
187
- /** Read a Lua value at the given stack index and return its JS equivalent */
188
- export function luaToJS(L: any, idx: number): unknown {
189
- const type = lua.lua_type(L, idx);
190
-
191
- switch (type) {
192
- case lua.LUA_TNIL:
193
- return null;
194
-
195
- case lua.LUA_TBOOLEAN:
196
- return lua.lua_toboolean(L, idx);
197
-
198
- case lua.LUA_TNUMBER:
199
- if (lua.lua_isinteger(L, idx)) {
200
- return Number(lua.lua_tointeger(L, idx));
201
- }
202
- return lua.lua_tonumber(L, idx);
203
-
204
- case lua.LUA_TSTRING:
205
- return to_jsstring(lua.lua_tostring(L, idx));
206
-
207
- case lua.LUA_TTABLE:
208
- return luaTableToJS(L, idx);
209
-
210
- default:
211
- return null;
212
- }
213
- }
214
-
215
- /** Convert a Lua table to a JS object or array */
216
- function luaTableToJS(L: any, idx: number): Record<string, unknown> | unknown[] {
217
- // Normalize index to absolute
218
- if (idx < 0) idx = lua.lua_gettop(L) + idx + 1;
219
-
220
- // Check if it's an array (sequential integer keys starting at 1)
221
- const len = lua.lua_rawlen(L, idx);
222
- if (len > 0) {
223
- // Verify it's a pure array by checking key 1 exists
224
- lua.lua_rawgeti(L, idx, 1);
225
- const hasFirst = lua.lua_type(L, -1) !== lua.LUA_TNIL;
226
- lua.lua_pop(L, 1);
227
-
228
- if (hasFirst) {
229
- // Check if there are also string keys (mixed table)
230
- let hasStringKeys = false;
231
- lua.lua_pushnil(L);
232
- while (lua.lua_next(L, idx) !== 0) {
233
- lua.lua_pop(L, 1); // pop value
234
- if (lua.lua_type(L, -1) === lua.LUA_TSTRING) {
235
- hasStringKeys = true;
236
- lua.lua_pop(L, 1); // pop key
237
- break;
238
- }
239
- }
240
-
241
- if (!hasStringKeys) {
242
- // Pure array
243
- const arr: unknown[] = [];
244
- for (let i = 1; i <= len; i++) {
245
- lua.lua_rawgeti(L, idx, i);
246
- arr.push(luaToJS(L, -1));
247
- lua.lua_pop(L, 1);
248
- }
249
- return arr;
250
- }
251
- }
252
- }
253
-
254
- // Object (or mixed table)
255
- const obj: Record<string, unknown> = {};
256
- lua.lua_pushnil(L);
257
- while (lua.lua_next(L, idx) !== 0) {
258
- const keyType = lua.lua_type(L, -2);
259
- let key: string;
260
- if (keyType === lua.LUA_TSTRING) {
261
- key = to_jsstring(lua.lua_tostring(L, -2));
262
- } else if (keyType === lua.LUA_TNUMBER) {
263
- key = String(lua.lua_tonumber(L, -2));
264
- } else {
265
- lua.lua_pop(L, 1);
266
- continue;
267
- }
268
- obj[key] = luaToJS(L, -1);
269
- lua.lua_pop(L, 1);
270
- }
271
- return obj;
272
- }
273
-
274
- /** Push a JS value onto the Lua stack */
275
- export function pushJSValue(L: any, value: unknown): void {
276
- if (value === null || value === undefined) {
277
- lua.lua_pushnil(L);
278
- } else if (typeof value === 'boolean') {
279
- lua.lua_pushboolean(L, value ? 1 : 0);
280
- } else if (typeof value === 'number') {
281
- if (Number.isInteger(value)) {
282
- lua.lua_pushinteger(L, value);
283
- } else {
284
- lua.lua_pushnumber(L, value);
285
- }
286
- } else if (typeof value === 'string') {
287
- lua.lua_pushstring(L, cachedToLuastring(value));
288
- } else if (Array.isArray(value)) {
289
- pushJSArray(L, value);
290
- } else if (typeof value === 'object') {
291
- pushJSObject(L, value as Record<string, unknown>);
292
- } else {
293
- lua.lua_pushnil(L);
294
- }
295
- }
296
-
297
- /** Push a JS array as a Lua table (1-based) */
298
- function pushJSArray(L: any, arr: unknown[]): void {
299
- lua.lua_createtable(L, arr.length, 0);
300
- for (let i = 0; i < arr.length; i++) {
301
- pushJSValue(L, arr[i]);
302
- lua.lua_rawseti(L, -2, i + 1);
303
- }
304
- }
305
-
306
- /** Push a JS object as a Lua table */
307
- function pushJSObject(L: any, obj: Record<string, unknown>): void {
308
- const keys = Object.keys(obj);
309
- lua.lua_createtable(L, 0, keys.length);
310
- for (const key of keys) {
311
- pushJSValue(L, obj[key]);
312
- lua.lua_setfield(L, -2, cachedToLuastring(key));
313
- }
314
- }
@@ -1,367 +0,0 @@
1
- import { spawn } from 'child_process';
2
- import { writeFile, unlink } from 'fs/promises';
3
- import { accessSync, constants as fsConstants } from 'fs';
4
- import { join, dirname } from 'path';
5
- import { tmpdir } from 'os';
6
- import { randomBytes } from 'crypto';
7
- import { execSync } from 'child_process';
8
- import { fileURLToPath } from 'url';
9
- import type { GameDefinition, SimulationResult } from './types';
10
-
11
- // ─── Types ──────────────────────────────────────────────
12
-
13
- export interface NativeSimulationConfig {
14
- /** Path to native simulation binary */
15
- binaryPath: string;
16
- /** Lua script source code */
17
- script: string;
18
- /** Platform game definition */
19
- gameDefinition: GameDefinition;
20
- /** Number of iterations */
21
- iterations: number;
22
- /** Bet amount */
23
- bet: number;
24
- /** Action to simulate (default: auto-detect by binary) */
25
- action?: string;
26
- /** Action params (buy_bonus, ante_bet, etc.) */
27
- params?: Record<string, unknown>;
28
- /** Progress callback */
29
- onProgress?: (completed: number, total: number) => void;
30
- }
31
-
32
- export interface StageStats {
33
- totalWin: number;
34
- spinCount: number;
35
- hitCount: number;
36
- maxWin: number;
37
- rtp: number;
38
- perSpinRtp: number;
39
- hitFrequency: number;
40
- avgWin: number;
41
- }
42
-
43
- export interface DistributionBucket {
44
- label: string;
45
- count: number;
46
- pct: number;
47
- }
48
-
49
- export interface NativeSimulationResult extends SimulationResult {
50
- /** Iterations per second */
51
- speed?: number;
52
- /** Number of parallel workers used */
53
- workersUsed?: number;
54
- /** Per-stage breakdown */
55
- perStage?: Record<string, StageStats>;
56
- /** Win distribution histogram */
57
- winDistribution?: DistributionBucket[];
58
- }
59
-
60
- // ─── Go JSON output shape (snake_case) ──────────────────
61
-
62
- interface GoSimulationOutput {
63
- game_id: string;
64
- speed: number;
65
- total_rtp: number;
66
- hit_frequency: number;
67
- max_win: number;
68
- max_win_hits: number;
69
- total_bet: number;
70
- total_win: number;
71
- iterations: number;
72
- workers_used: number;
73
- duration_sec: number;
74
- bonus_triggered: number;
75
- bonus_spins_total: number;
76
- per_stage_stats?: Record<string, {
77
- total_win: number;
78
- spin_count: number;
79
- hit_count: number;
80
- max_win: number;
81
- rtp: number;
82
- per_spin_rtp: number;
83
- hit_frequency: number;
84
- avg_win: number;
85
- }>;
86
- win_distribution?: Array<{
87
- label: string;
88
- count: number;
89
- pct: number;
90
- }>;
91
- }
92
-
93
- // ─── Runner ─────────────────────────────────────────────
94
-
95
- export class NativeSimulationRunner {
96
- private config: NativeSimulationConfig;
97
-
98
- constructor(config: NativeSimulationConfig) {
99
- this.config = config;
100
- }
101
-
102
- async run(): Promise<NativeSimulationResult> {
103
- const { binaryPath, script, gameDefinition, iterations, bet, action, params } = this.config;
104
- const id = randomBytes(8).toString('hex');
105
- const tmpDir = tmpdir();
106
- const luaPath = join(tmpDir, `sim-${id}.lua`);
107
- const configPath = join(tmpDir, `sim-${id}.json`);
108
-
109
- try {
110
- // Write temp files
111
- await Promise.all([
112
- writeFile(luaPath, script, 'utf-8'),
113
- writeFile(configPath, JSON.stringify({ ...gameDefinition, script_path: luaPath }), 'utf-8'),
114
- ]);
115
-
116
- // Build CLI args
117
- const args = [
118
- '-config', configPath,
119
- '-iterations', String(iterations),
120
- '-bet', String(bet),
121
- '-format', 'json',
122
- ];
123
- if (action) {
124
- args.push('-action', action);
125
- }
126
- if (params && Object.keys(params).length > 0) {
127
- args.push('-params', JSON.stringify(params));
128
- }
129
-
130
- // Execute binary
131
- const output = await this.exec(binaryPath, args);
132
-
133
- // Parse JSON output
134
- const json: GoSimulationOutput = JSON.parse(output);
135
- return mapGoResult(json);
136
- } finally {
137
- // Cleanup temp files
138
- await Promise.allSettled([unlink(luaPath), unlink(configPath)]);
139
- }
140
- }
141
-
142
- private exec(binary: string, args: string[]): Promise<string> {
143
- return new Promise((resolve, reject) => {
144
- const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
145
-
146
- let stdout = '';
147
- let stderr = '';
148
-
149
- child.stdout.on('data', (chunk: Buffer) => {
150
- stdout += chunk.toString();
151
- });
152
-
153
- child.stderr.on('data', (chunk: Buffer) => {
154
- stderr += chunk.toString();
155
- });
156
-
157
- child.on('error', (err) => {
158
- reject(new Error(`Failed to execute simulation binary: ${err.message}`));
159
- });
160
-
161
- child.on('close', (code) => {
162
- if (code !== 0) {
163
- reject(new Error(`Simulation binary exited with code ${code}: ${stderr.trim()}`));
164
- } else {
165
- resolve(stdout);
166
- }
167
- });
168
- });
169
- }
170
- }
171
-
172
- // ─── Result mapping ─────────────────────────────────────
173
-
174
- function mapGoResult(json: GoSimulationOutput): NativeSimulationResult {
175
- const baseStage = json.per_stage_stats?.base_game;
176
- const baseGameRtp = baseStage?.rtp ?? 0;
177
- const baseGameWin = baseStage?.total_win ?? 0;
178
-
179
- const perStage = json.per_stage_stats
180
- ? Object.fromEntries(
181
- Object.entries(json.per_stage_stats).map(([key, s]) => [
182
- key,
183
- {
184
- totalWin: s.total_win,
185
- spinCount: s.spin_count,
186
- hitCount: s.hit_count,
187
- maxWin: s.max_win,
188
- rtp: s.rtp,
189
- perSpinRtp: s.per_spin_rtp,
190
- hitFrequency: s.hit_frequency,
191
- avgWin: s.avg_win,
192
- },
193
- ]),
194
- )
195
- : undefined;
196
-
197
- return {
198
- gameId: json.game_id,
199
- action: 'spin',
200
- iterations: json.iterations,
201
- durationMs: Math.round(json.duration_sec * 1000),
202
- totalRtp: json.total_rtp,
203
- baseGameRtp,
204
- bonusRtp: json.total_rtp - baseGameRtp,
205
- hitFrequency: json.hit_frequency,
206
- maxWin: json.max_win,
207
- maxWinHits: json.max_win_hits,
208
- bonusTriggered: json.bonus_triggered,
209
- bonusSpinsPlayed: json.bonus_spins_total,
210
- speed: json.speed,
211
- workersUsed: json.workers_used,
212
- perStage,
213
- winDistribution: json.win_distribution,
214
- _raw: {
215
- totalWagered: json.total_bet,
216
- totalWon: json.total_win,
217
- baseGameWin,
218
- bonusWin: json.total_win - baseGameWin,
219
- hits: json.iterations > 0 ? Math.round((json.hit_frequency * json.iterations) / 100) : 0,
220
- },
221
- };
222
- }
223
-
224
- // ─── Binary discovery ───────────────────────────────────
225
-
226
- /**
227
- * Search for a native simulation binary in standard locations.
228
- * Returns the absolute path if found, null otherwise.
229
- */
230
- export function findNativeBinary(baseDir?: string): string | null {
231
- // 1. Explicit env var
232
- const envPath = process.env.SIMULATE_BINARY;
233
- if (envPath && isExecutable(envPath)) {
234
- return envPath;
235
- }
236
-
237
- const platform = process.platform; // darwin, linux, win32
238
- const nodeArch = process.arch; // arm64, x64
239
- const goArch = nodeArch === 'x64' ? 'amd64' : nodeArch;
240
- const goPlatform = platform === 'win32' ? 'windows' : platform;
241
- const ext = platform === 'win32' ? '.exe' : '';
242
-
243
- const names = [
244
- `simulate-${goPlatform}-${goArch}${ext}`,
245
- `simulation-${goPlatform}-${goArch}${ext}`,
246
- `simulate${ext}`,
247
- `simulation${ext}`,
248
- ];
249
-
250
- // Search directories: user's project first, then this package's bin/
251
- const searchDirs: string[] = [];
252
- if (baseDir) searchDirs.push(baseDir);
253
-
254
- // This package's root (where postinstall downloads the binary)
255
- try {
256
- const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
257
- if (!searchDirs.includes(pkgRoot)) searchDirs.push(pkgRoot);
258
- } catch {
259
- // fallback for CJS
260
- if (typeof __dirname !== 'undefined') {
261
- const pkgRoot = join(__dirname, '..');
262
- if (!searchDirs.includes(pkgRoot)) searchDirs.push(pkgRoot);
263
- }
264
- }
265
-
266
- for (const dir of searchDirs) {
267
- for (const name of names) {
268
- const candidate = join(dir, 'bin', name);
269
- if (isExecutable(candidate)) return candidate;
270
- }
271
- }
272
-
273
- // Check $PATH
274
- for (const bin of ['simulate', 'simulation']) {
275
- try {
276
- const cmd = platform === 'win32' ? `where ${bin}` : `which ${bin}`;
277
- const result = execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
278
- if (result) return result.split('\n')[0];
279
- } catch {
280
- // not found
281
- }
282
- }
283
-
284
- return null;
285
- }
286
-
287
- function isExecutable(path: string): boolean {
288
- try {
289
- accessSync(path, fsConstants.X_OK);
290
- return true;
291
- } catch {
292
- return false;
293
- }
294
- }
295
-
296
- // ─── Extended formatting ────────────────────────────────
297
-
298
- /** Format a NativeSimulationResult with per-stage and distribution data */
299
- export function formatNativeResult(result: NativeSimulationResult): string {
300
- const lines: string[] = [
301
- '',
302
- '--- Simulation Results ---',
303
- `Game: ${result.gameId}`,
304
- `Iterations: ${result.iterations.toLocaleString()}`,
305
- `Duration: ${(result.durationMs / 1000).toFixed(1)}s`,
306
- ];
307
-
308
- if (result.speed) {
309
- lines.push(`Speed: ${Math.round(result.speed).toLocaleString()} iterations/sec`);
310
- }
311
- if (result.workersUsed) {
312
- lines.push(`Workers: ${result.workersUsed}`);
313
- }
314
-
315
- lines.push(
316
- '',
317
- '--- Total ---',
318
- `Total RTP: ${result.totalRtp.toFixed(2)}%`,
319
- `Base Game RTP: ${result.baseGameRtp.toFixed(2)}%`,
320
- `Bonus RTP: ${result.bonusRtp.toFixed(2)}%`,
321
- `Hit Frequency: ${result.hitFrequency.toFixed(2)}%`,
322
- `Max Win: ${result.maxWin.toFixed(2)}x`,
323
- `Max Win Cap Hits: ${result.maxWinHits}`,
324
- );
325
-
326
- if (result.bonusTriggered > 0) {
327
- const frequency = Math.round(result.iterations / result.bonusTriggered);
328
- lines.push(
329
- '',
330
- '--- Bonus Stats ---',
331
- `Bonus Triggered: ${result.bonusTriggered.toLocaleString()} (1 in ${frequency} spins)`,
332
- `Bonus Spins Total: ${result.bonusSpinsPlayed.toLocaleString()}`,
333
- );
334
- }
335
-
336
- // Per-stage breakdown
337
- if (result.perStage && Object.keys(result.perStage).length > 0) {
338
- lines.push('', '--- Per-Stage Breakdown ---');
339
- const header = 'Stage | Spins | RTP (contrib) | Per-Spin RTP | Hit Freq | Avg Win | Max Win';
340
- lines.push(header);
341
- lines.push('-'.repeat(header.length));
342
-
343
- for (const [stage, stats] of Object.entries(result.perStage)) {
344
- lines.push(
345
- `${stage.padEnd(20)} | ${String(stats.spinCount).padStart(10)} | ` +
346
- `${stats.rtp.toFixed(2).padStart(12)}% | ` +
347
- `${stats.perSpinRtp.toFixed(2).padStart(11)}% | ` +
348
- `${stats.hitFrequency.toFixed(2).padStart(8)}% | ` +
349
- `${stats.avgWin.toFixed(3).padStart(8)}x | ` +
350
- `${stats.maxWin.toFixed(2).padStart(8)}x`,
351
- );
352
- }
353
- }
354
-
355
- // Win distribution
356
- if (result.winDistribution && result.winDistribution.length > 0) {
357
- lines.push('', '--- Win Distribution ---');
358
- for (const bucket of result.winDistribution) {
359
- const bar = '█'.repeat(Math.round(bucket.pct / 2));
360
- lines.push(
361
- `${bucket.label.padEnd(10)} ${String(bucket.count).padStart(10)} (${bucket.pct.toFixed(2).padStart(6)}%) ${bar}`,
362
- );
363
- }
364
- }
365
-
366
- return lines.join('\n');
367
- }