@energy8platform/game-engine 0.10.9 → 0.10.10

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.
@@ -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
+ }
@@ -132,6 +132,7 @@ export class SimulationRunner {
132
132
  maxWinHits,
133
133
  bonusTriggered,
134
134
  bonusSpinsPlayed,
135
+ _raw: { totalWagered, totalWon, baseGameWin, bonusWin, hits },
135
136
  };
136
137
  }
137
138
 
@@ -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 };