@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.
- package/README.md +22 -12
- package/dist/assets.d.ts +2 -14
- package/dist/core.cjs.js +16 -201
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +4 -35
- package/dist/core.esm.js +12 -197
- package/dist/core.esm.js.map +1 -1
- package/dist/debug.cjs.js +5 -233
- package/dist/debug.cjs.js.map +1 -1
- package/dist/debug.d.ts +2 -140
- package/dist/debug.esm.js +2 -233
- package/dist/debug.esm.js.map +1 -1
- package/dist/index.cjs.js +21 -432
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +10 -195
- package/dist/index.esm.js +14 -429
- package/dist/index.esm.js.map +1 -1
- package/dist/lua.cjs.js +7 -1493
- package/dist/lua.cjs.js.map +1 -1
- package/dist/lua.d.ts +1 -403
- package/dist/lua.esm.js +1 -1483
- package/dist/lua.esm.js.map +1 -1
- package/dist/react.cjs.js.map +1 -1
- package/dist/react.d.ts +4 -35
- package/dist/react.esm.js.map +1 -1
- package/dist/vite.cjs.js +19 -175
- package/dist/vite.cjs.js.map +1 -1
- package/dist/vite.d.ts +8 -3
- package/dist/vite.esm.js +10 -173
- package/dist/vite.esm.js.map +1 -1
- package/package.json +6 -16
- package/src/core/GameApplication.ts +14 -18
- package/src/debug/index.ts +4 -2
- package/src/index.ts +3 -3
- package/src/loading/LoadingScene.ts +1 -1
- package/src/loading/index.ts +9 -2
- package/src/lua/index.ts +3 -31
- package/src/types.ts +16 -41
- package/src/vite/index.ts +13 -196
- package/bin/simulate.ts +0 -139
- package/scripts/install-simulate.mjs +0 -101
- package/src/debug/DevBridge.ts +0 -305
- package/src/loading/CSSPreloader.ts +0 -129
- package/src/loading/logo.ts +0 -95
- package/src/lua/ActionRouter.ts +0 -132
- package/src/lua/LuaEngine.ts +0 -412
- package/src/lua/LuaEngineAPI.ts +0 -314
- package/src/lua/NativeSimulationRunner.ts +0 -367
- package/src/lua/ParallelSimulationRunner.ts +0 -156
- package/src/lua/PersistentState.ts +0 -80
- package/src/lua/SessionManager.ts +0 -227
- package/src/lua/SimulationRunner.ts +0 -192
- package/src/lua/SimulationWorker.ts +0 -44
- package/src/lua/fengari.d.ts +0 -10
- package/src/lua/types.ts +0 -149
package/src/lua/LuaEngineAPI.ts
DELETED
|
@@ -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
|
-
}
|