@energy8platform/game-engine 0.12.0 → 0.13.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/bin/simulate.ts +45 -5
- package/dist/lua.cjs.js +237 -0
- package/dist/lua.cjs.js.map +1 -1
- package/dist/lua.d.ts +59 -2
- package/dist/lua.esm.js +236 -2
- package/dist/lua.esm.js.map +1 -1
- package/package.json +4 -2
- package/scripts/install-simulate.mjs +100 -0
- package/src/lua/NativeSimulationRunner.ts +367 -0
- package/src/lua/index.ts +7 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { writeFile, unlink } from 'fs/promises';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { randomBytes } from 'crypto';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import type { GameDefinition, SimulationResult } from './types';
|
|
9
|
+
|
|
10
|
+
// ─── Types ──────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface NativeSimulationConfig {
|
|
13
|
+
/** Path to native simulation binary */
|
|
14
|
+
binaryPath: string;
|
|
15
|
+
/** Lua script source code */
|
|
16
|
+
script: string;
|
|
17
|
+
/** Platform game definition */
|
|
18
|
+
gameDefinition: GameDefinition;
|
|
19
|
+
/** Number of iterations */
|
|
20
|
+
iterations: number;
|
|
21
|
+
/** Bet amount */
|
|
22
|
+
bet: number;
|
|
23
|
+
/** Action to simulate (default: auto-detect by binary) */
|
|
24
|
+
action?: string;
|
|
25
|
+
/** Action params (buy_bonus, ante_bet, etc.) */
|
|
26
|
+
params?: Record<string, unknown>;
|
|
27
|
+
/** Progress callback */
|
|
28
|
+
onProgress?: (completed: number, total: number) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StageStats {
|
|
32
|
+
totalWin: number;
|
|
33
|
+
spinCount: number;
|
|
34
|
+
hitCount: number;
|
|
35
|
+
maxWin: number;
|
|
36
|
+
rtp: number;
|
|
37
|
+
perSpinRtp: number;
|
|
38
|
+
hitFrequency: number;
|
|
39
|
+
avgWin: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DistributionBucket {
|
|
43
|
+
label: string;
|
|
44
|
+
count: number;
|
|
45
|
+
pct: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface NativeSimulationResult extends SimulationResult {
|
|
49
|
+
/** Iterations per second */
|
|
50
|
+
speed?: number;
|
|
51
|
+
/** Number of parallel workers used */
|
|
52
|
+
workersUsed?: number;
|
|
53
|
+
/** Per-stage breakdown */
|
|
54
|
+
perStage?: Record<string, StageStats>;
|
|
55
|
+
/** Win distribution histogram */
|
|
56
|
+
winDistribution?: DistributionBucket[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Go JSON output shape (snake_case) ──────────────────
|
|
60
|
+
|
|
61
|
+
interface GoSimulationOutput {
|
|
62
|
+
game_id: string;
|
|
63
|
+
speed: number;
|
|
64
|
+
total_rtp: number;
|
|
65
|
+
hit_frequency: number;
|
|
66
|
+
max_win: number;
|
|
67
|
+
max_win_hits: number;
|
|
68
|
+
total_bet: number;
|
|
69
|
+
total_win: number;
|
|
70
|
+
iterations: number;
|
|
71
|
+
workers_used: number;
|
|
72
|
+
duration_sec: number;
|
|
73
|
+
bonus_triggered: number;
|
|
74
|
+
bonus_spins_total: number;
|
|
75
|
+
per_stage_stats?: Record<string, {
|
|
76
|
+
total_win: number;
|
|
77
|
+
spin_count: number;
|
|
78
|
+
hit_count: number;
|
|
79
|
+
max_win: number;
|
|
80
|
+
rtp: number;
|
|
81
|
+
per_spin_rtp: number;
|
|
82
|
+
hit_frequency: number;
|
|
83
|
+
avg_win: number;
|
|
84
|
+
}>;
|
|
85
|
+
win_distribution?: Array<{
|
|
86
|
+
label: string;
|
|
87
|
+
count: number;
|
|
88
|
+
pct: number;
|
|
89
|
+
}>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Runner ─────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export class NativeSimulationRunner {
|
|
95
|
+
private config: NativeSimulationConfig;
|
|
96
|
+
|
|
97
|
+
constructor(config: NativeSimulationConfig) {
|
|
98
|
+
this.config = config;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async run(): Promise<NativeSimulationResult> {
|
|
102
|
+
const { binaryPath, script, gameDefinition, iterations, bet, action, params } = this.config;
|
|
103
|
+
const id = randomBytes(8).toString('hex');
|
|
104
|
+
const tmpDir = tmpdir();
|
|
105
|
+
const luaPath = join(tmpDir, `sim-${id}.lua`);
|
|
106
|
+
const configPath = join(tmpDir, `sim-${id}.json`);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
// Write temp files
|
|
110
|
+
await Promise.all([
|
|
111
|
+
writeFile(luaPath, script, 'utf-8'),
|
|
112
|
+
writeFile(configPath, JSON.stringify({ ...gameDefinition, script_path: luaPath }), 'utf-8'),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
// Build CLI args
|
|
116
|
+
const args = [
|
|
117
|
+
'-config', configPath,
|
|
118
|
+
'-iterations', String(iterations),
|
|
119
|
+
'-bet', String(bet),
|
|
120
|
+
'-format', 'json',
|
|
121
|
+
];
|
|
122
|
+
if (action) {
|
|
123
|
+
args.push('-action', action);
|
|
124
|
+
}
|
|
125
|
+
if (params && Object.keys(params).length > 0) {
|
|
126
|
+
args.push('-params', JSON.stringify(params));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Execute binary
|
|
130
|
+
const output = await this.exec(binaryPath, args);
|
|
131
|
+
|
|
132
|
+
// Parse JSON output
|
|
133
|
+
const json: GoSimulationOutput = JSON.parse(output);
|
|
134
|
+
return mapGoResult(json);
|
|
135
|
+
} finally {
|
|
136
|
+
// Cleanup temp files
|
|
137
|
+
await Promise.allSettled([unlink(luaPath), unlink(configPath)]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private exec(binary: string, args: string[]): Promise<string> {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
144
|
+
|
|
145
|
+
let stdout = '';
|
|
146
|
+
let stderr = '';
|
|
147
|
+
|
|
148
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
149
|
+
stdout += chunk.toString();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
153
|
+
stderr += chunk.toString();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
child.on('error', (err) => {
|
|
157
|
+
reject(new Error(`Failed to execute simulation binary: ${err.message}`));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
child.on('close', (code) => {
|
|
161
|
+
if (code !== 0) {
|
|
162
|
+
reject(new Error(`Simulation binary exited with code ${code}: ${stderr.trim()}`));
|
|
163
|
+
} else {
|
|
164
|
+
resolve(stdout);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Result mapping ─────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
function mapGoResult(json: GoSimulationOutput): NativeSimulationResult {
|
|
174
|
+
const baseStage = json.per_stage_stats?.base_game;
|
|
175
|
+
const baseGameRtp = baseStage?.rtp ?? 0;
|
|
176
|
+
const baseGameWin = baseStage?.total_win ?? 0;
|
|
177
|
+
|
|
178
|
+
const perStage = json.per_stage_stats
|
|
179
|
+
? Object.fromEntries(
|
|
180
|
+
Object.entries(json.per_stage_stats).map(([key, s]) => [
|
|
181
|
+
key,
|
|
182
|
+
{
|
|
183
|
+
totalWin: s.total_win,
|
|
184
|
+
spinCount: s.spin_count,
|
|
185
|
+
hitCount: s.hit_count,
|
|
186
|
+
maxWin: s.max_win,
|
|
187
|
+
rtp: s.rtp,
|
|
188
|
+
perSpinRtp: s.per_spin_rtp,
|
|
189
|
+
hitFrequency: s.hit_frequency,
|
|
190
|
+
avgWin: s.avg_win,
|
|
191
|
+
},
|
|
192
|
+
]),
|
|
193
|
+
)
|
|
194
|
+
: undefined;
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
gameId: json.game_id,
|
|
198
|
+
action: 'spin',
|
|
199
|
+
iterations: json.iterations,
|
|
200
|
+
durationMs: Math.round(json.duration_sec * 1000),
|
|
201
|
+
totalRtp: json.total_rtp,
|
|
202
|
+
baseGameRtp,
|
|
203
|
+
bonusRtp: json.total_rtp - baseGameRtp,
|
|
204
|
+
hitFrequency: json.hit_frequency,
|
|
205
|
+
maxWin: json.max_win,
|
|
206
|
+
maxWinHits: json.max_win_hits,
|
|
207
|
+
bonusTriggered: json.bonus_triggered,
|
|
208
|
+
bonusSpinsPlayed: json.bonus_spins_total,
|
|
209
|
+
speed: json.speed,
|
|
210
|
+
workersUsed: json.workers_used,
|
|
211
|
+
perStage,
|
|
212
|
+
winDistribution: json.win_distribution,
|
|
213
|
+
_raw: {
|
|
214
|
+
totalWagered: json.total_bet,
|
|
215
|
+
totalWon: json.total_win,
|
|
216
|
+
baseGameWin,
|
|
217
|
+
bonusWin: json.total_win - baseGameWin,
|
|
218
|
+
hits: json.iterations > 0 ? Math.round((json.hit_frequency * json.iterations) / 100) : 0,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Binary discovery ───────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Search for a native simulation binary in standard locations.
|
|
227
|
+
* Returns the absolute path if found, null otherwise.
|
|
228
|
+
*/
|
|
229
|
+
export function findNativeBinary(baseDir?: string): string | null {
|
|
230
|
+
// 1. Explicit env var
|
|
231
|
+
const envPath = process.env.SIMULATE_BINARY;
|
|
232
|
+
if (envPath && isExecutable(envPath)) {
|
|
233
|
+
return envPath;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const platform = process.platform; // darwin, linux, win32
|
|
237
|
+
const nodeArch = process.arch; // arm64, x64
|
|
238
|
+
const goArch = nodeArch === 'x64' ? 'amd64' : nodeArch;
|
|
239
|
+
const goPlatform = platform === 'win32' ? 'windows' : platform;
|
|
240
|
+
const ext = platform === 'win32' ? '.exe' : '';
|
|
241
|
+
|
|
242
|
+
const names = [
|
|
243
|
+
`simulate-${goPlatform}-${goArch}${ext}`,
|
|
244
|
+
`simulation-${goPlatform}-${goArch}${ext}`,
|
|
245
|
+
`simulate${ext}`,
|
|
246
|
+
`simulation${ext}`,
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
// Search directories: user's project first, then this package's bin/
|
|
250
|
+
const searchDirs: string[] = [];
|
|
251
|
+
if (baseDir) searchDirs.push(baseDir);
|
|
252
|
+
|
|
253
|
+
// This package's root (where postinstall downloads the binary)
|
|
254
|
+
try {
|
|
255
|
+
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
256
|
+
if (!searchDirs.includes(pkgRoot)) searchDirs.push(pkgRoot);
|
|
257
|
+
} catch {
|
|
258
|
+
// fallback for CJS
|
|
259
|
+
if (typeof __dirname !== 'undefined') {
|
|
260
|
+
const pkgRoot = join(__dirname, '..');
|
|
261
|
+
if (!searchDirs.includes(pkgRoot)) searchDirs.push(pkgRoot);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const dir of searchDirs) {
|
|
266
|
+
for (const name of names) {
|
|
267
|
+
const candidate = join(dir, 'bin', name);
|
|
268
|
+
if (isExecutable(candidate)) return candidate;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Check $PATH
|
|
273
|
+
for (const bin of ['simulate', 'simulation']) {
|
|
274
|
+
try {
|
|
275
|
+
const cmd = platform === 'win32' ? `where ${bin}` : `which ${bin}`;
|
|
276
|
+
const result = execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
|
|
277
|
+
if (result) return result.split('\n')[0];
|
|
278
|
+
} catch {
|
|
279
|
+
// not found
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isExecutable(path: string): boolean {
|
|
287
|
+
try {
|
|
288
|
+
const { accessSync, constants } = require('fs');
|
|
289
|
+
accessSync(path, constants.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
|
+
}
|
package/src/lua/index.ts
CHANGED
|
@@ -5,6 +5,13 @@ export { SessionManager } from './SessionManager';
|
|
|
5
5
|
export { PersistentState } from './PersistentState';
|
|
6
6
|
export { SimulationRunner, formatSimulationResult } from './SimulationRunner';
|
|
7
7
|
export { ParallelSimulationRunner } from './ParallelSimulationRunner';
|
|
8
|
+
export { NativeSimulationRunner, findNativeBinary, formatNativeResult } from './NativeSimulationRunner';
|
|
9
|
+
export type {
|
|
10
|
+
NativeSimulationConfig,
|
|
11
|
+
NativeSimulationResult,
|
|
12
|
+
StageStats,
|
|
13
|
+
DistributionBucket,
|
|
14
|
+
} from './NativeSimulationRunner';
|
|
8
15
|
export type {
|
|
9
16
|
GameDefinition,
|
|
10
17
|
ActionDefinition,
|