@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.
@@ -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
- const stateVars: Record<string, number> = {
107
- ...DEFAULT_VARIABLES,
108
- ...this.variables,
109
- bet,
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: Record<string, unknown> = { ...clientParams };
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
- variables: { ...this.variables },
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, to_luastring('execute'));
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, to_luastring('execute'));
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, to_luastring(stage));
316
- lua.lua_setfield(this.L, -2, to_luastring('stage'));
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, to_luastring(action));
320
- lua.lua_setfield(this.L, -2, to_luastring('action'));
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, to_luastring('params'));
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, to_luastring('variables'));
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, to_luastring('total_win'));
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, to_luastring('variables'));
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
- lua.lua_pushnil(this.L);
353
- while (lua.lua_next(this.L, -2) !== 0) {
354
- if (lua.lua_type(this.L, -2) === lua.LUA_TSTRING) {
355
- const key = to_jsstring(lua.lua_tostring(this.L, -2));
356
- if (key.startsWith('_persist_')) {
357
- result[key] = luaToJS(this.L, -1);
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);
@@ -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, to_luastring(value));
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, to_luastring(key));
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
- roundWin += roundBonusWin;
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 };