@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
package/dist/lua.esm.js
CHANGED
|
@@ -1,1484 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
import { Worker } from 'worker_threads';
|
|
3
|
-
import { cpus, tmpdir } from 'os';
|
|
4
|
-
import { fileURLToPath } from 'url';
|
|
5
|
-
import { join, dirname } from 'path';
|
|
6
|
-
import { spawn, execSync } from 'child_process';
|
|
7
|
-
import { writeFile, unlink } from 'fs/promises';
|
|
8
|
-
import { accessSync, constants } from 'fs';
|
|
9
|
-
import { randomBytes } from 'crypto';
|
|
10
|
-
|
|
11
|
-
const { lua: lua$1, lauxlib: lauxlib$1 } = fengari;
|
|
12
|
-
const { to_luastring: to_luastring$1, to_jsstring: to_jsstring$1 } = fengari;
|
|
13
|
-
/** Cache for to_luastring() results — avoids re-encoding the same keys every iteration */
|
|
14
|
-
const luaStringCache = new Map();
|
|
15
|
-
function cachedToLuastring(s) {
|
|
16
|
-
let cached = luaStringCache.get(s);
|
|
17
|
-
if (!cached) {
|
|
18
|
-
cached = to_luastring$1(s);
|
|
19
|
-
luaStringCache.set(s, cached);
|
|
20
|
-
}
|
|
21
|
-
return cached;
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Seeded xoshiro128** PRNG for deterministic simulation/replay.
|
|
25
|
-
* Period: 2^128 - 1
|
|
26
|
-
*/
|
|
27
|
-
function createSeededRng(seed) {
|
|
28
|
-
let s0 = (seed >>> 0) | 1;
|
|
29
|
-
let s1 = (seed * 1103515245 + 12345) >>> 0;
|
|
30
|
-
let s2 = (seed * 6364136223846793005 + 1442695040888963407) >>> 0;
|
|
31
|
-
let s3 = (seed * 1442695040888963407 + 6364136223846793005) >>> 0;
|
|
32
|
-
return () => {
|
|
33
|
-
const result = (((s1 * 5) << 7) * 9) >>> 0;
|
|
34
|
-
const t = s1 << 9;
|
|
35
|
-
s2 ^= s0;
|
|
36
|
-
s3 ^= s1;
|
|
37
|
-
s1 ^= s2;
|
|
38
|
-
s0 ^= s3;
|
|
39
|
-
s2 ^= t;
|
|
40
|
-
s3 = ((s3 << 11) | (s3 >>> 21)) >>> 0;
|
|
41
|
-
return result / 4294967296;
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Implements and registers all platform `engine.*` functions into a Lua state.
|
|
46
|
-
*/
|
|
47
|
-
class LuaEngineAPI {
|
|
48
|
-
rng;
|
|
49
|
-
logger;
|
|
50
|
-
gameDefinition;
|
|
51
|
-
constructor(gameDefinition, rng, logger) {
|
|
52
|
-
this.gameDefinition = gameDefinition;
|
|
53
|
-
this.rng = rng ?? Math.random;
|
|
54
|
-
this.logger = logger ?? ((level, msg) => {
|
|
55
|
-
const fn = level === 'error' ? console.error
|
|
56
|
-
: level === 'warn' ? console.warn
|
|
57
|
-
: level === 'debug' ? console.debug
|
|
58
|
-
: console.log;
|
|
59
|
-
fn(`[Lua:${level}] ${msg}`);
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
/** Register `engine` global table on the Lua state */
|
|
63
|
-
register(L) {
|
|
64
|
-
// Create the `engine` table
|
|
65
|
-
lua$1.lua_newtable(L);
|
|
66
|
-
this.registerFunction(L, 'random', (LS) => {
|
|
67
|
-
const min = lauxlib$1.luaL_checkinteger(LS, 1);
|
|
68
|
-
const max = lauxlib$1.luaL_checkinteger(LS, 2);
|
|
69
|
-
const result = this.random(Number(min), Number(max));
|
|
70
|
-
lua$1.lua_pushinteger(LS, result);
|
|
71
|
-
return 1;
|
|
72
|
-
});
|
|
73
|
-
this.registerFunction(L, 'random_float', (LS) => {
|
|
74
|
-
lua$1.lua_pushnumber(LS, this.randomFloat());
|
|
75
|
-
return 1;
|
|
76
|
-
});
|
|
77
|
-
this.registerFunction(L, 'random_weighted', (LS) => {
|
|
78
|
-
lauxlib$1.luaL_checktype(LS, 1, lua$1.LUA_TTABLE);
|
|
79
|
-
const weights = [];
|
|
80
|
-
const len = lua$1.lua_rawlen(LS, 1);
|
|
81
|
-
for (let i = 1; i <= len; i++) {
|
|
82
|
-
lua$1.lua_rawgeti(LS, 1, i);
|
|
83
|
-
weights.push(lua$1.lua_tonumber(LS, -1));
|
|
84
|
-
lua$1.lua_pop(LS, 1);
|
|
85
|
-
}
|
|
86
|
-
const result = this.randomWeighted(weights);
|
|
87
|
-
lua$1.lua_pushinteger(LS, result);
|
|
88
|
-
return 1;
|
|
89
|
-
});
|
|
90
|
-
this.registerFunction(L, 'shuffle', (LS) => {
|
|
91
|
-
lauxlib$1.luaL_checktype(LS, 1, lua$1.LUA_TTABLE);
|
|
92
|
-
const arr = [];
|
|
93
|
-
const len = lua$1.lua_rawlen(LS, 1);
|
|
94
|
-
for (let i = 1; i <= len; i++) {
|
|
95
|
-
lua$1.lua_rawgeti(LS, 1, i);
|
|
96
|
-
arr.push(luaToJS(LS, -1));
|
|
97
|
-
lua$1.lua_pop(LS, 1);
|
|
98
|
-
}
|
|
99
|
-
const shuffled = this.shuffle(arr);
|
|
100
|
-
pushJSArray(LS, shuffled);
|
|
101
|
-
return 1;
|
|
102
|
-
});
|
|
103
|
-
this.registerFunction(L, 'log', (LS) => {
|
|
104
|
-
const level = to_jsstring$1(lauxlib$1.luaL_checkstring(LS, 1));
|
|
105
|
-
const msg = to_jsstring$1(lauxlib$1.luaL_checkstring(LS, 2));
|
|
106
|
-
this.logger(level, msg);
|
|
107
|
-
return 0;
|
|
108
|
-
});
|
|
109
|
-
this.registerFunction(L, 'get_config', (LS) => {
|
|
110
|
-
const config = this.getConfig();
|
|
111
|
-
pushJSObject(LS, config);
|
|
112
|
-
return 1;
|
|
113
|
-
});
|
|
114
|
-
// Set the table as global `engine`
|
|
115
|
-
lua$1.lua_setglobal(L, to_luastring$1('engine'));
|
|
116
|
-
}
|
|
117
|
-
// ─── engine.* implementations ─────────────────────────
|
|
118
|
-
random(min, max) {
|
|
119
|
-
return Math.floor(this.rng() * (max - min + 1)) + min;
|
|
120
|
-
}
|
|
121
|
-
randomFloat() {
|
|
122
|
-
return this.rng();
|
|
123
|
-
}
|
|
124
|
-
randomWeighted(weights) {
|
|
125
|
-
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
|
126
|
-
let roll = this.rng() * totalWeight;
|
|
127
|
-
for (let i = 0; i < weights.length; i++) {
|
|
128
|
-
roll -= weights[i];
|
|
129
|
-
if (roll < 0)
|
|
130
|
-
return i + 1; // 1-based index
|
|
131
|
-
}
|
|
132
|
-
return weights.length; // fallback to last
|
|
133
|
-
}
|
|
134
|
-
shuffle(arr) {
|
|
135
|
-
const copy = [...arr];
|
|
136
|
-
for (let i = copy.length - 1; i > 0; i--) {
|
|
137
|
-
const j = Math.floor(this.rng() * (i + 1));
|
|
138
|
-
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
139
|
-
}
|
|
140
|
-
return copy;
|
|
141
|
-
}
|
|
142
|
-
getConfig() {
|
|
143
|
-
const def = this.gameDefinition;
|
|
144
|
-
let betLevels = [];
|
|
145
|
-
if (Array.isArray(def.bet_levels)) {
|
|
146
|
-
betLevels = def.bet_levels;
|
|
147
|
-
}
|
|
148
|
-
else if (def.bet_levels && 'levels' in def.bet_levels && def.bet_levels.levels) {
|
|
149
|
-
betLevels = def.bet_levels.levels;
|
|
150
|
-
}
|
|
151
|
-
return {
|
|
152
|
-
id: def.id,
|
|
153
|
-
type: def.type,
|
|
154
|
-
bet_levels: betLevels,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
// ─── Helpers ──────────────────────────────────────────
|
|
158
|
-
registerFunction(L, name, fn) {
|
|
159
|
-
lua$1.lua_pushcfunction(L, fn);
|
|
160
|
-
lua$1.lua_setfield(L, -2, to_luastring$1(name));
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
// ─── Lua ↔ JS marshalling ───────────────────────────────
|
|
164
|
-
/** Read a Lua value at the given stack index and return its JS equivalent */
|
|
165
|
-
function luaToJS(L, idx) {
|
|
166
|
-
const type = lua$1.lua_type(L, idx);
|
|
167
|
-
switch (type) {
|
|
168
|
-
case lua$1.LUA_TNIL:
|
|
169
|
-
return null;
|
|
170
|
-
case lua$1.LUA_TBOOLEAN:
|
|
171
|
-
return lua$1.lua_toboolean(L, idx);
|
|
172
|
-
case lua$1.LUA_TNUMBER:
|
|
173
|
-
if (lua$1.lua_isinteger(L, idx)) {
|
|
174
|
-
return Number(lua$1.lua_tointeger(L, idx));
|
|
175
|
-
}
|
|
176
|
-
return lua$1.lua_tonumber(L, idx);
|
|
177
|
-
case lua$1.LUA_TSTRING:
|
|
178
|
-
return to_jsstring$1(lua$1.lua_tostring(L, idx));
|
|
179
|
-
case lua$1.LUA_TTABLE:
|
|
180
|
-
return luaTableToJS(L, idx);
|
|
181
|
-
default:
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
/** Convert a Lua table to a JS object or array */
|
|
186
|
-
function luaTableToJS(L, idx) {
|
|
187
|
-
// Normalize index to absolute
|
|
188
|
-
if (idx < 0)
|
|
189
|
-
idx = lua$1.lua_gettop(L) + idx + 1;
|
|
190
|
-
// Check if it's an array (sequential integer keys starting at 1)
|
|
191
|
-
const len = lua$1.lua_rawlen(L, idx);
|
|
192
|
-
if (len > 0) {
|
|
193
|
-
// Verify it's a pure array by checking key 1 exists
|
|
194
|
-
lua$1.lua_rawgeti(L, idx, 1);
|
|
195
|
-
const hasFirst = lua$1.lua_type(L, -1) !== lua$1.LUA_TNIL;
|
|
196
|
-
lua$1.lua_pop(L, 1);
|
|
197
|
-
if (hasFirst) {
|
|
198
|
-
// Check if there are also string keys (mixed table)
|
|
199
|
-
let hasStringKeys = false;
|
|
200
|
-
lua$1.lua_pushnil(L);
|
|
201
|
-
while (lua$1.lua_next(L, idx) !== 0) {
|
|
202
|
-
lua$1.lua_pop(L, 1); // pop value
|
|
203
|
-
if (lua$1.lua_type(L, -1) === lua$1.LUA_TSTRING) {
|
|
204
|
-
hasStringKeys = true;
|
|
205
|
-
lua$1.lua_pop(L, 1); // pop key
|
|
206
|
-
break;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
if (!hasStringKeys) {
|
|
210
|
-
// Pure array
|
|
211
|
-
const arr = [];
|
|
212
|
-
for (let i = 1; i <= len; i++) {
|
|
213
|
-
lua$1.lua_rawgeti(L, idx, i);
|
|
214
|
-
arr.push(luaToJS(L, -1));
|
|
215
|
-
lua$1.lua_pop(L, 1);
|
|
216
|
-
}
|
|
217
|
-
return arr;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
// Object (or mixed table)
|
|
222
|
-
const obj = {};
|
|
223
|
-
lua$1.lua_pushnil(L);
|
|
224
|
-
while (lua$1.lua_next(L, idx) !== 0) {
|
|
225
|
-
const keyType = lua$1.lua_type(L, -2);
|
|
226
|
-
let key;
|
|
227
|
-
if (keyType === lua$1.LUA_TSTRING) {
|
|
228
|
-
key = to_jsstring$1(lua$1.lua_tostring(L, -2));
|
|
229
|
-
}
|
|
230
|
-
else if (keyType === lua$1.LUA_TNUMBER) {
|
|
231
|
-
key = String(lua$1.lua_tonumber(L, -2));
|
|
232
|
-
}
|
|
233
|
-
else {
|
|
234
|
-
lua$1.lua_pop(L, 1);
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
obj[key] = luaToJS(L, -1);
|
|
238
|
-
lua$1.lua_pop(L, 1);
|
|
239
|
-
}
|
|
240
|
-
return obj;
|
|
241
|
-
}
|
|
242
|
-
/** Push a JS value onto the Lua stack */
|
|
243
|
-
function pushJSValue(L, value) {
|
|
244
|
-
if (value === null || value === undefined) {
|
|
245
|
-
lua$1.lua_pushnil(L);
|
|
246
|
-
}
|
|
247
|
-
else if (typeof value === 'boolean') {
|
|
248
|
-
lua$1.lua_pushboolean(L, value ? 1 : 0);
|
|
249
|
-
}
|
|
250
|
-
else if (typeof value === 'number') {
|
|
251
|
-
if (Number.isInteger(value)) {
|
|
252
|
-
lua$1.lua_pushinteger(L, value);
|
|
253
|
-
}
|
|
254
|
-
else {
|
|
255
|
-
lua$1.lua_pushnumber(L, value);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
else if (typeof value === 'string') {
|
|
259
|
-
lua$1.lua_pushstring(L, cachedToLuastring(value));
|
|
260
|
-
}
|
|
261
|
-
else if (Array.isArray(value)) {
|
|
262
|
-
pushJSArray(L, value);
|
|
263
|
-
}
|
|
264
|
-
else if (typeof value === 'object') {
|
|
265
|
-
pushJSObject(L, value);
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
lua$1.lua_pushnil(L);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
/** Push a JS array as a Lua table (1-based) */
|
|
272
|
-
function pushJSArray(L, arr) {
|
|
273
|
-
lua$1.lua_createtable(L, arr.length, 0);
|
|
274
|
-
for (let i = 0; i < arr.length; i++) {
|
|
275
|
-
pushJSValue(L, arr[i]);
|
|
276
|
-
lua$1.lua_rawseti(L, -2, i + 1);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
/** Push a JS object as a Lua table */
|
|
280
|
-
function pushJSObject(L, obj) {
|
|
281
|
-
const keys = Object.keys(obj);
|
|
282
|
-
lua$1.lua_createtable(L, 0, keys.length);
|
|
283
|
-
for (const key of keys) {
|
|
284
|
-
pushJSValue(L, obj[key]);
|
|
285
|
-
lua$1.lua_setfield(L, -2, cachedToLuastring(key));
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Replicates the platform's action dispatch and transition evaluation.
|
|
291
|
-
* Routes play requests to the correct action, evaluates transition conditions
|
|
292
|
-
* against current variables to determine next actions and session operations.
|
|
293
|
-
*/
|
|
294
|
-
class ActionRouter {
|
|
295
|
-
actions;
|
|
296
|
-
constructor(gameDefinition) {
|
|
297
|
-
this.actions = gameDefinition.actions;
|
|
298
|
-
}
|
|
299
|
-
/** Look up action by name and validate prerequisites */
|
|
300
|
-
resolveAction(actionName, hasSession) {
|
|
301
|
-
const action = this.actions[actionName];
|
|
302
|
-
if (!action) {
|
|
303
|
-
throw new Error(`Unknown action: "${actionName}". Available: ${Object.keys(this.actions).join(', ')}`);
|
|
304
|
-
}
|
|
305
|
-
if (action.requires_session && !hasSession) {
|
|
306
|
-
throw new Error(`Action "${actionName}" requires an active session`);
|
|
307
|
-
}
|
|
308
|
-
return action;
|
|
309
|
-
}
|
|
310
|
-
/** Evaluate transitions in order, return the first matching rule */
|
|
311
|
-
evaluateTransitions(action, variables) {
|
|
312
|
-
for (const rule of action.transitions) {
|
|
313
|
-
if (evaluateCondition(rule.condition, variables)) {
|
|
314
|
-
return { rule, nextActions: rule.next_actions };
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
throw new Error(`No matching transition for action with stage "${action.stage}". ` +
|
|
318
|
-
`Variables: ${JSON.stringify(variables)}`);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
// ─── Condition Evaluator ────────────────────────────────
|
|
322
|
-
/**
|
|
323
|
-
* Evaluates a transition condition expression against variables.
|
|
324
|
-
*
|
|
325
|
-
* Supports:
|
|
326
|
-
* - "always" → true
|
|
327
|
-
* - Simple comparisons: "var > 0", "var == 1", "var >= 10", "var != 0", "var < 5", "var <= 3"
|
|
328
|
-
* - Logical connectives: "expr && expr", "expr || expr"
|
|
329
|
-
*
|
|
330
|
-
* This covers all patterns used by the platform's govaluate conditions.
|
|
331
|
-
*/
|
|
332
|
-
function evaluateCondition(condition, variables) {
|
|
333
|
-
const trimmed = condition.trim();
|
|
334
|
-
if (trimmed === 'always')
|
|
335
|
-
return true;
|
|
336
|
-
// Handle || (OR) — lowest precedence
|
|
337
|
-
if (trimmed.includes('||')) {
|
|
338
|
-
const parts = splitOnOperator(trimmed, '||');
|
|
339
|
-
return parts.some(part => evaluateCondition(part, variables));
|
|
340
|
-
}
|
|
341
|
-
// Handle && (AND)
|
|
342
|
-
if (trimmed.includes('&&')) {
|
|
343
|
-
const parts = splitOnOperator(trimmed, '&&');
|
|
344
|
-
return parts.every(part => evaluateCondition(part, variables));
|
|
345
|
-
}
|
|
346
|
-
// Single comparison: "variable op value"
|
|
347
|
-
return evaluateComparison(trimmed, variables);
|
|
348
|
-
}
|
|
349
|
-
function splitOnOperator(expr, operator) {
|
|
350
|
-
const parts = [];
|
|
351
|
-
let depth = 0;
|
|
352
|
-
let current = '';
|
|
353
|
-
for (let i = 0; i < expr.length; i++) {
|
|
354
|
-
if (expr[i] === '(')
|
|
355
|
-
depth++;
|
|
356
|
-
else if (expr[i] === ')')
|
|
357
|
-
depth--;
|
|
358
|
-
if (depth === 0 && expr.substring(i, i + operator.length) === operator) {
|
|
359
|
-
parts.push(current);
|
|
360
|
-
current = '';
|
|
361
|
-
i += operator.length - 1;
|
|
362
|
-
}
|
|
363
|
-
else {
|
|
364
|
-
current += expr[i];
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
parts.push(current);
|
|
368
|
-
return parts;
|
|
369
|
-
}
|
|
370
|
-
function evaluateComparison(expr, variables) {
|
|
371
|
-
// Match: variable_name operator value
|
|
372
|
-
const match = expr.trim().match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(>=|<=|!=|==|>|<)\s*(-?\d+(?:\.\d+)?)\s*$/);
|
|
373
|
-
if (!match) {
|
|
374
|
-
throw new Error(`Cannot parse condition: "${expr}"`);
|
|
375
|
-
}
|
|
376
|
-
const [, varName, op, valueStr] = match;
|
|
377
|
-
const left = variables[varName] ?? 0;
|
|
378
|
-
const right = parseFloat(valueStr);
|
|
379
|
-
switch (op) {
|
|
380
|
-
case '>': return left > right;
|
|
381
|
-
case '>=': return left >= right;
|
|
382
|
-
case '<': return left < right;
|
|
383
|
-
case '<=': return left <= right;
|
|
384
|
-
case '==': return left === right;
|
|
385
|
-
case '!=': return left !== right;
|
|
386
|
-
default: return false;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const MAX_SESSION_SPINS = 200;
|
|
391
|
-
/**
|
|
392
|
-
* Manages session lifecycle matching the platform server behavior:
|
|
393
|
-
* - createSession: initial spin counted (spinsPlayed=1, totalWin=spinWin)
|
|
394
|
-
* - updateSession: accumulates win, decrements spins, checks max win cap on session level
|
|
395
|
-
* - completeSession: returns cumulative totalWin, cleans up session vars
|
|
396
|
-
* - Safety cap: 200 spins max per session
|
|
397
|
-
*/
|
|
398
|
-
class SessionManager {
|
|
399
|
-
session = null;
|
|
400
|
-
get isActive() {
|
|
401
|
-
return this.session !== null && !this.session.completed;
|
|
402
|
-
}
|
|
403
|
-
get current() {
|
|
404
|
-
if (!this.session)
|
|
405
|
-
return null;
|
|
406
|
-
return this.toSessionData();
|
|
407
|
-
}
|
|
408
|
-
get sessionTotalWin() {
|
|
409
|
-
return this.session?.totalWin ?? 0;
|
|
410
|
-
}
|
|
411
|
-
/** Get the fixed bet amount from the session (server uses session bet, not request bet) */
|
|
412
|
-
get sessionBet() {
|
|
413
|
-
return this.session?.bet;
|
|
414
|
-
}
|
|
415
|
-
/** Get spinsVarName to restore free_spins_remaining into variables */
|
|
416
|
-
get spinsVarName() {
|
|
417
|
-
return this.session?.spinsVarName;
|
|
418
|
-
}
|
|
419
|
-
get spinsRemaining() {
|
|
420
|
-
return this.session?.spinsRemaining ?? 0;
|
|
421
|
-
}
|
|
422
|
-
/**
|
|
423
|
-
* Create a new session from a transition rule.
|
|
424
|
-
* Server behavior: initial spin is already counted (spinsPlayed=1, totalWin includes spinWin).
|
|
425
|
-
*/
|
|
426
|
-
createSession(rule, variables, bet, spinWin, maxWinCap) {
|
|
427
|
-
let spinsRemaining = -1;
|
|
428
|
-
let spinsVarName;
|
|
429
|
-
if (rule.session_config?.total_spins_var) {
|
|
430
|
-
spinsVarName = rule.session_config.total_spins_var;
|
|
431
|
-
spinsRemaining = variables[spinsVarName] ?? -1;
|
|
432
|
-
}
|
|
433
|
-
const persistentVarNames = rule.session_config?.persistent_vars ?? [];
|
|
434
|
-
const persistentVars = {};
|
|
435
|
-
for (const varName of persistentVarNames) {
|
|
436
|
-
persistentVars[varName] = variables[varName] ?? 0;
|
|
437
|
-
}
|
|
438
|
-
this.session = {
|
|
439
|
-
spinsRemaining,
|
|
440
|
-
spinsPlayed: 1, // initial spin counts
|
|
441
|
-
totalWin: spinWin, // initial spin win included
|
|
442
|
-
completed: false,
|
|
443
|
-
maxWinReached: false,
|
|
444
|
-
bet,
|
|
445
|
-
maxWinCap,
|
|
446
|
-
spinsVarName,
|
|
447
|
-
persistentVarNames,
|
|
448
|
-
persistentVars,
|
|
449
|
-
persistentData: {},
|
|
450
|
-
};
|
|
451
|
-
return this.toSessionData();
|
|
452
|
-
}
|
|
453
|
-
/**
|
|
454
|
-
* Update session after a bonus spin.
|
|
455
|
-
* Server behavior: accumulate win, decrement spins, check retrigger, check max win cap,
|
|
456
|
-
* safety cap at 200 spins.
|
|
457
|
-
*/
|
|
458
|
-
updateSession(rule, variables, spinWin) {
|
|
459
|
-
if (!this.session)
|
|
460
|
-
throw new Error('No active session');
|
|
461
|
-
// Accumulate win and count spin
|
|
462
|
-
this.session.totalWin += spinWin;
|
|
463
|
-
this.session.spinsPlayed++;
|
|
464
|
-
// Decrement spins (only for non-unlimited sessions)
|
|
465
|
-
if (this.session.spinsRemaining > 0) {
|
|
466
|
-
this.session.spinsRemaining--;
|
|
467
|
-
}
|
|
468
|
-
// Handle retrigger (add_spins_var)
|
|
469
|
-
if (rule.add_spins_var) {
|
|
470
|
-
const extraSpins = variables[rule.add_spins_var] ?? 0;
|
|
471
|
-
if (extraSpins > 0 && this.session.spinsRemaining >= 0) {
|
|
472
|
-
this.session.spinsRemaining += extraSpins;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
// Safety cap: server limits sessions to 200 spins
|
|
476
|
-
if (this.session.spinsPlayed >= MAX_SESSION_SPINS) {
|
|
477
|
-
this.session.spinsRemaining = 0;
|
|
478
|
-
}
|
|
479
|
-
// Update session persistent vars from current variables
|
|
480
|
-
for (const varName of this.session.persistentVarNames) {
|
|
481
|
-
if (varName in variables) {
|
|
482
|
-
this.session.persistentVars[varName] = variables[varName];
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
// Check max win cap (on session level, not per spin)
|
|
486
|
-
if (this.session.maxWinCap !== undefined && this.session.totalWin >= this.session.maxWinCap) {
|
|
487
|
-
this.session.totalWin = this.session.maxWinCap;
|
|
488
|
-
this.session.spinsRemaining = 0;
|
|
489
|
-
this.session.maxWinReached = true;
|
|
490
|
-
}
|
|
491
|
-
// Auto-complete if spins exhausted or explicit complete
|
|
492
|
-
if (this.session.spinsRemaining === 0 || rule.complete_session) {
|
|
493
|
-
this.session.completed = true;
|
|
494
|
-
}
|
|
495
|
-
return this.toSessionData();
|
|
496
|
-
}
|
|
497
|
-
/**
|
|
498
|
-
* Complete the session explicitly.
|
|
499
|
-
* Returns cumulative totalWin and list of session-scoped var names to clean up.
|
|
500
|
-
*/
|
|
501
|
-
completeSession() {
|
|
502
|
-
if (!this.session)
|
|
503
|
-
throw new Error('No active session to complete');
|
|
504
|
-
this.session.completed = true;
|
|
505
|
-
const totalWin = this.session.totalWin;
|
|
506
|
-
const session = this.toSessionData();
|
|
507
|
-
const sessionVarNames = [...this.session.persistentVarNames];
|
|
508
|
-
this.session = null;
|
|
509
|
-
return { totalWin, session, sessionVarNames };
|
|
510
|
-
}
|
|
511
|
-
/** Mark max win reached — stops the session */
|
|
512
|
-
markMaxWinReached() {
|
|
513
|
-
if (this.session) {
|
|
514
|
-
this.session.maxWinReached = true;
|
|
515
|
-
this.session.completed = true;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
/** Store _persist_* data extracted from Lua result */
|
|
519
|
-
storePersistData(data) {
|
|
520
|
-
if (!this.session)
|
|
521
|
-
return;
|
|
522
|
-
for (const key of Object.keys(data)) {
|
|
523
|
-
if (key.startsWith('_persist_')) {
|
|
524
|
-
const cleanKey = key.slice('_persist_'.length);
|
|
525
|
-
this.session.persistentData[cleanKey] = data[key];
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
/** Get persistent params to inject into next execute() call */
|
|
530
|
-
getPersistentParams() {
|
|
531
|
-
if (!this.session)
|
|
532
|
-
return {};
|
|
533
|
-
const params = {};
|
|
534
|
-
// Session persistent vars (float64) → state.variables
|
|
535
|
-
for (const [key, value] of Object.entries(this.session.persistentVars)) {
|
|
536
|
-
params[key] = value;
|
|
537
|
-
}
|
|
538
|
-
// _persist_ complex data → _ps_* in state.params
|
|
539
|
-
for (const [key, value] of Object.entries(this.session.persistentData)) {
|
|
540
|
-
params[`_ps_${key}`] = value;
|
|
541
|
-
}
|
|
542
|
-
return params;
|
|
543
|
-
}
|
|
544
|
-
/** Reset all session state */
|
|
545
|
-
reset() {
|
|
546
|
-
this.session = null;
|
|
547
|
-
}
|
|
548
|
-
toSessionData() {
|
|
549
|
-
if (!this.session)
|
|
550
|
-
throw new Error('No session');
|
|
551
|
-
return {
|
|
552
|
-
spinsRemaining: this.session.spinsRemaining,
|
|
553
|
-
spinsPlayed: this.session.spinsPlayed,
|
|
554
|
-
totalWin: Math.round(this.session.totalWin * 100) / 100,
|
|
555
|
-
completed: this.session.completed,
|
|
556
|
-
maxWinReached: this.session.maxWinReached,
|
|
557
|
-
betAmount: this.session.bet,
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* Manages cross-spin persistent state — variables that survive between base game spins.
|
|
564
|
-
* Separate from session-scoped persistence (handled by SessionManager).
|
|
565
|
-
*
|
|
566
|
-
* Handles two mechanisms:
|
|
567
|
-
* 1. Numeric vars declared in `persistent_state.vars` — stored in state.variables
|
|
568
|
-
* 2. Complex data with `_persist_game_*` prefix — stored separately, injected as `_ps_*`
|
|
569
|
-
*/
|
|
570
|
-
class PersistentState {
|
|
571
|
-
config;
|
|
572
|
-
vars = {};
|
|
573
|
-
gameData = {};
|
|
574
|
-
constructor(config) {
|
|
575
|
-
this.config = config;
|
|
576
|
-
}
|
|
577
|
-
/** Load persistent vars into variables map before execute() */
|
|
578
|
-
loadIntoVariables(variables) {
|
|
579
|
-
if (!this.config)
|
|
580
|
-
return;
|
|
581
|
-
for (const varName of this.config.vars) {
|
|
582
|
-
if (varName in this.vars) {
|
|
583
|
-
variables[varName] = this.vars[varName];
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
/** Save persistent vars from variables map after execute() */
|
|
588
|
-
saveFromVariables(variables) {
|
|
589
|
-
if (!this.config)
|
|
590
|
-
return;
|
|
591
|
-
for (const varName of this.config.vars) {
|
|
592
|
-
if (varName in variables) {
|
|
593
|
-
this.vars[varName] = variables[varName];
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
/** Extract _persist_game_* keys from Lua return data, store them */
|
|
598
|
-
storeGameData(data) {
|
|
599
|
-
for (const key of Object.keys(data)) {
|
|
600
|
-
if (key.startsWith('_persist_game_')) {
|
|
601
|
-
const cleanKey = key.slice('_persist_game_'.length);
|
|
602
|
-
this.gameData[cleanKey] = data[key];
|
|
603
|
-
delete data[key]; // remove from client data
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
/** Get _ps_* params for next execute() call */
|
|
608
|
-
getGameDataParams() {
|
|
609
|
-
const params = {};
|
|
610
|
-
for (const [key, value] of Object.entries(this.gameData)) {
|
|
611
|
-
params[`_ps_${key}`] = value;
|
|
612
|
-
}
|
|
613
|
-
return params;
|
|
614
|
-
}
|
|
615
|
-
/** Get exposed vars for client data.persistent_state */
|
|
616
|
-
getExposedVars() {
|
|
617
|
-
if (!this.config?.exposed_vars?.length)
|
|
618
|
-
return undefined;
|
|
619
|
-
const exposed = {};
|
|
620
|
-
for (const varName of this.config.exposed_vars) {
|
|
621
|
-
if (varName in this.vars) {
|
|
622
|
-
exposed[varName] = this.vars[varName];
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
return exposed;
|
|
626
|
-
}
|
|
627
|
-
/** Reset all state */
|
|
628
|
-
reset() {
|
|
629
|
-
this.vars = {};
|
|
630
|
-
this.gameData = {};
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
const { lua, lauxlib, lualib } = fengari;
|
|
635
|
-
const { to_luastring, to_jsstring } = fengari;
|
|
636
|
-
/** Default engine variables matching the server's NewGameState() */
|
|
637
|
-
const DEFAULT_VARIABLES = {
|
|
638
|
-
multiplier: 1,
|
|
639
|
-
total_multiplier: 1,
|
|
640
|
-
global_multiplier: 1,
|
|
641
|
-
last_win_amount: 0,
|
|
642
|
-
free_spins_awarded: 0,
|
|
643
|
-
};
|
|
644
|
-
/**
|
|
645
|
-
* Runs Lua game scripts locally, replicating the platform's server-side execution.
|
|
646
|
-
*
|
|
647
|
-
* Implements the full lifecycle matching `casino_platform/internal/usecase/game_usecase.go`:
|
|
648
|
-
* action routing → state assembly → Lua execute() → result extraction →
|
|
649
|
-
* transition evaluation → session management.
|
|
650
|
-
*/
|
|
651
|
-
class LuaEngine {
|
|
652
|
-
L;
|
|
653
|
-
api;
|
|
654
|
-
actionRouter;
|
|
655
|
-
sessionManager;
|
|
656
|
-
persistentState;
|
|
657
|
-
gameDefinition;
|
|
658
|
-
variables = {};
|
|
659
|
-
simulationMode;
|
|
660
|
-
/** Reusable state objects to avoid per-iteration allocation */
|
|
661
|
-
_stateVars = {};
|
|
662
|
-
_stateParams = {};
|
|
663
|
-
constructor(config) {
|
|
664
|
-
this.gameDefinition = config.gameDefinition;
|
|
665
|
-
this.simulationMode = config.simulationMode ?? false;
|
|
666
|
-
const rng = config.seed !== undefined
|
|
667
|
-
? createSeededRng(config.seed)
|
|
668
|
-
: undefined;
|
|
669
|
-
this.api = new LuaEngineAPI(config.gameDefinition, rng, config.logger);
|
|
670
|
-
this.actionRouter = new ActionRouter(config.gameDefinition);
|
|
671
|
-
this.sessionManager = new SessionManager();
|
|
672
|
-
this.persistentState = new PersistentState(config.gameDefinition.persistent_state);
|
|
673
|
-
this.L = lauxlib.luaL_newstate();
|
|
674
|
-
lualib.luaL_openlibs(this.L);
|
|
675
|
-
// Polyfill Lua 5.1/5.2 functions removed in 5.3
|
|
676
|
-
lauxlib.luaL_dostring(this.L, to_luastring(`
|
|
677
|
-
math.pow = function(a, b) return a ^ b end
|
|
678
|
-
math.atan2 = math.atan2 or function(y, x) return math.atan(y, x) end
|
|
679
|
-
math.log10 = math.log10 or function(x) return math.log(x, 10) end
|
|
680
|
-
math.cosh = math.cosh or function(x) return (math.exp(x) + math.exp(-x)) / 2 end
|
|
681
|
-
math.sinh = math.sinh or function(x) return (math.exp(x) - math.exp(-x)) / 2 end
|
|
682
|
-
math.tanh = math.tanh or function(x) return math.sinh(x) / math.cosh(x) end
|
|
683
|
-
math.frexp = math.frexp or function(x)
|
|
684
|
-
if x == 0 then return 0, 0 end
|
|
685
|
-
local e = math.floor(math.log(math.abs(x), 2)) + 1
|
|
686
|
-
return x / (2 ^ e), e
|
|
687
|
-
end
|
|
688
|
-
math.ldexp = math.ldexp or function(m, e) return m * (2 ^ e) end
|
|
689
|
-
unpack = unpack or table.unpack
|
|
690
|
-
loadstring = loadstring or load
|
|
691
|
-
table.getn = table.getn or function(t) return #t end
|
|
692
|
-
`));
|
|
693
|
-
this.api.register(this.L);
|
|
694
|
-
this.loadScript(config.script);
|
|
695
|
-
}
|
|
696
|
-
get session() {
|
|
697
|
-
return this.sessionManager.current;
|
|
698
|
-
}
|
|
699
|
-
get persistentVars() {
|
|
700
|
-
return { ...this.variables };
|
|
701
|
-
}
|
|
702
|
-
/**
|
|
703
|
-
* Execute a play action — replicates server's Play() function.
|
|
704
|
-
*/
|
|
705
|
-
execute(params) {
|
|
706
|
-
const { action: actionName, params: clientParams } = params;
|
|
707
|
-
// 1. Resolve action
|
|
708
|
-
const action = this.actionRouter.resolveAction(actionName, this.sessionManager.isActive);
|
|
709
|
-
// 2. Determine bet — server uses session bet for session actions
|
|
710
|
-
let bet = params.bet;
|
|
711
|
-
if (this.sessionManager.isActive && this.sessionManager.sessionBet !== undefined) {
|
|
712
|
-
bet = this.sessionManager.sessionBet;
|
|
713
|
-
}
|
|
714
|
-
// 3. Build state.variables (matching server's NewGameState + restore)
|
|
715
|
-
// Reuse pooled object to avoid per-iteration allocation
|
|
716
|
-
const stateVars = this._stateVars;
|
|
717
|
-
// Clear previous keys
|
|
718
|
-
for (const key in stateVars)
|
|
719
|
-
delete stateVars[key];
|
|
720
|
-
// Apply defaults, then engine vars, then bet
|
|
721
|
-
Object.assign(stateVars, DEFAULT_VARIABLES, this.variables);
|
|
722
|
-
stateVars.bet = bet;
|
|
723
|
-
// Load cross-spin persistent state
|
|
724
|
-
this.persistentState.loadIntoVariables(stateVars);
|
|
725
|
-
// Load session persistent vars + restore spinsRemaining
|
|
726
|
-
if (this.sessionManager.isActive) {
|
|
727
|
-
const sessionParams = this.sessionManager.getPersistentParams();
|
|
728
|
-
for (const [k, v] of Object.entries(sessionParams)) {
|
|
729
|
-
if (typeof v === 'number') {
|
|
730
|
-
stateVars[k] = v;
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
// Restore spinsRemaining into the variable the script reads
|
|
734
|
-
if (this.sessionManager.spinsVarName) {
|
|
735
|
-
stateVars[this.sessionManager.spinsVarName] = this.sessionManager.spinsRemaining;
|
|
736
|
-
}
|
|
737
|
-
// Also set free_spins_remaining for convenience
|
|
738
|
-
stateVars.free_spins_remaining = this.sessionManager.spinsRemaining;
|
|
739
|
-
}
|
|
740
|
-
// 4. Build state.params (reuse pooled object)
|
|
741
|
-
const stateParams = this._stateParams;
|
|
742
|
-
for (const key in stateParams)
|
|
743
|
-
delete stateParams[key];
|
|
744
|
-
if (clientParams)
|
|
745
|
-
Object.assign(stateParams, clientParams);
|
|
746
|
-
stateParams._action = actionName;
|
|
747
|
-
// Inject session _ps_* persistent data
|
|
748
|
-
if (this.sessionManager.isActive) {
|
|
749
|
-
const sessionParams = this.sessionManager.getPersistentParams();
|
|
750
|
-
for (const [k, v] of Object.entries(sessionParams)) {
|
|
751
|
-
if (typeof v !== 'number') {
|
|
752
|
-
stateParams[k] = v;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
// Inject cross-spin _ps_* game data
|
|
757
|
-
const gameDataParams = this.persistentState.getGameDataParams();
|
|
758
|
-
Object.assign(stateParams, gameDataParams);
|
|
759
|
-
// Handle buy bonus
|
|
760
|
-
if (action.buy_bonus_mode && this.gameDefinition.buy_bonus) {
|
|
761
|
-
const mode = this.gameDefinition.buy_bonus.modes[action.buy_bonus_mode];
|
|
762
|
-
if (mode) {
|
|
763
|
-
stateParams.buy_bonus = true;
|
|
764
|
-
stateParams.buy_bonus_mode = action.buy_bonus_mode;
|
|
765
|
-
if (mode.scatter_distribution) {
|
|
766
|
-
stateParams.forced_scatter_count = this.pickFromDistribution(mode.scatter_distribution);
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
// Handle ante bet
|
|
771
|
-
if (clientParams?.ante_bet && this.gameDefinition.ante_bet) {
|
|
772
|
-
stateParams.ante_bet = true;
|
|
773
|
-
}
|
|
774
|
-
// 5. Execute Lua (server: executor.Execute(stage, state))
|
|
775
|
-
const luaResult = this.callLuaExecute(action.stage, actionName, stateParams, stateVars);
|
|
776
|
-
// 6. Process result (server: ApplyLuaResult)
|
|
777
|
-
const totalWinMultiplier = typeof luaResult.total_win === 'number' ? luaResult.total_win : 0;
|
|
778
|
-
const resultVariables = (luaResult.variables ?? {});
|
|
779
|
-
const spinWin = Math.round(totalWinMultiplier * bet * 100) / 100;
|
|
780
|
-
// Merge ONLY Lua return variables into engine state (not the whole stateVars).
|
|
781
|
-
// On the server, state.Variables is a temporary object rebuilt each call.
|
|
782
|
-
// Only the Lua result's `variables` table persists between calls.
|
|
783
|
-
Object.assign(this.variables, resultVariables);
|
|
784
|
-
// Also update stateVars for transition evaluation below
|
|
785
|
-
Object.assign(stateVars, resultVariables);
|
|
786
|
-
// Build client data (everything except special keys)
|
|
787
|
-
const data = {};
|
|
788
|
-
for (const [key, value] of Object.entries(luaResult)) {
|
|
789
|
-
if (key !== 'total_win' && key !== 'variables') {
|
|
790
|
-
data[key] = value;
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
// 7. Handle _persist_* and _persist_game_* keys
|
|
794
|
-
this.sessionManager.storePersistData(data);
|
|
795
|
-
this.persistentState.storeGameData(data);
|
|
796
|
-
// Save cross-spin persistent state (from stateVars which has Lua result merged)
|
|
797
|
-
this.persistentState.saveFromVariables(stateVars);
|
|
798
|
-
// Add exposed persistent vars to client data
|
|
799
|
-
const exposedVars = this.persistentState.getExposedVars();
|
|
800
|
-
if (exposedVars) {
|
|
801
|
-
data.persistent_state = exposedVars;
|
|
802
|
-
}
|
|
803
|
-
// Remove _persist_* keys from client data
|
|
804
|
-
for (const key of Object.keys(data)) {
|
|
805
|
-
if (key.startsWith('_persist_')) {
|
|
806
|
-
delete data[key];
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
// 8. Evaluate transitions (server uses state.Variables which is stateVars)
|
|
810
|
-
const { rule, nextActions } = this.actionRouter.evaluateTransitions(action, stateVars);
|
|
811
|
-
// 9. Determine credit behavior (server: creditNow logic)
|
|
812
|
-
let creditDeferred = action.credit === 'defer' || rule.credit_override === 'defer';
|
|
813
|
-
// 10. Session lifecycle (server: create/update/complete session)
|
|
814
|
-
let session = this.sessionManager.current;
|
|
815
|
-
let resultTotalWin = spinWin;
|
|
816
|
-
let sessionCompleted = false;
|
|
817
|
-
// Calculate max win cap for session
|
|
818
|
-
const maxWinCap = this.calculateMaxWinCap(bet);
|
|
819
|
-
if (rule.creates_session && !this.sessionManager.isActive) {
|
|
820
|
-
// CREATE SESSION — initial spin counted (server: createSession includes spinWin)
|
|
821
|
-
session = this.sessionManager.createSession(rule, stateVars, bet, spinWin, maxWinCap);
|
|
822
|
-
creditDeferred = true;
|
|
823
|
-
resultTotalWin = spinWin;
|
|
824
|
-
// Clear the trigger variable — it was consumed to set spinsRemaining
|
|
825
|
-
if (rule.session_config?.total_spins_var) {
|
|
826
|
-
delete this.variables[rule.session_config.total_spins_var];
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
else if (this.sessionManager.isActive) {
|
|
830
|
-
// UPDATE SESSION — accumulate win, check completion
|
|
831
|
-
session = this.sessionManager.updateSession(rule, stateVars, spinWin);
|
|
832
|
-
if (session?.completed) {
|
|
833
|
-
// SESSION COMPLETED — server returns session.TotalWin as result.TotalWin
|
|
834
|
-
const completed = this.sessionManager.completeSession();
|
|
835
|
-
session = completed.session;
|
|
836
|
-
resultTotalWin = completed.totalWin;
|
|
837
|
-
sessionCompleted = true;
|
|
838
|
-
creditDeferred = false;
|
|
839
|
-
// Clean up session-scoped variables
|
|
840
|
-
for (const varName of completed.sessionVarNames) {
|
|
841
|
-
delete this.variables[varName];
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
else {
|
|
845
|
-
// Mid-session: totalWin = spinWin, credit deferred
|
|
846
|
-
resultTotalWin = spinWin;
|
|
847
|
-
creditDeferred = true;
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
// No session: resultTotalWin = spinWin (already set)
|
|
851
|
-
// Apply max win cap for non-session spins
|
|
852
|
-
if (!this.sessionManager.isActive && !sessionCompleted && maxWinCap !== undefined && resultTotalWin > maxWinCap) {
|
|
853
|
-
resultTotalWin = maxWinCap;
|
|
854
|
-
this.variables.max_win_reached = 1;
|
|
855
|
-
data.max_win_reached = true;
|
|
856
|
-
}
|
|
857
|
-
return {
|
|
858
|
-
totalWin: Math.round(resultTotalWin * 100) / 100,
|
|
859
|
-
data,
|
|
860
|
-
nextActions,
|
|
861
|
-
session,
|
|
862
|
-
// In simulation mode, return reference directly (caller only reads, never mutates)
|
|
863
|
-
variables: this.simulationMode ? this.variables : { ...this.variables },
|
|
864
|
-
creditDeferred,
|
|
865
|
-
};
|
|
866
|
-
}
|
|
867
|
-
reset() {
|
|
868
|
-
this.variables = {};
|
|
869
|
-
this.sessionManager.reset();
|
|
870
|
-
this.persistentState.reset();
|
|
871
|
-
}
|
|
872
|
-
destroy() {
|
|
873
|
-
if (this.L) {
|
|
874
|
-
lua.lua_close(this.L);
|
|
875
|
-
this.L = null;
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
// ─── Private ──────────────────────────────────────────
|
|
879
|
-
loadScript(source) {
|
|
880
|
-
const status = lauxlib.luaL_dostring(this.L, to_luastring(source));
|
|
881
|
-
if (status !== lua.LUA_OK) {
|
|
882
|
-
const err = to_jsstring(lua.lua_tostring(this.L, -1));
|
|
883
|
-
lua.lua_pop(this.L, 1);
|
|
884
|
-
throw new Error(`Failed to load Lua script: ${err}`);
|
|
885
|
-
}
|
|
886
|
-
lua.lua_getglobal(this.L, cachedToLuastring('execute'));
|
|
887
|
-
if (lua.lua_type(this.L, -1) !== lua.LUA_TFUNCTION) {
|
|
888
|
-
lua.lua_pop(this.L, 1);
|
|
889
|
-
throw new Error('Lua script must define a global `execute(state)` function');
|
|
890
|
-
}
|
|
891
|
-
lua.lua_pop(this.L, 1);
|
|
892
|
-
}
|
|
893
|
-
callLuaExecute(stage, action, params, variables) {
|
|
894
|
-
lua.lua_getglobal(this.L, cachedToLuastring('execute'));
|
|
895
|
-
// Build state table: {stage, action, params, variables}
|
|
896
|
-
lua.lua_createtable(this.L, 0, 4);
|
|
897
|
-
// state.stage
|
|
898
|
-
lua.lua_pushstring(this.L, cachedToLuastring(stage));
|
|
899
|
-
lua.lua_setfield(this.L, -2, cachedToLuastring('stage'));
|
|
900
|
-
// state.action (server sets this at top level)
|
|
901
|
-
lua.lua_pushstring(this.L, cachedToLuastring(action));
|
|
902
|
-
lua.lua_setfield(this.L, -2, cachedToLuastring('action'));
|
|
903
|
-
// state.params
|
|
904
|
-
pushJSValue(this.L, params);
|
|
905
|
-
lua.lua_setfield(this.L, -2, cachedToLuastring('params'));
|
|
906
|
-
// state.variables
|
|
907
|
-
pushJSValue(this.L, variables);
|
|
908
|
-
lua.lua_setfield(this.L, -2, cachedToLuastring('variables'));
|
|
909
|
-
const status = lua.lua_pcall(this.L, 1, 1, 0);
|
|
910
|
-
if (status !== lua.LUA_OK) {
|
|
911
|
-
const err = to_jsstring(lua.lua_tostring(this.L, -1));
|
|
912
|
-
lua.lua_pop(this.L, 1);
|
|
913
|
-
throw new Error(`Lua execute() failed: ${err}`);
|
|
914
|
-
}
|
|
915
|
-
if (this.simulationMode) {
|
|
916
|
-
// Fast path: extract only total_win, variables, _persist_* keys
|
|
917
|
-
const result = {};
|
|
918
|
-
lua.lua_getfield(this.L, -1, cachedToLuastring('total_win'));
|
|
919
|
-
result.total_win = lua.lua_type(this.L, -1) === lua.LUA_TNUMBER
|
|
920
|
-
? lua.lua_tonumber(this.L, -1) : 0;
|
|
921
|
-
lua.lua_pop(this.L, 1);
|
|
922
|
-
lua.lua_getfield(this.L, -1, cachedToLuastring('variables'));
|
|
923
|
-
if (lua.lua_type(this.L, -1) === lua.LUA_TTABLE) {
|
|
924
|
-
result.variables = luaToJS(this.L, -1);
|
|
925
|
-
}
|
|
926
|
-
lua.lua_pop(this.L, 1);
|
|
927
|
-
// Scan for _persist_* keys (different stages may or may not have them)
|
|
928
|
-
lua.lua_pushnil(this.L);
|
|
929
|
-
while (lua.lua_next(this.L, -2) !== 0) {
|
|
930
|
-
if (lua.lua_type(this.L, -2) === lua.LUA_TSTRING) {
|
|
931
|
-
const key = to_jsstring(lua.lua_tostring(this.L, -2));
|
|
932
|
-
if (key.startsWith('_persist_')) {
|
|
933
|
-
result[key] = luaToJS(this.L, -1);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
lua.lua_pop(this.L, 1);
|
|
937
|
-
}
|
|
938
|
-
lua.lua_pop(this.L, 1);
|
|
939
|
-
return result;
|
|
940
|
-
}
|
|
941
|
-
// Full path
|
|
942
|
-
const result = luaToJS(this.L, -1);
|
|
943
|
-
lua.lua_pop(this.L, 1);
|
|
944
|
-
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
945
|
-
throw new Error('Lua execute() must return a table');
|
|
946
|
-
}
|
|
947
|
-
return result;
|
|
948
|
-
}
|
|
949
|
-
calculateMaxWinCap(bet) {
|
|
950
|
-
const mw = this.gameDefinition.max_win;
|
|
951
|
-
if (!mw)
|
|
952
|
-
return undefined;
|
|
953
|
-
const caps = [];
|
|
954
|
-
if (mw.multiplier !== undefined)
|
|
955
|
-
caps.push(bet * mw.multiplier);
|
|
956
|
-
if (mw.fixed !== undefined)
|
|
957
|
-
caps.push(mw.fixed);
|
|
958
|
-
return caps.length > 0 ? Math.min(...caps) : undefined;
|
|
959
|
-
}
|
|
960
|
-
pickFromDistribution(distribution) {
|
|
961
|
-
const entries = Object.entries(distribution);
|
|
962
|
-
const totalWeight = entries.reduce((sum, [, w]) => sum + w, 0);
|
|
963
|
-
let roll = this.api.randomFloat() * totalWeight;
|
|
964
|
-
for (const [value, weight] of entries) {
|
|
965
|
-
roll -= weight;
|
|
966
|
-
if (roll < 0)
|
|
967
|
-
return parseInt(value, 10);
|
|
968
|
-
}
|
|
969
|
-
return parseInt(entries[entries.length - 1][0], 10);
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
/**
|
|
974
|
-
* Runs N iterations of a Lua game script and collects RTP statistics.
|
|
975
|
-
* Supports regular spins, buy bonus, and ante bet simulation.
|
|
976
|
-
*
|
|
977
|
-
* @example
|
|
978
|
-
* ```ts
|
|
979
|
-
* const runner = new SimulationRunner({
|
|
980
|
-
* script: luaSource,
|
|
981
|
-
* gameDefinition,
|
|
982
|
-
* iterations: 1_000_000,
|
|
983
|
-
* bet: 1.0,
|
|
984
|
-
* seed: 42,
|
|
985
|
-
* onProgress: (done, total) => console.log(`${done}/${total}`),
|
|
986
|
-
* });
|
|
987
|
-
*
|
|
988
|
-
* const result = runner.run();
|
|
989
|
-
* console.log(`RTP: ${result.totalRtp.toFixed(2)}%`);
|
|
990
|
-
* ```
|
|
991
|
-
*/
|
|
992
|
-
class SimulationRunner {
|
|
993
|
-
config;
|
|
994
|
-
constructor(config) {
|
|
995
|
-
this.config = config;
|
|
996
|
-
}
|
|
997
|
-
run() {
|
|
998
|
-
const { script, gameDefinition, iterations, bet, seed, action: startAction = 'spin', params, progressInterval = 100_000, onProgress, } = this.config;
|
|
999
|
-
const engine = new LuaEngine({
|
|
1000
|
-
script,
|
|
1001
|
-
gameDefinition,
|
|
1002
|
-
seed,
|
|
1003
|
-
logger: () => { },
|
|
1004
|
-
simulationMode: true,
|
|
1005
|
-
});
|
|
1006
|
-
const spinCost = this.calculateSpinCost(startAction, bet, gameDefinition, params);
|
|
1007
|
-
let totalWagered = 0;
|
|
1008
|
-
let totalWon = 0;
|
|
1009
|
-
let baseGameWin = 0;
|
|
1010
|
-
let bonusWin = 0;
|
|
1011
|
-
let hits = 0;
|
|
1012
|
-
let maxWinMultiplier = 0;
|
|
1013
|
-
let maxWinHits = 0;
|
|
1014
|
-
let bonusTriggered = 0;
|
|
1015
|
-
let bonusSpinsPlayed = 0;
|
|
1016
|
-
const startTime = Date.now();
|
|
1017
|
-
try {
|
|
1018
|
-
for (let i = 0; i < iterations; i++) {
|
|
1019
|
-
totalWagered += spinCost;
|
|
1020
|
-
let roundWin = 0;
|
|
1021
|
-
let roundBonusWin = 0;
|
|
1022
|
-
// Execute the starting action
|
|
1023
|
-
let result = engine.execute({
|
|
1024
|
-
action: startAction,
|
|
1025
|
-
bet,
|
|
1026
|
-
params,
|
|
1027
|
-
});
|
|
1028
|
-
const baseWin = result.totalWin;
|
|
1029
|
-
// If a session was created, play through it using nextActions from the engine
|
|
1030
|
-
if (result.session && !result.session.completed) {
|
|
1031
|
-
bonusTriggered++;
|
|
1032
|
-
let safetyLimit = 10_000;
|
|
1033
|
-
while (result.session && !result.session.completed && safetyLimit-- > 0) {
|
|
1034
|
-
const nextAction = result.nextActions[0];
|
|
1035
|
-
result = engine.execute({ action: nextAction, bet });
|
|
1036
|
-
bonusSpinsPlayed++;
|
|
1037
|
-
}
|
|
1038
|
-
// Session completion returns cumulative totalWin (includes trigger spin).
|
|
1039
|
-
// Use it as the full round win — don't add baseWin separately.
|
|
1040
|
-
roundWin = result.totalWin;
|
|
1041
|
-
roundBonusWin = roundWin - baseWin;
|
|
1042
|
-
}
|
|
1043
|
-
else {
|
|
1044
|
-
// No session — just base game win
|
|
1045
|
-
roundWin = baseWin;
|
|
1046
|
-
}
|
|
1047
|
-
baseGameWin += baseWin;
|
|
1048
|
-
bonusWin += roundBonusWin;
|
|
1049
|
-
totalWon += roundWin;
|
|
1050
|
-
if (roundWin > 0)
|
|
1051
|
-
hits++;
|
|
1052
|
-
const roundMultiplier = roundWin / bet;
|
|
1053
|
-
if (roundMultiplier > maxWinMultiplier) {
|
|
1054
|
-
maxWinMultiplier = roundMultiplier;
|
|
1055
|
-
}
|
|
1056
|
-
if (result.variables?.max_win_reached === 1) {
|
|
1057
|
-
maxWinHits++;
|
|
1058
|
-
}
|
|
1059
|
-
// Progress reporting
|
|
1060
|
-
if (onProgress && (i + 1) % progressInterval === 0) {
|
|
1061
|
-
onProgress(i + 1, iterations);
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
finally {
|
|
1066
|
-
engine.destroy();
|
|
1067
|
-
}
|
|
1068
|
-
const durationMs = Date.now() - startTime;
|
|
1069
|
-
return {
|
|
1070
|
-
gameId: gameDefinition.id,
|
|
1071
|
-
action: startAction,
|
|
1072
|
-
iterations,
|
|
1073
|
-
durationMs,
|
|
1074
|
-
totalRtp: totalWagered > 0 ? (totalWon / totalWagered) * 100 : 0,
|
|
1075
|
-
baseGameRtp: totalWagered > 0 ? (baseGameWin / totalWagered) * 100 : 0,
|
|
1076
|
-
bonusRtp: totalWagered > 0 ? (bonusWin / totalWagered) * 100 : 0,
|
|
1077
|
-
hitFrequency: iterations > 0 ? (hits / iterations) * 100 : 0,
|
|
1078
|
-
maxWin: Math.round(maxWinMultiplier * 100) / 100,
|
|
1079
|
-
maxWinHits,
|
|
1080
|
-
bonusTriggered,
|
|
1081
|
-
bonusSpinsPlayed,
|
|
1082
|
-
_raw: { totalWagered, totalWon, baseGameWin, bonusWin, hits },
|
|
1083
|
-
};
|
|
1084
|
-
}
|
|
1085
|
-
/** Calculate the real cost of one spin (accounting for buy bonus / ante bet) */
|
|
1086
|
-
calculateSpinCost(action, bet, gameDefinition, params) {
|
|
1087
|
-
// Check if this is a buy bonus action
|
|
1088
|
-
const actionDef = gameDefinition.actions[action];
|
|
1089
|
-
if (actionDef?.buy_bonus_mode && gameDefinition.buy_bonus) {
|
|
1090
|
-
const mode = gameDefinition.buy_bonus.modes[actionDef.buy_bonus_mode];
|
|
1091
|
-
if (mode) {
|
|
1092
|
-
return bet * mode.cost_multiplier;
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
// Check ante bet
|
|
1096
|
-
if (params?.ante_bet && gameDefinition.ante_bet) {
|
|
1097
|
-
return bet * gameDefinition.ante_bet.cost_multiplier;
|
|
1098
|
-
}
|
|
1099
|
-
return bet;
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
/** Format a SimulationResult for console output */
|
|
1103
|
-
function formatSimulationResult(result) {
|
|
1104
|
-
const lines = [
|
|
1105
|
-
'',
|
|
1106
|
-
'--- Simulation Results ---',
|
|
1107
|
-
`Game: ${result.gameId}`,
|
|
1108
|
-
`Action: ${result.action}`,
|
|
1109
|
-
`Iterations: ${result.iterations.toLocaleString()}`,
|
|
1110
|
-
`Duration: ${(result.durationMs / 1000).toFixed(1)}s`,
|
|
1111
|
-
`Total RTP: ${result.totalRtp.toFixed(2)}%`,
|
|
1112
|
-
`Base Game RTP: ${result.baseGameRtp.toFixed(2)}%`,
|
|
1113
|
-
`Bonus RTP: ${result.bonusRtp.toFixed(2)}%`,
|
|
1114
|
-
`Hit Frequency: ${result.hitFrequency.toFixed(2)}%`,
|
|
1115
|
-
`Max Win: ${result.maxWin.toFixed(2)}x`,
|
|
1116
|
-
`Max Win Hits: ${result.maxWinHits} (rounds capped by max_win)`,
|
|
1117
|
-
];
|
|
1118
|
-
if (result.bonusTriggered > 0) {
|
|
1119
|
-
const frequency = Math.round(result.iterations / result.bonusTriggered);
|
|
1120
|
-
lines.push(`Bonus Triggered: ${result.bonusTriggered.toLocaleString()} (1 in ${frequency} spins)`);
|
|
1121
|
-
lines.push(`Bonus Spins Played: ${result.bonusSpinsPlayed.toLocaleString()}`);
|
|
1122
|
-
}
|
|
1123
|
-
return lines.join('\n');
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
/// <reference types="node" />
|
|
1127
|
-
const SEED_STRIDE = 1 << 20; // 2^20 — gap between worker seeds to avoid overlap
|
|
1128
|
-
/**
|
|
1129
|
-
* Runs simulation across multiple worker threads for parallel speedup.
|
|
1130
|
-
* Each worker gets an independent LuaEngine with a partitioned seed range.
|
|
1131
|
-
*
|
|
1132
|
-
* Results are statistically equivalent to single-threaded mode but not
|
|
1133
|
-
* bit-identical (different RNG sequence ordering).
|
|
1134
|
-
*
|
|
1135
|
-
* @example
|
|
1136
|
-
* ```ts
|
|
1137
|
-
* const runner = new ParallelSimulationRunner({
|
|
1138
|
-
* script: luaSource,
|
|
1139
|
-
* gameDefinition,
|
|
1140
|
-
* iterations: 1_000_000,
|
|
1141
|
-
* bet: 1.0,
|
|
1142
|
-
* workerCount: 8,
|
|
1143
|
-
* onProgress: (done, total) => console.log(`${done}/${total}`),
|
|
1144
|
-
* });
|
|
1145
|
-
* const result = await runner.run();
|
|
1146
|
-
* ```
|
|
1147
|
-
*/
|
|
1148
|
-
class ParallelSimulationRunner {
|
|
1149
|
-
config;
|
|
1150
|
-
workerCount;
|
|
1151
|
-
constructor(config) {
|
|
1152
|
-
this.config = config;
|
|
1153
|
-
const maxWorkers = cpus().length;
|
|
1154
|
-
this.workerCount = Math.max(1, Math.min(config.workerCount ?? maxWorkers, maxWorkers, config.iterations));
|
|
1155
|
-
}
|
|
1156
|
-
async run() {
|
|
1157
|
-
const { iterations, seed, onProgress, workerCount: _, ...restConfig } = this.config;
|
|
1158
|
-
const workerCount = this.workerCount;
|
|
1159
|
-
// Split iterations evenly, remainder goes to last worker
|
|
1160
|
-
const baseChunk = Math.floor(iterations / workerCount);
|
|
1161
|
-
const remainder = iterations - baseChunk * workerCount;
|
|
1162
|
-
const workerPath = join(dirname(fileURLToPath(import.meta.url)), 'SimulationWorker.ts');
|
|
1163
|
-
const progressPerWorker = new Array(workerCount).fill(0);
|
|
1164
|
-
const totalIterations = iterations;
|
|
1165
|
-
const promises = Array.from({ length: workerCount }, (_, i) => {
|
|
1166
|
-
const workerIterations = baseChunk + (i < remainder ? 1 : 0);
|
|
1167
|
-
const workerSeed = seed !== undefined ? seed + i * SEED_STRIDE : undefined;
|
|
1168
|
-
const workerConfig = {
|
|
1169
|
-
config: {
|
|
1170
|
-
...restConfig,
|
|
1171
|
-
iterations: workerIterations,
|
|
1172
|
-
seed: workerSeed,
|
|
1173
|
-
progressInterval: this.config.progressInterval,
|
|
1174
|
-
},
|
|
1175
|
-
};
|
|
1176
|
-
return new Promise((resolve, reject) => {
|
|
1177
|
-
const worker = new Worker(workerPath, {
|
|
1178
|
-
workerData: workerConfig,
|
|
1179
|
-
// tsx registers itself via --require/--import; pass through to workers
|
|
1180
|
-
execArgv: process.execArgv,
|
|
1181
|
-
});
|
|
1182
|
-
worker.on('message', (msg) => {
|
|
1183
|
-
if (msg.type === 'progress' && onProgress) {
|
|
1184
|
-
progressPerWorker[i] = msg.progress.completed;
|
|
1185
|
-
const totalCompleted = progressPerWorker.reduce((a, b) => a + b, 0);
|
|
1186
|
-
onProgress(totalCompleted, totalIterations);
|
|
1187
|
-
}
|
|
1188
|
-
else if (msg.type === 'result') {
|
|
1189
|
-
resolve(msg.result);
|
|
1190
|
-
}
|
|
1191
|
-
else if (msg.type === 'error') {
|
|
1192
|
-
reject(new Error(`Worker ${i} failed: ${msg.error}`));
|
|
1193
|
-
}
|
|
1194
|
-
});
|
|
1195
|
-
worker.on('error', (err) => reject(new Error(`Worker ${i} error: ${err.message}`)));
|
|
1196
|
-
worker.on('exit', (code) => {
|
|
1197
|
-
if (code !== 0)
|
|
1198
|
-
reject(new Error(`Worker ${i} exited with code ${code}`));
|
|
1199
|
-
});
|
|
1200
|
-
});
|
|
1201
|
-
});
|
|
1202
|
-
const results = await Promise.all(promises);
|
|
1203
|
-
return aggregateResults(results);
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
function aggregateResults(results) {
|
|
1207
|
-
const raw = {
|
|
1208
|
-
totalWagered: 0,
|
|
1209
|
-
totalWon: 0,
|
|
1210
|
-
baseGameWin: 0,
|
|
1211
|
-
bonusWin: 0,
|
|
1212
|
-
hits: 0,
|
|
1213
|
-
};
|
|
1214
|
-
let iterations = 0;
|
|
1215
|
-
let maxWin = 0;
|
|
1216
|
-
let maxWinHits = 0;
|
|
1217
|
-
let bonusTriggered = 0;
|
|
1218
|
-
let bonusSpinsPlayed = 0;
|
|
1219
|
-
let maxDurationMs = 0;
|
|
1220
|
-
for (const r of results) {
|
|
1221
|
-
const rr = r._raw;
|
|
1222
|
-
raw.totalWagered += rr.totalWagered;
|
|
1223
|
-
raw.totalWon += rr.totalWon;
|
|
1224
|
-
raw.baseGameWin += rr.baseGameWin;
|
|
1225
|
-
raw.bonusWin += rr.bonusWin;
|
|
1226
|
-
raw.hits += rr.hits;
|
|
1227
|
-
iterations += r.iterations;
|
|
1228
|
-
if (r.maxWin > maxWin)
|
|
1229
|
-
maxWin = r.maxWin;
|
|
1230
|
-
maxWinHits += r.maxWinHits;
|
|
1231
|
-
bonusTriggered += r.bonusTriggered;
|
|
1232
|
-
bonusSpinsPlayed += r.bonusSpinsPlayed;
|
|
1233
|
-
if (r.durationMs > maxDurationMs)
|
|
1234
|
-
maxDurationMs = r.durationMs;
|
|
1235
|
-
}
|
|
1236
|
-
return {
|
|
1237
|
-
gameId: results[0].gameId,
|
|
1238
|
-
action: results[0].action,
|
|
1239
|
-
iterations,
|
|
1240
|
-
durationMs: maxDurationMs,
|
|
1241
|
-
totalRtp: raw.totalWagered > 0 ? (raw.totalWon / raw.totalWagered) * 100 : 0,
|
|
1242
|
-
baseGameRtp: raw.totalWagered > 0 ? (raw.baseGameWin / raw.totalWagered) * 100 : 0,
|
|
1243
|
-
bonusRtp: raw.totalWagered > 0 ? (raw.bonusWin / raw.totalWagered) * 100 : 0,
|
|
1244
|
-
hitFrequency: iterations > 0 ? (raw.hits / iterations) * 100 : 0,
|
|
1245
|
-
maxWin,
|
|
1246
|
-
maxWinHits,
|
|
1247
|
-
bonusTriggered,
|
|
1248
|
-
bonusSpinsPlayed,
|
|
1249
|
-
_raw: raw,
|
|
1250
|
-
};
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
// ─── Runner ─────────────────────────────────────────────
|
|
1254
|
-
class NativeSimulationRunner {
|
|
1255
|
-
config;
|
|
1256
|
-
constructor(config) {
|
|
1257
|
-
this.config = config;
|
|
1258
|
-
}
|
|
1259
|
-
async run() {
|
|
1260
|
-
const { binaryPath, script, gameDefinition, iterations, bet, action, params } = this.config;
|
|
1261
|
-
const id = randomBytes(8).toString('hex');
|
|
1262
|
-
const tmpDir = tmpdir();
|
|
1263
|
-
const luaPath = join(tmpDir, `sim-${id}.lua`);
|
|
1264
|
-
const configPath = join(tmpDir, `sim-${id}.json`);
|
|
1265
|
-
try {
|
|
1266
|
-
// Write temp files
|
|
1267
|
-
await Promise.all([
|
|
1268
|
-
writeFile(luaPath, script, 'utf-8'),
|
|
1269
|
-
writeFile(configPath, JSON.stringify({ ...gameDefinition, script_path: luaPath }), 'utf-8'),
|
|
1270
|
-
]);
|
|
1271
|
-
// Build CLI args
|
|
1272
|
-
const args = [
|
|
1273
|
-
'-config', configPath,
|
|
1274
|
-
'-iterations', String(iterations),
|
|
1275
|
-
'-bet', String(bet),
|
|
1276
|
-
'-format', 'json',
|
|
1277
|
-
];
|
|
1278
|
-
if (action) {
|
|
1279
|
-
args.push('-action', action);
|
|
1280
|
-
}
|
|
1281
|
-
if (params && Object.keys(params).length > 0) {
|
|
1282
|
-
args.push('-params', JSON.stringify(params));
|
|
1283
|
-
}
|
|
1284
|
-
// Execute binary
|
|
1285
|
-
const output = await this.exec(binaryPath, args);
|
|
1286
|
-
// Parse JSON output
|
|
1287
|
-
const json = JSON.parse(output);
|
|
1288
|
-
return mapGoResult(json);
|
|
1289
|
-
}
|
|
1290
|
-
finally {
|
|
1291
|
-
// Cleanup temp files
|
|
1292
|
-
await Promise.allSettled([unlink(luaPath), unlink(configPath)]);
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
exec(binary, args) {
|
|
1296
|
-
return new Promise((resolve, reject) => {
|
|
1297
|
-
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1298
|
-
let stdout = '';
|
|
1299
|
-
let stderr = '';
|
|
1300
|
-
child.stdout.on('data', (chunk) => {
|
|
1301
|
-
stdout += chunk.toString();
|
|
1302
|
-
});
|
|
1303
|
-
child.stderr.on('data', (chunk) => {
|
|
1304
|
-
stderr += chunk.toString();
|
|
1305
|
-
});
|
|
1306
|
-
child.on('error', (err) => {
|
|
1307
|
-
reject(new Error(`Failed to execute simulation binary: ${err.message}`));
|
|
1308
|
-
});
|
|
1309
|
-
child.on('close', (code) => {
|
|
1310
|
-
if (code !== 0) {
|
|
1311
|
-
reject(new Error(`Simulation binary exited with code ${code}: ${stderr.trim()}`));
|
|
1312
|
-
}
|
|
1313
|
-
else {
|
|
1314
|
-
resolve(stdout);
|
|
1315
|
-
}
|
|
1316
|
-
});
|
|
1317
|
-
});
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
// ─── Result mapping ─────────────────────────────────────
|
|
1321
|
-
function mapGoResult(json) {
|
|
1322
|
-
const baseStage = json.per_stage_stats?.base_game;
|
|
1323
|
-
const baseGameRtp = baseStage?.rtp ?? 0;
|
|
1324
|
-
const baseGameWin = baseStage?.total_win ?? 0;
|
|
1325
|
-
const perStage = json.per_stage_stats
|
|
1326
|
-
? Object.fromEntries(Object.entries(json.per_stage_stats).map(([key, s]) => [
|
|
1327
|
-
key,
|
|
1328
|
-
{
|
|
1329
|
-
totalWin: s.total_win,
|
|
1330
|
-
spinCount: s.spin_count,
|
|
1331
|
-
hitCount: s.hit_count,
|
|
1332
|
-
maxWin: s.max_win,
|
|
1333
|
-
rtp: s.rtp,
|
|
1334
|
-
perSpinRtp: s.per_spin_rtp,
|
|
1335
|
-
hitFrequency: s.hit_frequency,
|
|
1336
|
-
avgWin: s.avg_win,
|
|
1337
|
-
},
|
|
1338
|
-
]))
|
|
1339
|
-
: undefined;
|
|
1340
|
-
return {
|
|
1341
|
-
gameId: json.game_id,
|
|
1342
|
-
action: 'spin',
|
|
1343
|
-
iterations: json.iterations,
|
|
1344
|
-
durationMs: Math.round(json.duration_sec * 1000),
|
|
1345
|
-
totalRtp: json.total_rtp,
|
|
1346
|
-
baseGameRtp,
|
|
1347
|
-
bonusRtp: json.total_rtp - baseGameRtp,
|
|
1348
|
-
hitFrequency: json.hit_frequency,
|
|
1349
|
-
maxWin: json.max_win,
|
|
1350
|
-
maxWinHits: json.max_win_hits,
|
|
1351
|
-
bonusTriggered: json.bonus_triggered,
|
|
1352
|
-
bonusSpinsPlayed: json.bonus_spins_total,
|
|
1353
|
-
speed: json.speed,
|
|
1354
|
-
workersUsed: json.workers_used,
|
|
1355
|
-
perStage,
|
|
1356
|
-
winDistribution: json.win_distribution,
|
|
1357
|
-
_raw: {
|
|
1358
|
-
totalWagered: json.total_bet,
|
|
1359
|
-
totalWon: json.total_win,
|
|
1360
|
-
baseGameWin,
|
|
1361
|
-
bonusWin: json.total_win - baseGameWin,
|
|
1362
|
-
hits: json.iterations > 0 ? Math.round((json.hit_frequency * json.iterations) / 100) : 0,
|
|
1363
|
-
},
|
|
1364
|
-
};
|
|
1365
|
-
}
|
|
1366
|
-
// ─── Binary discovery ───────────────────────────────────
|
|
1367
|
-
/**
|
|
1368
|
-
* Search for a native simulation binary in standard locations.
|
|
1369
|
-
* Returns the absolute path if found, null otherwise.
|
|
1370
|
-
*/
|
|
1371
|
-
function findNativeBinary(baseDir) {
|
|
1372
|
-
// 1. Explicit env var
|
|
1373
|
-
const envPath = process.env.SIMULATE_BINARY;
|
|
1374
|
-
if (envPath && isExecutable(envPath)) {
|
|
1375
|
-
return envPath;
|
|
1376
|
-
}
|
|
1377
|
-
const platform = process.platform; // darwin, linux, win32
|
|
1378
|
-
const nodeArch = process.arch; // arm64, x64
|
|
1379
|
-
const goArch = nodeArch === 'x64' ? 'amd64' : nodeArch;
|
|
1380
|
-
const goPlatform = platform === 'win32' ? 'windows' : platform;
|
|
1381
|
-
const ext = platform === 'win32' ? '.exe' : '';
|
|
1382
|
-
const names = [
|
|
1383
|
-
`simulate-${goPlatform}-${goArch}${ext}`,
|
|
1384
|
-
`simulation-${goPlatform}-${goArch}${ext}`,
|
|
1385
|
-
`simulate${ext}`,
|
|
1386
|
-
`simulation${ext}`,
|
|
1387
|
-
];
|
|
1388
|
-
// Search directories: user's project first, then this package's bin/
|
|
1389
|
-
const searchDirs = [];
|
|
1390
|
-
if (baseDir)
|
|
1391
|
-
searchDirs.push(baseDir);
|
|
1392
|
-
// This package's root (where postinstall downloads the binary)
|
|
1393
|
-
try {
|
|
1394
|
-
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
1395
|
-
if (!searchDirs.includes(pkgRoot))
|
|
1396
|
-
searchDirs.push(pkgRoot);
|
|
1397
|
-
}
|
|
1398
|
-
catch {
|
|
1399
|
-
// fallback for CJS
|
|
1400
|
-
if (typeof __dirname !== 'undefined') {
|
|
1401
|
-
const pkgRoot = join(__dirname, '..');
|
|
1402
|
-
if (!searchDirs.includes(pkgRoot))
|
|
1403
|
-
searchDirs.push(pkgRoot);
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
for (const dir of searchDirs) {
|
|
1407
|
-
for (const name of names) {
|
|
1408
|
-
const candidate = join(dir, 'bin', name);
|
|
1409
|
-
if (isExecutable(candidate))
|
|
1410
|
-
return candidate;
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
// Check $PATH
|
|
1414
|
-
for (const bin of ['simulate', 'simulation']) {
|
|
1415
|
-
try {
|
|
1416
|
-
const cmd = platform === 'win32' ? `where ${bin}` : `which ${bin}`;
|
|
1417
|
-
const result = execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
|
|
1418
|
-
if (result)
|
|
1419
|
-
return result.split('\n')[0];
|
|
1420
|
-
}
|
|
1421
|
-
catch {
|
|
1422
|
-
// not found
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
return null;
|
|
1426
|
-
}
|
|
1427
|
-
function isExecutable(path) {
|
|
1428
|
-
try {
|
|
1429
|
-
accessSync(path, constants.X_OK);
|
|
1430
|
-
return true;
|
|
1431
|
-
}
|
|
1432
|
-
catch {
|
|
1433
|
-
return false;
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
// ─── Extended formatting ────────────────────────────────
|
|
1437
|
-
/** Format a NativeSimulationResult with per-stage and distribution data */
|
|
1438
|
-
function formatNativeResult(result) {
|
|
1439
|
-
const lines = [
|
|
1440
|
-
'',
|
|
1441
|
-
'--- Simulation Results ---',
|
|
1442
|
-
`Game: ${result.gameId}`,
|
|
1443
|
-
`Iterations: ${result.iterations.toLocaleString()}`,
|
|
1444
|
-
`Duration: ${(result.durationMs / 1000).toFixed(1)}s`,
|
|
1445
|
-
];
|
|
1446
|
-
if (result.speed) {
|
|
1447
|
-
lines.push(`Speed: ${Math.round(result.speed).toLocaleString()} iterations/sec`);
|
|
1448
|
-
}
|
|
1449
|
-
if (result.workersUsed) {
|
|
1450
|
-
lines.push(`Workers: ${result.workersUsed}`);
|
|
1451
|
-
}
|
|
1452
|
-
lines.push('', '--- Total ---', `Total RTP: ${result.totalRtp.toFixed(2)}%`, `Base Game RTP: ${result.baseGameRtp.toFixed(2)}%`, `Bonus RTP: ${result.bonusRtp.toFixed(2)}%`, `Hit Frequency: ${result.hitFrequency.toFixed(2)}%`, `Max Win: ${result.maxWin.toFixed(2)}x`, `Max Win Cap Hits: ${result.maxWinHits}`);
|
|
1453
|
-
if (result.bonusTriggered > 0) {
|
|
1454
|
-
const frequency = Math.round(result.iterations / result.bonusTriggered);
|
|
1455
|
-
lines.push('', '--- Bonus Stats ---', `Bonus Triggered: ${result.bonusTriggered.toLocaleString()} (1 in ${frequency} spins)`, `Bonus Spins Total: ${result.bonusSpinsPlayed.toLocaleString()}`);
|
|
1456
|
-
}
|
|
1457
|
-
// Per-stage breakdown
|
|
1458
|
-
if (result.perStage && Object.keys(result.perStage).length > 0) {
|
|
1459
|
-
lines.push('', '--- Per-Stage Breakdown ---');
|
|
1460
|
-
const header = 'Stage | Spins | RTP (contrib) | Per-Spin RTP | Hit Freq | Avg Win | Max Win';
|
|
1461
|
-
lines.push(header);
|
|
1462
|
-
lines.push('-'.repeat(header.length));
|
|
1463
|
-
for (const [stage, stats] of Object.entries(result.perStage)) {
|
|
1464
|
-
lines.push(`${stage.padEnd(20)} | ${String(stats.spinCount).padStart(10)} | ` +
|
|
1465
|
-
`${stats.rtp.toFixed(2).padStart(12)}% | ` +
|
|
1466
|
-
`${stats.perSpinRtp.toFixed(2).padStart(11)}% | ` +
|
|
1467
|
-
`${stats.hitFrequency.toFixed(2).padStart(8)}% | ` +
|
|
1468
|
-
`${stats.avgWin.toFixed(3).padStart(8)}x | ` +
|
|
1469
|
-
`${stats.maxWin.toFixed(2).padStart(8)}x`);
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
// Win distribution
|
|
1473
|
-
if (result.winDistribution && result.winDistribution.length > 0) {
|
|
1474
|
-
lines.push('', '--- Win Distribution ---');
|
|
1475
|
-
for (const bucket of result.winDistribution) {
|
|
1476
|
-
const bar = '█'.repeat(Math.round(bucket.pct / 2));
|
|
1477
|
-
lines.push(`${bucket.label.padEnd(10)} ${String(bucket.count).padStart(10)} (${bucket.pct.toFixed(2).padStart(6)}%) ${bar}`);
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
return lines.join('\n');
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
export { ActionRouter, LuaEngine, LuaEngineAPI, NativeSimulationRunner, ParallelSimulationRunner, PersistentState, SessionManager, SimulationRunner, createSeededRng, evaluateCondition, findNativeBinary, formatNativeResult, formatSimulationResult };
|
|
1
|
+
export * from '@energy8platform/platform-core/lua';
|
|
1484
2
|
//# sourceMappingURL=lua.esm.js.map
|