@energy8platform/game-engine 0.10.9 → 0.10.11
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/bin/simulate.ts +38 -15
- package/dist/lua.cjs.js +201 -30
- package/dist/lua.cjs.js.map +1 -1
- package/dist/lua.d.ts +45 -2
- package/dist/lua.esm.js +200 -31
- package/dist/lua.esm.js.map +1 -1
- package/package.json +2 -1
- package/src/lua/LuaEngine.ts +45 -26
- package/src/lua/LuaEngineAPI.ts +14 -2
- package/src/lua/ParallelSimulationRunner.ts +156 -0
- package/src/lua/SimulationRunner.ts +8 -3
- package/src/lua/SimulationWorker.ts +44 -0
- package/src/lua/index.ts +2 -0
- package/src/lua/types.ts +12 -0
package/src/lua/LuaEngine.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PlayParams, SessionData } from '@energy8platform/game-sdk';
|
|
2
2
|
import type { LuaEngineConfig, LuaPlayResult, GameDefinition } from './types';
|
|
3
|
-
import { LuaEngineAPI, createSeededRng, luaToJS, pushJSValue } from './LuaEngineAPI';
|
|
3
|
+
import { LuaEngineAPI, createSeededRng, luaToJS, pushJSValue, cachedToLuastring } from './LuaEngineAPI';
|
|
4
4
|
import { ActionRouter } from './ActionRouter';
|
|
5
5
|
import { SessionManager } from './SessionManager';
|
|
6
6
|
import { PersistentState } from './PersistentState';
|
|
@@ -36,6 +36,11 @@ export class LuaEngine {
|
|
|
36
36
|
private gameDefinition: GameDefinition;
|
|
37
37
|
private variables: Record<string, number> = {};
|
|
38
38
|
private simulationMode: boolean;
|
|
39
|
+
/** Reusable state objects to avoid per-iteration allocation */
|
|
40
|
+
private _stateVars: Record<string, number> = {};
|
|
41
|
+
private _stateParams: Record<string, unknown> = {};
|
|
42
|
+
/** Whether the game uses _persist_ keys (detected on first run, cached) */
|
|
43
|
+
private _hasPersistKeys: boolean | undefined;
|
|
39
44
|
|
|
40
45
|
constructor(config: LuaEngineConfig) {
|
|
41
46
|
this.gameDefinition = config.gameDefinition;
|
|
@@ -103,11 +108,13 @@ export class LuaEngine {
|
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
// 3. Build state.variables (matching server's NewGameState + restore)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
// Reuse pooled object to avoid per-iteration allocation
|
|
112
|
+
const stateVars = this._stateVars;
|
|
113
|
+
// Clear previous keys
|
|
114
|
+
for (const key in stateVars) delete stateVars[key];
|
|
115
|
+
// Apply defaults, then engine vars, then bet
|
|
116
|
+
Object.assign(stateVars, DEFAULT_VARIABLES, this.variables);
|
|
117
|
+
stateVars.bet = bet;
|
|
111
118
|
|
|
112
119
|
// Load cross-spin persistent state
|
|
113
120
|
this.persistentState.loadIntoVariables(stateVars);
|
|
@@ -128,8 +135,10 @@ export class LuaEngine {
|
|
|
128
135
|
stateVars.free_spins_remaining = this.sessionManager.spinsRemaining;
|
|
129
136
|
}
|
|
130
137
|
|
|
131
|
-
// 4. Build state.params
|
|
132
|
-
const stateParams
|
|
138
|
+
// 4. Build state.params (reuse pooled object)
|
|
139
|
+
const stateParams = this._stateParams;
|
|
140
|
+
for (const key in stateParams) delete stateParams[key];
|
|
141
|
+
if (clientParams) Object.assign(stateParams, clientParams);
|
|
133
142
|
stateParams._action = actionName;
|
|
134
143
|
|
|
135
144
|
// Inject session _ps_* persistent data
|
|
@@ -264,7 +273,8 @@ export class LuaEngine {
|
|
|
264
273
|
data,
|
|
265
274
|
nextActions,
|
|
266
275
|
session,
|
|
267
|
-
|
|
276
|
+
// In simulation mode, return reference directly (caller only reads, never mutates)
|
|
277
|
+
variables: this.simulationMode ? this.variables : { ...this.variables },
|
|
268
278
|
creditDeferred,
|
|
269
279
|
};
|
|
270
280
|
}
|
|
@@ -292,7 +302,7 @@ export class LuaEngine {
|
|
|
292
302
|
throw new Error(`Failed to load Lua script: ${err}`);
|
|
293
303
|
}
|
|
294
304
|
|
|
295
|
-
lua.lua_getglobal(this.L,
|
|
305
|
+
lua.lua_getglobal(this.L, cachedToLuastring('execute'));
|
|
296
306
|
if (lua.lua_type(this.L, -1) !== lua.LUA_TFUNCTION) {
|
|
297
307
|
lua.lua_pop(this.L, 1);
|
|
298
308
|
throw new Error('Lua script must define a global `execute(state)` function');
|
|
@@ -306,26 +316,26 @@ export class LuaEngine {
|
|
|
306
316
|
params: Record<string, unknown>,
|
|
307
317
|
variables: Record<string, number>,
|
|
308
318
|
): Record<string, unknown> {
|
|
309
|
-
lua.lua_getglobal(this.L,
|
|
319
|
+
lua.lua_getglobal(this.L, cachedToLuastring('execute'));
|
|
310
320
|
|
|
311
321
|
// Build state table: {stage, action, params, variables}
|
|
312
322
|
lua.lua_createtable(this.L, 0, 4);
|
|
313
323
|
|
|
314
324
|
// state.stage
|
|
315
|
-
lua.lua_pushstring(this.L,
|
|
316
|
-
lua.lua_setfield(this.L, -2,
|
|
325
|
+
lua.lua_pushstring(this.L, cachedToLuastring(stage));
|
|
326
|
+
lua.lua_setfield(this.L, -2, cachedToLuastring('stage'));
|
|
317
327
|
|
|
318
328
|
// state.action (server sets this at top level)
|
|
319
|
-
lua.lua_pushstring(this.L,
|
|
320
|
-
lua.lua_setfield(this.L, -2,
|
|
329
|
+
lua.lua_pushstring(this.L, cachedToLuastring(action));
|
|
330
|
+
lua.lua_setfield(this.L, -2, cachedToLuastring('action'));
|
|
321
331
|
|
|
322
332
|
// state.params
|
|
323
333
|
pushJSValue(this.L, params);
|
|
324
|
-
lua.lua_setfield(this.L, -2,
|
|
334
|
+
lua.lua_setfield(this.L, -2, cachedToLuastring('params'));
|
|
325
335
|
|
|
326
336
|
// state.variables
|
|
327
337
|
pushJSValue(this.L, variables);
|
|
328
|
-
lua.lua_setfield(this.L, -2,
|
|
338
|
+
lua.lua_setfield(this.L, -2, cachedToLuastring('variables'));
|
|
329
339
|
|
|
330
340
|
const status = lua.lua_pcall(this.L, 1, 1, 0);
|
|
331
341
|
if (status !== lua.LUA_OK) {
|
|
@@ -338,26 +348,35 @@ export class LuaEngine {
|
|
|
338
348
|
// Fast path: extract only total_win, variables, _persist_* keys
|
|
339
349
|
const result: Record<string, unknown> = {};
|
|
340
350
|
|
|
341
|
-
lua.lua_getfield(this.L, -1,
|
|
351
|
+
lua.lua_getfield(this.L, -1, cachedToLuastring('total_win'));
|
|
342
352
|
result.total_win = lua.lua_type(this.L, -1) === lua.LUA_TNUMBER
|
|
343
353
|
? lua.lua_tonumber(this.L, -1) : 0;
|
|
344
354
|
lua.lua_pop(this.L, 1);
|
|
345
355
|
|
|
346
|
-
lua.lua_getfield(this.L, -1,
|
|
356
|
+
lua.lua_getfield(this.L, -1, cachedToLuastring('variables'));
|
|
347
357
|
if (lua.lua_type(this.L, -1) === lua.LUA_TTABLE) {
|
|
348
358
|
result.variables = luaToJS(this.L, -1);
|
|
349
359
|
}
|
|
350
360
|
lua.lua_pop(this.L, 1);
|
|
351
361
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
362
|
+
// Scan for _persist_* keys only if the game might use them
|
|
363
|
+
if (this._hasPersistKeys !== false) {
|
|
364
|
+
let foundPersist = false;
|
|
365
|
+
lua.lua_pushnil(this.L);
|
|
366
|
+
while (lua.lua_next(this.L, -2) !== 0) {
|
|
367
|
+
if (lua.lua_type(this.L, -2) === lua.LUA_TSTRING) {
|
|
368
|
+
const key = to_jsstring(lua.lua_tostring(this.L, -2));
|
|
369
|
+
if (key.startsWith('_persist_')) {
|
|
370
|
+
result[key] = luaToJS(this.L, -1);
|
|
371
|
+
foundPersist = true;
|
|
372
|
+
}
|
|
358
373
|
}
|
|
374
|
+
lua.lua_pop(this.L, 1);
|
|
375
|
+
}
|
|
376
|
+
// After first iteration, cache whether persist keys exist
|
|
377
|
+
if (this._hasPersistKeys === undefined) {
|
|
378
|
+
this._hasPersistKeys = foundPersist;
|
|
359
379
|
}
|
|
360
|
-
lua.lua_pop(this.L, 1);
|
|
361
380
|
}
|
|
362
381
|
|
|
363
382
|
lua.lua_pop(this.L, 1);
|
package/src/lua/LuaEngineAPI.ts
CHANGED
|
@@ -6,6 +6,18 @@ const { to_luastring, to_jsstring } = fengari;
|
|
|
6
6
|
|
|
7
7
|
export type RngFunction = () => number;
|
|
8
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
|
+
|
|
9
21
|
/**
|
|
10
22
|
* Seeded xoshiro128** PRNG for deterministic simulation/replay.
|
|
11
23
|
* Period: 2^128 - 1
|
|
@@ -272,7 +284,7 @@ export function pushJSValue(L: any, value: unknown): void {
|
|
|
272
284
|
lua.lua_pushnumber(L, value);
|
|
273
285
|
}
|
|
274
286
|
} else if (typeof value === 'string') {
|
|
275
|
-
lua.lua_pushstring(L,
|
|
287
|
+
lua.lua_pushstring(L, cachedToLuastring(value));
|
|
276
288
|
} else if (Array.isArray(value)) {
|
|
277
289
|
pushJSArray(L, value);
|
|
278
290
|
} else if (typeof value === 'object') {
|
|
@@ -297,6 +309,6 @@ function pushJSObject(L: any, obj: Record<string, unknown>): void {
|
|
|
297
309
|
lua.lua_createtable(L, 0, keys.length);
|
|
298
310
|
for (const key of keys) {
|
|
299
311
|
pushJSValue(L, obj[key]);
|
|
300
|
-
lua.lua_setfield(L, -2,
|
|
312
|
+
lua.lua_setfield(L, -2, cachedToLuastring(key));
|
|
301
313
|
}
|
|
302
314
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { Worker } from 'worker_threads';
|
|
3
|
+
import { cpus } from 'os';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
import type { SimulationConfig, SimulationResult, SimulationRawAccumulators } from './types';
|
|
7
|
+
import type { WorkerMessage, WorkerConfig } from './SimulationWorker';
|
|
8
|
+
|
|
9
|
+
const SEED_STRIDE = 1 << 20; // 2^20 — gap between worker seeds to avoid overlap
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Runs simulation across multiple worker threads for parallel speedup.
|
|
13
|
+
* Each worker gets an independent LuaEngine with a partitioned seed range.
|
|
14
|
+
*
|
|
15
|
+
* Results are statistically equivalent to single-threaded mode but not
|
|
16
|
+
* bit-identical (different RNG sequence ordering).
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* const runner = new ParallelSimulationRunner({
|
|
21
|
+
* script: luaSource,
|
|
22
|
+
* gameDefinition,
|
|
23
|
+
* iterations: 1_000_000,
|
|
24
|
+
* bet: 1.0,
|
|
25
|
+
* workerCount: 8,
|
|
26
|
+
* onProgress: (done, total) => console.log(`${done}/${total}`),
|
|
27
|
+
* });
|
|
28
|
+
* const result = await runner.run();
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export class ParallelSimulationRunner {
|
|
32
|
+
private config: SimulationConfig;
|
|
33
|
+
private workerCount: number;
|
|
34
|
+
|
|
35
|
+
constructor(config: SimulationConfig) {
|
|
36
|
+
this.config = config;
|
|
37
|
+
const maxWorkers = cpus().length;
|
|
38
|
+
this.workerCount = Math.max(1, Math.min(
|
|
39
|
+
config.workerCount ?? maxWorkers,
|
|
40
|
+
maxWorkers,
|
|
41
|
+
config.iterations, // no point having more workers than iterations
|
|
42
|
+
));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async run(): Promise<SimulationResult> {
|
|
46
|
+
const {
|
|
47
|
+
iterations,
|
|
48
|
+
seed,
|
|
49
|
+
onProgress,
|
|
50
|
+
workerCount: _,
|
|
51
|
+
...restConfig
|
|
52
|
+
} = this.config;
|
|
53
|
+
|
|
54
|
+
const workerCount = this.workerCount;
|
|
55
|
+
|
|
56
|
+
// Split iterations evenly, remainder goes to last worker
|
|
57
|
+
const baseChunk = Math.floor(iterations / workerCount);
|
|
58
|
+
const remainder = iterations - baseChunk * workerCount;
|
|
59
|
+
|
|
60
|
+
const workerPath = join(dirname(fileURLToPath(import.meta.url)), 'SimulationWorker.ts');
|
|
61
|
+
|
|
62
|
+
const progressPerWorker = new Array<number>(workerCount).fill(0);
|
|
63
|
+
const totalIterations = iterations;
|
|
64
|
+
|
|
65
|
+
const promises = Array.from({ length: workerCount }, (_, i) => {
|
|
66
|
+
const workerIterations = baseChunk + (i < remainder ? 1 : 0);
|
|
67
|
+
const workerSeed = seed !== undefined ? seed + i * SEED_STRIDE : undefined;
|
|
68
|
+
|
|
69
|
+
const workerConfig: WorkerConfig = {
|
|
70
|
+
config: {
|
|
71
|
+
...restConfig,
|
|
72
|
+
iterations: workerIterations,
|
|
73
|
+
seed: workerSeed,
|
|
74
|
+
progressInterval: this.config.progressInterval,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return new Promise<SimulationResult>((resolve, reject) => {
|
|
79
|
+
const worker = new Worker(workerPath, {
|
|
80
|
+
workerData: workerConfig,
|
|
81
|
+
// tsx registers itself via --require/--import; pass through to workers
|
|
82
|
+
execArgv: process.execArgv,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
worker.on('message', (msg: WorkerMessage) => {
|
|
86
|
+
if (msg.type === 'progress' && onProgress) {
|
|
87
|
+
progressPerWorker[i] = msg.progress!.completed;
|
|
88
|
+
const totalCompleted = progressPerWorker.reduce((a, b) => a + b, 0);
|
|
89
|
+
onProgress(totalCompleted, totalIterations);
|
|
90
|
+
} else if (msg.type === 'result') {
|
|
91
|
+
resolve(msg.result!);
|
|
92
|
+
} else if (msg.type === 'error') {
|
|
93
|
+
reject(new Error(`Worker ${i} failed: ${msg.error}`));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
worker.on('error', (err: Error) => reject(new Error(`Worker ${i} error: ${err.message}`)));
|
|
98
|
+
worker.on('exit', (code) => {
|
|
99
|
+
if (code !== 0) reject(new Error(`Worker ${i} exited with code ${code}`));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const results = await Promise.all(promises);
|
|
105
|
+
return aggregateResults(results);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function aggregateResults(results: SimulationResult[]): SimulationResult {
|
|
110
|
+
const raw: SimulationRawAccumulators = {
|
|
111
|
+
totalWagered: 0,
|
|
112
|
+
totalWon: 0,
|
|
113
|
+
baseGameWin: 0,
|
|
114
|
+
bonusWin: 0,
|
|
115
|
+
hits: 0,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
let iterations = 0;
|
|
119
|
+
let maxWin = 0;
|
|
120
|
+
let maxWinHits = 0;
|
|
121
|
+
let bonusTriggered = 0;
|
|
122
|
+
let bonusSpinsPlayed = 0;
|
|
123
|
+
let maxDurationMs = 0;
|
|
124
|
+
|
|
125
|
+
for (const r of results) {
|
|
126
|
+
const rr = r._raw!;
|
|
127
|
+
raw.totalWagered += rr.totalWagered;
|
|
128
|
+
raw.totalWon += rr.totalWon;
|
|
129
|
+
raw.baseGameWin += rr.baseGameWin;
|
|
130
|
+
raw.bonusWin += rr.bonusWin;
|
|
131
|
+
raw.hits += rr.hits;
|
|
132
|
+
|
|
133
|
+
iterations += r.iterations;
|
|
134
|
+
if (r.maxWin > maxWin) maxWin = r.maxWin;
|
|
135
|
+
maxWinHits += r.maxWinHits;
|
|
136
|
+
bonusTriggered += r.bonusTriggered;
|
|
137
|
+
bonusSpinsPlayed += r.bonusSpinsPlayed;
|
|
138
|
+
if (r.durationMs > maxDurationMs) maxDurationMs = r.durationMs;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
gameId: results[0].gameId,
|
|
143
|
+
action: results[0].action,
|
|
144
|
+
iterations,
|
|
145
|
+
durationMs: maxDurationMs,
|
|
146
|
+
totalRtp: raw.totalWagered > 0 ? (raw.totalWon / raw.totalWagered) * 100 : 0,
|
|
147
|
+
baseGameRtp: raw.totalWagered > 0 ? (raw.baseGameWin / raw.totalWagered) * 100 : 0,
|
|
148
|
+
bonusRtp: raw.totalWagered > 0 ? (raw.bonusWin / raw.totalWagered) * 100 : 0,
|
|
149
|
+
hitFrequency: iterations > 0 ? (raw.hits / iterations) * 100 : 0,
|
|
150
|
+
maxWin,
|
|
151
|
+
maxWinHits,
|
|
152
|
+
bonusTriggered,
|
|
153
|
+
bonusSpinsPlayed,
|
|
154
|
+
_raw: raw,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -76,7 +76,6 @@ export class SimulationRunner {
|
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
const baseWin = result.totalWin;
|
|
79
|
-
roundWin += baseWin;
|
|
80
79
|
|
|
81
80
|
// If a session was created, play through it using nextActions from the engine
|
|
82
81
|
if (result.session && !result.session.completed) {
|
|
@@ -86,11 +85,16 @@ export class SimulationRunner {
|
|
|
86
85
|
while (result.session && !result.session.completed && safetyLimit-- > 0) {
|
|
87
86
|
const nextAction = result.nextActions[0];
|
|
88
87
|
result = engine.execute({ action: nextAction, bet });
|
|
89
|
-
roundBonusWin += result.totalWin;
|
|
90
88
|
bonusSpinsPlayed++;
|
|
91
89
|
}
|
|
92
90
|
|
|
93
|
-
|
|
91
|
+
// Session completion returns cumulative totalWin (includes trigger spin).
|
|
92
|
+
// Use it as the full round win — don't add baseWin separately.
|
|
93
|
+
roundWin = result.totalWin;
|
|
94
|
+
roundBonusWin = roundWin - baseWin;
|
|
95
|
+
} else {
|
|
96
|
+
// No session — just base game win
|
|
97
|
+
roundWin = baseWin;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
baseGameWin += baseWin;
|
|
@@ -132,6 +136,7 @@ export class SimulationRunner {
|
|
|
132
136
|
maxWinHits,
|
|
133
137
|
bonusTriggered,
|
|
134
138
|
bonusSpinsPlayed,
|
|
139
|
+
_raw: { totalWagered, totalWon, baseGameWin, bonusWin, hits },
|
|
135
140
|
};
|
|
136
141
|
}
|
|
137
142
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { parentPort, workerData } from 'worker_threads';
|
|
3
|
+
import { SimulationRunner } from './SimulationRunner';
|
|
4
|
+
import type { SimulationConfig, SimulationResult } from './types';
|
|
5
|
+
|
|
6
|
+
export interface WorkerMessage {
|
|
7
|
+
type: 'progress' | 'result' | 'error';
|
|
8
|
+
progress?: { completed: number; total: number };
|
|
9
|
+
result?: SimulationResult;
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WorkerConfig {
|
|
14
|
+
config: Omit<SimulationConfig, 'onProgress' | 'workerCount'>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function run() {
|
|
18
|
+
const { config } = workerData as WorkerConfig;
|
|
19
|
+
|
|
20
|
+
const runner = new SimulationRunner({
|
|
21
|
+
...config,
|
|
22
|
+
onProgress: (completed, total) => {
|
|
23
|
+
parentPort!.postMessage({
|
|
24
|
+
type: 'progress',
|
|
25
|
+
progress: { completed, total },
|
|
26
|
+
} satisfies WorkerMessage);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const result = runner.run();
|
|
32
|
+
parentPort!.postMessage({
|
|
33
|
+
type: 'result',
|
|
34
|
+
result,
|
|
35
|
+
} satisfies WorkerMessage);
|
|
36
|
+
} catch (e: any) {
|
|
37
|
+
parentPort!.postMessage({
|
|
38
|
+
type: 'error',
|
|
39
|
+
error: e.message ?? String(e),
|
|
40
|
+
} satisfies WorkerMessage);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
run();
|
package/src/lua/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { ActionRouter, evaluateCondition } from './ActionRouter';
|
|
|
4
4
|
export { SessionManager } from './SessionManager';
|
|
5
5
|
export { PersistentState } from './PersistentState';
|
|
6
6
|
export { SimulationRunner, formatSimulationResult } from './SimulationRunner';
|
|
7
|
+
export { ParallelSimulationRunner } from './ParallelSimulationRunner';
|
|
7
8
|
export type {
|
|
8
9
|
GameDefinition,
|
|
9
10
|
ActionDefinition,
|
|
@@ -19,4 +20,5 @@ export type {
|
|
|
19
20
|
BetLevelsConfig,
|
|
20
21
|
SimulationConfig,
|
|
21
22
|
SimulationResult,
|
|
23
|
+
SimulationRawAccumulators,
|
|
22
24
|
} from './types';
|
package/src/lua/types.ts
CHANGED
|
@@ -114,6 +114,8 @@ export interface SimulationConfig {
|
|
|
114
114
|
progressInterval?: number;
|
|
115
115
|
/** Progress callback */
|
|
116
116
|
onProgress?: (completed: number, total: number) => void;
|
|
117
|
+
/** Number of worker threads for parallel simulation (default: os.cpus().length) */
|
|
118
|
+
workerCount?: number;
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
export interface SimulationResult {
|
|
@@ -129,6 +131,16 @@ export interface SimulationResult {
|
|
|
129
131
|
maxWinHits: number;
|
|
130
132
|
bonusTriggered: number;
|
|
131
133
|
bonusSpinsPlayed: number;
|
|
134
|
+
/** Raw accumulators for aggregation across workers */
|
|
135
|
+
_raw?: SimulationRawAccumulators;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface SimulationRawAccumulators {
|
|
139
|
+
totalWagered: number;
|
|
140
|
+
totalWon: number;
|
|
141
|
+
baseGameWin: number;
|
|
142
|
+
bonusWin: number;
|
|
143
|
+
hits: number;
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
export type { SessionData, PlayParams };
|