@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.
- package/bin/simulate.ts +38 -15
- package/dist/lua.cjs.js +193 -27
- package/dist/lua.cjs.js.map +1 -1
- package/dist/lua.d.ts +45 -2
- package/dist/lua.esm.js +192 -28
- 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 +1 -0
- package/src/lua/SimulationWorker.ts +44 -0
- package/src/lua/index.ts +2 -0
- package/src/lua/types.ts +12 -0
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
|
+
}
|
|
@@ -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 };
|