@energy8platform/game-engine 0.15.2 → 0.16.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/README.md +22 -12
- package/dist/assets.d.ts +2 -14
- package/dist/core.cjs.js +16 -201
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +4 -35
- package/dist/core.esm.js +12 -197
- package/dist/core.esm.js.map +1 -1
- package/dist/debug.cjs.js +5 -233
- package/dist/debug.cjs.js.map +1 -1
- package/dist/debug.d.ts +2 -140
- package/dist/debug.esm.js +2 -233
- package/dist/debug.esm.js.map +1 -1
- package/dist/index.cjs.js +21 -432
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +10 -195
- package/dist/index.esm.js +14 -429
- package/dist/index.esm.js.map +1 -1
- package/dist/lua.cjs.js +7 -1493
- package/dist/lua.cjs.js.map +1 -1
- package/dist/lua.d.ts +1 -403
- package/dist/lua.esm.js +1 -1483
- package/dist/lua.esm.js.map +1 -1
- package/dist/react.cjs.js.map +1 -1
- package/dist/react.d.ts +4 -35
- package/dist/react.esm.js.map +1 -1
- package/dist/vite.cjs.js +19 -175
- package/dist/vite.cjs.js.map +1 -1
- package/dist/vite.d.ts +8 -3
- package/dist/vite.esm.js +10 -173
- package/dist/vite.esm.js.map +1 -1
- package/package.json +6 -16
- package/src/core/GameApplication.ts +14 -18
- package/src/debug/index.ts +4 -2
- package/src/index.ts +3 -3
- package/src/loading/LoadingScene.ts +1 -1
- package/src/loading/index.ts +9 -2
- package/src/lua/index.ts +3 -31
- package/src/types.ts +16 -41
- package/src/vite/index.ts +13 -196
- package/bin/simulate.ts +0 -139
- package/scripts/install-simulate.mjs +0 -101
- package/src/debug/DevBridge.ts +0 -305
- package/src/loading/CSSPreloader.ts +0 -129
- package/src/loading/logo.ts +0 -95
- package/src/lua/ActionRouter.ts +0 -132
- package/src/lua/LuaEngine.ts +0 -412
- package/src/lua/LuaEngineAPI.ts +0 -314
- package/src/lua/NativeSimulationRunner.ts +0 -367
- package/src/lua/ParallelSimulationRunner.ts +0 -156
- package/src/lua/PersistentState.ts +0 -80
- package/src/lua/SessionManager.ts +0 -227
- package/src/lua/SimulationRunner.ts +0 -192
- package/src/lua/SimulationWorker.ts +0 -44
- package/src/lua/fengari.d.ts +0 -10
- package/src/lua/types.ts +0 -149
|
@@ -1,156 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import type { PersistentStateConfig } from './types';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Manages cross-spin persistent state — variables that survive between base game spins.
|
|
5
|
-
* Separate from session-scoped persistence (handled by SessionManager).
|
|
6
|
-
*
|
|
7
|
-
* Handles two mechanisms:
|
|
8
|
-
* 1. Numeric vars declared in `persistent_state.vars` — stored in state.variables
|
|
9
|
-
* 2. Complex data with `_persist_game_*` prefix — stored separately, injected as `_ps_*`
|
|
10
|
-
*/
|
|
11
|
-
export class PersistentState {
|
|
12
|
-
private config: PersistentStateConfig | undefined;
|
|
13
|
-
private vars: Record<string, number> = {};
|
|
14
|
-
private gameData: Record<string, unknown> = {};
|
|
15
|
-
|
|
16
|
-
constructor(config?: PersistentStateConfig) {
|
|
17
|
-
this.config = config;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Load persistent vars into variables map before execute() */
|
|
21
|
-
loadIntoVariables(variables: Record<string, number>): void {
|
|
22
|
-
if (!this.config) return;
|
|
23
|
-
|
|
24
|
-
for (const varName of this.config.vars) {
|
|
25
|
-
if (varName in this.vars) {
|
|
26
|
-
variables[varName] = this.vars[varName];
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Save persistent vars from variables map after execute() */
|
|
32
|
-
saveFromVariables(variables: Record<string, number>): void {
|
|
33
|
-
if (!this.config) return;
|
|
34
|
-
|
|
35
|
-
for (const varName of this.config.vars) {
|
|
36
|
-
if (varName in variables) {
|
|
37
|
-
this.vars[varName] = variables[varName];
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Extract _persist_game_* keys from Lua return data, store them */
|
|
43
|
-
storeGameData(data: Record<string, unknown>): void {
|
|
44
|
-
for (const key of Object.keys(data)) {
|
|
45
|
-
if (key.startsWith('_persist_game_')) {
|
|
46
|
-
const cleanKey = key.slice('_persist_game_'.length);
|
|
47
|
-
this.gameData[cleanKey] = data[key];
|
|
48
|
-
delete data[key]; // remove from client data
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Get _ps_* params for next execute() call */
|
|
54
|
-
getGameDataParams(): Record<string, unknown> {
|
|
55
|
-
const params: Record<string, unknown> = {};
|
|
56
|
-
for (const [key, value] of Object.entries(this.gameData)) {
|
|
57
|
-
params[`_ps_${key}`] = value;
|
|
58
|
-
}
|
|
59
|
-
return params;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Get exposed vars for client data.persistent_state */
|
|
63
|
-
getExposedVars(): Record<string, number> | undefined {
|
|
64
|
-
if (!this.config?.exposed_vars?.length) return undefined;
|
|
65
|
-
|
|
66
|
-
const exposed: Record<string, number> = {};
|
|
67
|
-
for (const varName of this.config.exposed_vars) {
|
|
68
|
-
if (varName in this.vars) {
|
|
69
|
-
exposed[varName] = this.vars[varName];
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return exposed;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Reset all state */
|
|
76
|
-
reset(): void {
|
|
77
|
-
this.vars = {};
|
|
78
|
-
this.gameData = {};
|
|
79
|
-
}
|
|
80
|
-
}
|
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
import type { SessionData } from '@energy8platform/game-sdk';
|
|
2
|
-
import type { TransitionRule } from './types';
|
|
3
|
-
|
|
4
|
-
interface SessionState {
|
|
5
|
-
spinsRemaining: number;
|
|
6
|
-
spinsPlayed: number;
|
|
7
|
-
totalWin: number;
|
|
8
|
-
completed: boolean;
|
|
9
|
-
maxWinReached: boolean;
|
|
10
|
-
bet: number;
|
|
11
|
-
maxWinCap: number | undefined;
|
|
12
|
-
spinsVarName: string | undefined;
|
|
13
|
-
persistentVarNames: string[];
|
|
14
|
-
persistentVars: Record<string, number>;
|
|
15
|
-
persistentData: Record<string, unknown>;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const MAX_SESSION_SPINS = 200;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Manages session lifecycle matching the platform server behavior:
|
|
22
|
-
* - createSession: initial spin counted (spinsPlayed=1, totalWin=spinWin)
|
|
23
|
-
* - updateSession: accumulates win, decrements spins, checks max win cap on session level
|
|
24
|
-
* - completeSession: returns cumulative totalWin, cleans up session vars
|
|
25
|
-
* - Safety cap: 200 spins max per session
|
|
26
|
-
*/
|
|
27
|
-
export class SessionManager {
|
|
28
|
-
private session: SessionState | null = null;
|
|
29
|
-
|
|
30
|
-
get isActive(): boolean {
|
|
31
|
-
return this.session !== null && !this.session.completed;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
get current(): SessionData | null {
|
|
35
|
-
if (!this.session) return null;
|
|
36
|
-
return this.toSessionData();
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
get sessionTotalWin(): number {
|
|
40
|
-
return this.session?.totalWin ?? 0;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Get the fixed bet amount from the session (server uses session bet, not request bet) */
|
|
44
|
-
get sessionBet(): number | undefined {
|
|
45
|
-
return this.session?.bet;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Get spinsVarName to restore free_spins_remaining into variables */
|
|
49
|
-
get spinsVarName(): string | undefined {
|
|
50
|
-
return this.session?.spinsVarName;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
get spinsRemaining(): number {
|
|
54
|
-
return this.session?.spinsRemaining ?? 0;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Create a new session from a transition rule.
|
|
59
|
-
* Server behavior: initial spin is already counted (spinsPlayed=1, totalWin includes spinWin).
|
|
60
|
-
*/
|
|
61
|
-
createSession(
|
|
62
|
-
rule: TransitionRule,
|
|
63
|
-
variables: Record<string, number>,
|
|
64
|
-
bet: number,
|
|
65
|
-
spinWin: number,
|
|
66
|
-
maxWinCap: number | undefined,
|
|
67
|
-
): SessionData {
|
|
68
|
-
let spinsRemaining = -1;
|
|
69
|
-
let spinsVarName: string | undefined;
|
|
70
|
-
if (rule.session_config?.total_spins_var) {
|
|
71
|
-
spinsVarName = rule.session_config.total_spins_var;
|
|
72
|
-
spinsRemaining = variables[spinsVarName] ?? -1;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const persistentVarNames: string[] = rule.session_config?.persistent_vars ?? [];
|
|
76
|
-
const persistentVars: Record<string, number> = {};
|
|
77
|
-
for (const varName of persistentVarNames) {
|
|
78
|
-
persistentVars[varName] = variables[varName] ?? 0;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
this.session = {
|
|
82
|
-
spinsRemaining,
|
|
83
|
-
spinsPlayed: 1, // initial spin counts
|
|
84
|
-
totalWin: spinWin, // initial spin win included
|
|
85
|
-
completed: false,
|
|
86
|
-
maxWinReached: false,
|
|
87
|
-
bet,
|
|
88
|
-
maxWinCap,
|
|
89
|
-
spinsVarName,
|
|
90
|
-
persistentVarNames,
|
|
91
|
-
persistentVars,
|
|
92
|
-
persistentData: {},
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
return this.toSessionData();
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Update session after a bonus spin.
|
|
100
|
-
* Server behavior: accumulate win, decrement spins, check retrigger, check max win cap,
|
|
101
|
-
* safety cap at 200 spins.
|
|
102
|
-
*/
|
|
103
|
-
updateSession(
|
|
104
|
-
rule: TransitionRule,
|
|
105
|
-
variables: Record<string, number>,
|
|
106
|
-
spinWin: number,
|
|
107
|
-
): SessionData {
|
|
108
|
-
if (!this.session) throw new Error('No active session');
|
|
109
|
-
|
|
110
|
-
// Accumulate win and count spin
|
|
111
|
-
this.session.totalWin += spinWin;
|
|
112
|
-
this.session.spinsPlayed++;
|
|
113
|
-
|
|
114
|
-
// Decrement spins (only for non-unlimited sessions)
|
|
115
|
-
if (this.session.spinsRemaining > 0) {
|
|
116
|
-
this.session.spinsRemaining--;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Handle retrigger (add_spins_var)
|
|
120
|
-
if (rule.add_spins_var) {
|
|
121
|
-
const extraSpins = variables[rule.add_spins_var] ?? 0;
|
|
122
|
-
if (extraSpins > 0 && this.session.spinsRemaining >= 0) {
|
|
123
|
-
this.session.spinsRemaining += extraSpins;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Safety cap: server limits sessions to 200 spins
|
|
128
|
-
if (this.session.spinsPlayed >= MAX_SESSION_SPINS) {
|
|
129
|
-
this.session.spinsRemaining = 0;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Update session persistent vars from current variables
|
|
133
|
-
for (const varName of this.session.persistentVarNames) {
|
|
134
|
-
if (varName in variables) {
|
|
135
|
-
this.session.persistentVars[varName] = variables[varName];
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Check max win cap (on session level, not per spin)
|
|
140
|
-
if (this.session.maxWinCap !== undefined && this.session.totalWin >= this.session.maxWinCap) {
|
|
141
|
-
this.session.totalWin = this.session.maxWinCap;
|
|
142
|
-
this.session.spinsRemaining = 0;
|
|
143
|
-
this.session.maxWinReached = true;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Auto-complete if spins exhausted or explicit complete
|
|
147
|
-
if (this.session.spinsRemaining === 0 || rule.complete_session) {
|
|
148
|
-
this.session.completed = true;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return this.toSessionData();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Complete the session explicitly.
|
|
156
|
-
* Returns cumulative totalWin and list of session-scoped var names to clean up.
|
|
157
|
-
*/
|
|
158
|
-
completeSession(): { totalWin: number; session: SessionData; sessionVarNames: string[] } {
|
|
159
|
-
if (!this.session) throw new Error('No active session to complete');
|
|
160
|
-
|
|
161
|
-
this.session.completed = true;
|
|
162
|
-
const totalWin = this.session.totalWin;
|
|
163
|
-
const session = this.toSessionData();
|
|
164
|
-
const sessionVarNames = [...this.session.persistentVarNames];
|
|
165
|
-
|
|
166
|
-
this.session = null;
|
|
167
|
-
|
|
168
|
-
return { totalWin, session, sessionVarNames };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Mark max win reached — stops the session */
|
|
172
|
-
markMaxWinReached(): void {
|
|
173
|
-
if (this.session) {
|
|
174
|
-
this.session.maxWinReached = true;
|
|
175
|
-
this.session.completed = true;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Store _persist_* data extracted from Lua result */
|
|
180
|
-
storePersistData(data: Record<string, unknown>): void {
|
|
181
|
-
if (!this.session) return;
|
|
182
|
-
|
|
183
|
-
for (const key of Object.keys(data)) {
|
|
184
|
-
if (key.startsWith('_persist_')) {
|
|
185
|
-
const cleanKey = key.slice('_persist_'.length);
|
|
186
|
-
this.session.persistentData[cleanKey] = data[key];
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Get persistent params to inject into next execute() call */
|
|
192
|
-
getPersistentParams(): Record<string, unknown> {
|
|
193
|
-
if (!this.session) return {};
|
|
194
|
-
|
|
195
|
-
const params: Record<string, unknown> = {};
|
|
196
|
-
|
|
197
|
-
// Session persistent vars (float64) → state.variables
|
|
198
|
-
for (const [key, value] of Object.entries(this.session.persistentVars)) {
|
|
199
|
-
params[key] = value;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// _persist_ complex data → _ps_* in state.params
|
|
203
|
-
for (const [key, value] of Object.entries(this.session.persistentData)) {
|
|
204
|
-
params[`_ps_${key}`] = value;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
return params;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** Reset all session state */
|
|
211
|
-
reset(): void {
|
|
212
|
-
this.session = null;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
private toSessionData(): SessionData {
|
|
216
|
-
if (!this.session) throw new Error('No session');
|
|
217
|
-
|
|
218
|
-
return {
|
|
219
|
-
spinsRemaining: this.session.spinsRemaining,
|
|
220
|
-
spinsPlayed: this.session.spinsPlayed,
|
|
221
|
-
totalWin: Math.round(this.session.totalWin * 100) / 100,
|
|
222
|
-
completed: this.session.completed,
|
|
223
|
-
maxWinReached: this.session.maxWinReached,
|
|
224
|
-
betAmount: this.session.bet,
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
}
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import { LuaEngine } from './LuaEngine';
|
|
2
|
-
import type { SimulationConfig, SimulationResult, GameDefinition } from './types';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Runs N iterations of a Lua game script and collects RTP statistics.
|
|
6
|
-
* Supports regular spins, buy bonus, and ante bet simulation.
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* ```ts
|
|
10
|
-
* const runner = new SimulationRunner({
|
|
11
|
-
* script: luaSource,
|
|
12
|
-
* gameDefinition,
|
|
13
|
-
* iterations: 1_000_000,
|
|
14
|
-
* bet: 1.0,
|
|
15
|
-
* seed: 42,
|
|
16
|
-
* onProgress: (done, total) => console.log(`${done}/${total}`),
|
|
17
|
-
* });
|
|
18
|
-
*
|
|
19
|
-
* const result = runner.run();
|
|
20
|
-
* console.log(`RTP: ${result.totalRtp.toFixed(2)}%`);
|
|
21
|
-
* ```
|
|
22
|
-
*/
|
|
23
|
-
export class SimulationRunner {
|
|
24
|
-
private config: SimulationConfig;
|
|
25
|
-
|
|
26
|
-
constructor(config: SimulationConfig) {
|
|
27
|
-
this.config = config;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
run(): SimulationResult {
|
|
31
|
-
const {
|
|
32
|
-
script,
|
|
33
|
-
gameDefinition,
|
|
34
|
-
iterations,
|
|
35
|
-
bet,
|
|
36
|
-
seed,
|
|
37
|
-
action: startAction = 'spin',
|
|
38
|
-
params,
|
|
39
|
-
progressInterval = 100_000,
|
|
40
|
-
onProgress,
|
|
41
|
-
} = this.config;
|
|
42
|
-
|
|
43
|
-
const engine = new LuaEngine({
|
|
44
|
-
script,
|
|
45
|
-
gameDefinition,
|
|
46
|
-
seed,
|
|
47
|
-
logger: () => {},
|
|
48
|
-
simulationMode: true,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const spinCost = this.calculateSpinCost(startAction, bet, gameDefinition, params);
|
|
52
|
-
|
|
53
|
-
let totalWagered = 0;
|
|
54
|
-
let totalWon = 0;
|
|
55
|
-
let baseGameWin = 0;
|
|
56
|
-
let bonusWin = 0;
|
|
57
|
-
let hits = 0;
|
|
58
|
-
let maxWinMultiplier = 0;
|
|
59
|
-
let maxWinHits = 0;
|
|
60
|
-
let bonusTriggered = 0;
|
|
61
|
-
let bonusSpinsPlayed = 0;
|
|
62
|
-
|
|
63
|
-
const startTime = Date.now();
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
for (let i = 0; i < iterations; i++) {
|
|
67
|
-
totalWagered += spinCost;
|
|
68
|
-
let roundWin = 0;
|
|
69
|
-
let roundBonusWin = 0;
|
|
70
|
-
|
|
71
|
-
// Execute the starting action
|
|
72
|
-
let result = engine.execute({
|
|
73
|
-
action: startAction,
|
|
74
|
-
bet,
|
|
75
|
-
params,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const baseWin = result.totalWin;
|
|
79
|
-
|
|
80
|
-
// If a session was created, play through it using nextActions from the engine
|
|
81
|
-
if (result.session && !result.session.completed) {
|
|
82
|
-
bonusTriggered++;
|
|
83
|
-
|
|
84
|
-
let safetyLimit = 10_000;
|
|
85
|
-
while (result.session && !result.session.completed && safetyLimit-- > 0) {
|
|
86
|
-
const nextAction = result.nextActions[0];
|
|
87
|
-
result = engine.execute({ action: nextAction, bet });
|
|
88
|
-
bonusSpinsPlayed++;
|
|
89
|
-
}
|
|
90
|
-
|
|
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;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
baseGameWin += baseWin;
|
|
101
|
-
bonusWin += roundBonusWin;
|
|
102
|
-
totalWon += roundWin;
|
|
103
|
-
|
|
104
|
-
if (roundWin > 0) hits++;
|
|
105
|
-
|
|
106
|
-
const roundMultiplier = roundWin / bet;
|
|
107
|
-
if (roundMultiplier > maxWinMultiplier) {
|
|
108
|
-
maxWinMultiplier = roundMultiplier;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (result.variables?.max_win_reached === 1) {
|
|
112
|
-
maxWinHits++;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Progress reporting
|
|
116
|
-
if (onProgress && (i + 1) % progressInterval === 0) {
|
|
117
|
-
onProgress(i + 1, iterations);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
} finally {
|
|
121
|
-
engine.destroy();
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const durationMs = Date.now() - startTime;
|
|
125
|
-
|
|
126
|
-
return {
|
|
127
|
-
gameId: gameDefinition.id,
|
|
128
|
-
action: startAction,
|
|
129
|
-
iterations,
|
|
130
|
-
durationMs,
|
|
131
|
-
totalRtp: totalWagered > 0 ? (totalWon / totalWagered) * 100 : 0,
|
|
132
|
-
baseGameRtp: totalWagered > 0 ? (baseGameWin / totalWagered) * 100 : 0,
|
|
133
|
-
bonusRtp: totalWagered > 0 ? (bonusWin / totalWagered) * 100 : 0,
|
|
134
|
-
hitFrequency: iterations > 0 ? (hits / iterations) * 100 : 0,
|
|
135
|
-
maxWin: Math.round(maxWinMultiplier * 100) / 100,
|
|
136
|
-
maxWinHits,
|
|
137
|
-
bonusTriggered,
|
|
138
|
-
bonusSpinsPlayed,
|
|
139
|
-
_raw: { totalWagered, totalWon, baseGameWin, bonusWin, hits },
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Calculate the real cost of one spin (accounting for buy bonus / ante bet) */
|
|
144
|
-
private calculateSpinCost(
|
|
145
|
-
action: string,
|
|
146
|
-
bet: number,
|
|
147
|
-
gameDefinition: GameDefinition,
|
|
148
|
-
params?: Record<string, unknown>,
|
|
149
|
-
): number {
|
|
150
|
-
// Check if this is a buy bonus action
|
|
151
|
-
const actionDef = gameDefinition.actions[action];
|
|
152
|
-
if (actionDef?.buy_bonus_mode && gameDefinition.buy_bonus) {
|
|
153
|
-
const mode = gameDefinition.buy_bonus.modes[actionDef.buy_bonus_mode];
|
|
154
|
-
if (mode) {
|
|
155
|
-
return bet * mode.cost_multiplier;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Check ante bet
|
|
160
|
-
if (params?.ante_bet && gameDefinition.ante_bet) {
|
|
161
|
-
return bet * gameDefinition.ante_bet.cost_multiplier;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return bet;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** Format a SimulationResult for console output */
|
|
169
|
-
export function formatSimulationResult(result: SimulationResult): string {
|
|
170
|
-
const lines: string[] = [
|
|
171
|
-
'',
|
|
172
|
-
'--- Simulation Results ---',
|
|
173
|
-
`Game: ${result.gameId}`,
|
|
174
|
-
`Action: ${result.action}`,
|
|
175
|
-
`Iterations: ${result.iterations.toLocaleString()}`,
|
|
176
|
-
`Duration: ${(result.durationMs / 1000).toFixed(1)}s`,
|
|
177
|
-
`Total RTP: ${result.totalRtp.toFixed(2)}%`,
|
|
178
|
-
`Base Game RTP: ${result.baseGameRtp.toFixed(2)}%`,
|
|
179
|
-
`Bonus RTP: ${result.bonusRtp.toFixed(2)}%`,
|
|
180
|
-
`Hit Frequency: ${result.hitFrequency.toFixed(2)}%`,
|
|
181
|
-
`Max Win: ${result.maxWin.toFixed(2)}x`,
|
|
182
|
-
`Max Win Hits: ${result.maxWinHits} (rounds capped by max_win)`,
|
|
183
|
-
];
|
|
184
|
-
|
|
185
|
-
if (result.bonusTriggered > 0) {
|
|
186
|
-
const frequency = Math.round(result.iterations / result.bonusTriggered);
|
|
187
|
-
lines.push(`Bonus Triggered: ${result.bonusTriggered.toLocaleString()} (1 in ${frequency} spins)`);
|
|
188
|
-
lines.push(`Bonus Spins Played: ${result.bonusSpinsPlayed.toLocaleString()}`);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return lines.join('\n');
|
|
192
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
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();
|