@energy8platform/game-engine 0.9.2 → 0.10.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 +293 -1414
- package/bin/simulate.ts +75 -0
- package/dist/debug.cjs.js +892 -18
- package/dist/debug.cjs.js.map +1 -1
- package/dist/debug.d.ts +64 -0
- package/dist/debug.esm.js +892 -18
- package/dist/debug.esm.js.map +1 -1
- package/dist/index.cjs.js +899 -18
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +239 -2
- package/dist/index.esm.js +893 -19
- package/dist/index.esm.js.map +1 -1
- package/dist/lua.cjs.js +1000 -0
- package/dist/lua.cjs.js.map +1 -0
- package/dist/lua.d.ts +296 -0
- package/dist/lua.esm.js +990 -0
- package/dist/lua.esm.js.map +1 -0
- package/dist/vite.cjs.js +26 -0
- package/dist/vite.cjs.js.map +1 -1
- package/dist/vite.esm.js +26 -0
- package/dist/vite.esm.js.map +1 -1
- package/package.json +29 -13
- package/src/debug/DevBridge.ts +73 -22
- package/src/index.ts +17 -0
- package/src/lua/ActionRouter.ts +132 -0
- package/src/lua/LuaEngine.ts +322 -0
- package/src/lua/LuaEngineAPI.ts +305 -0
- package/src/lua/PersistentState.ts +80 -0
- package/src/lua/SessionManager.ts +178 -0
- package/src/lua/SimulationRunner.ts +195 -0
- package/src/lua/index.ts +22 -0
- package/src/lua/types.ts +132 -0
- package/src/vite/index.ts +30 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type { PlayParams, SessionData } from '@energy8platform/game-sdk';
|
|
2
|
+
import type { LuaEngineConfig, LuaPlayResult, GameDefinition } from './types';
|
|
3
|
+
import { LuaEngineAPI, createSeededRng, luaToJS, pushJSValue } from './LuaEngineAPI';
|
|
4
|
+
import { ActionRouter } from './ActionRouter';
|
|
5
|
+
import { SessionManager } from './SessionManager';
|
|
6
|
+
import { PersistentState } from './PersistentState';
|
|
7
|
+
|
|
8
|
+
// fengari — Lua 5.3 in pure JavaScript
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
10
|
+
declare const require: (module: string) => any;
|
|
11
|
+
const fengari = require('fengari');
|
|
12
|
+
const { lua, lauxlib, lualib } = fengari;
|
|
13
|
+
const { to_luastring, to_jsstring } = fengari;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Runs Lua game scripts locally, replicating the platform's server-side execution.
|
|
17
|
+
*
|
|
18
|
+
* Implements the full lifecycle: action routing → state assembly → Lua execute() →
|
|
19
|
+
* result extraction → transition evaluation → session management.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* const engine = new LuaEngine({
|
|
24
|
+
* script: luaSource,
|
|
25
|
+
* gameDefinition: { id: 'my-slot', type: 'SLOT', actions: { ... } },
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* const result = engine.execute({ action: 'spin', bet: 1.0 });
|
|
29
|
+
* // result.data.matrix, result.totalWin, result.nextActions, etc.
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export class LuaEngine {
|
|
33
|
+
private L: any;
|
|
34
|
+
private api: LuaEngineAPI;
|
|
35
|
+
private actionRouter: ActionRouter;
|
|
36
|
+
private sessionManager: SessionManager;
|
|
37
|
+
private persistentState: PersistentState;
|
|
38
|
+
private gameDefinition: GameDefinition;
|
|
39
|
+
private variables: Record<string, number> = {};
|
|
40
|
+
|
|
41
|
+
constructor(config: LuaEngineConfig) {
|
|
42
|
+
this.gameDefinition = config.gameDefinition;
|
|
43
|
+
|
|
44
|
+
// Set up RNG
|
|
45
|
+
const rng = config.seed !== undefined
|
|
46
|
+
? createSeededRng(config.seed)
|
|
47
|
+
: undefined;
|
|
48
|
+
|
|
49
|
+
// Initialize sub-managers
|
|
50
|
+
this.api = new LuaEngineAPI(config.gameDefinition, rng, config.logger);
|
|
51
|
+
this.actionRouter = new ActionRouter(config.gameDefinition);
|
|
52
|
+
this.sessionManager = new SessionManager();
|
|
53
|
+
this.persistentState = new PersistentState(config.gameDefinition.persistent_state);
|
|
54
|
+
|
|
55
|
+
// Create Lua state and load standard libraries
|
|
56
|
+
this.L = lauxlib.luaL_newstate();
|
|
57
|
+
lualib.luaL_openlibs(this.L);
|
|
58
|
+
|
|
59
|
+
// Register engine.* API
|
|
60
|
+
this.api.register(this.L);
|
|
61
|
+
|
|
62
|
+
// Load and compile the script
|
|
63
|
+
this.loadScript(config.script);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Current session data (if any) */
|
|
67
|
+
get session(): SessionData | null {
|
|
68
|
+
return this.sessionManager.current;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Current persistent state values */
|
|
72
|
+
get persistentVars(): Record<string, number> {
|
|
73
|
+
return { ...this.variables };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Execute a play action — the main entry point.
|
|
78
|
+
* This is what DevBridge calls on each PLAY_REQUEST.
|
|
79
|
+
*/
|
|
80
|
+
execute(params: PlayParams): LuaPlayResult {
|
|
81
|
+
const { action: actionName, bet, params: clientParams } = params;
|
|
82
|
+
|
|
83
|
+
// 1. Resolve the action definition
|
|
84
|
+
const action = this.actionRouter.resolveAction(
|
|
85
|
+
actionName,
|
|
86
|
+
this.sessionManager.isActive,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// 2. Build state.variables
|
|
90
|
+
const stateVars: Record<string, number> = { ...this.variables, bet };
|
|
91
|
+
|
|
92
|
+
// Load cross-spin persistent state
|
|
93
|
+
this.persistentState.loadIntoVariables(stateVars);
|
|
94
|
+
|
|
95
|
+
// Load session persistent vars
|
|
96
|
+
if (this.sessionManager.isActive) {
|
|
97
|
+
const sessionParams = this.sessionManager.getPersistentParams();
|
|
98
|
+
for (const [k, v] of Object.entries(sessionParams)) {
|
|
99
|
+
if (typeof v === 'number') {
|
|
100
|
+
stateVars[k] = v;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 3. Build state.params
|
|
106
|
+
const stateParams: Record<string, unknown> = { ...clientParams };
|
|
107
|
+
stateParams._action = actionName;
|
|
108
|
+
|
|
109
|
+
// Inject session _ps_* persistent data
|
|
110
|
+
if (this.sessionManager.isActive) {
|
|
111
|
+
const sessionParams = this.sessionManager.getPersistentParams();
|
|
112
|
+
for (const [k, v] of Object.entries(sessionParams)) {
|
|
113
|
+
if (typeof v !== 'number') {
|
|
114
|
+
stateParams[k] = v;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Inject cross-spin _ps_* game data
|
|
120
|
+
const gameDataParams = this.persistentState.getGameDataParams();
|
|
121
|
+
Object.assign(stateParams, gameDataParams);
|
|
122
|
+
|
|
123
|
+
// Handle buy bonus
|
|
124
|
+
if (action.buy_bonus_mode && this.gameDefinition.buy_bonus) {
|
|
125
|
+
const mode = this.gameDefinition.buy_bonus.modes[action.buy_bonus_mode];
|
|
126
|
+
if (mode) {
|
|
127
|
+
stateParams.buy_bonus = true;
|
|
128
|
+
stateParams.buy_bonus_mode = action.buy_bonus_mode;
|
|
129
|
+
stateParams.forced_scatter_count = this.pickFromDistribution(mode.scatter_distribution);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Handle ante bet
|
|
134
|
+
if (clientParams?.ante_bet && this.gameDefinition.ante_bet) {
|
|
135
|
+
stateParams.ante_bet = true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 4. Build the state table and call Lua execute()
|
|
139
|
+
const luaResult = this.callLuaExecute(action.stage, stateParams, stateVars);
|
|
140
|
+
|
|
141
|
+
// 5. Extract special fields from Lua result
|
|
142
|
+
const totalWinMultiplier = typeof luaResult.total_win === 'number' ? luaResult.total_win : 0;
|
|
143
|
+
const resultVariables = (luaResult.variables ?? {}) as Record<string, number>;
|
|
144
|
+
const totalWin = Math.round(totalWinMultiplier * bet * 100) / 100;
|
|
145
|
+
|
|
146
|
+
// Merge result variables into engine variables
|
|
147
|
+
Object.assign(stateVars, resultVariables);
|
|
148
|
+
this.variables = { ...stateVars };
|
|
149
|
+
delete this.variables.bet; // bet is per-spin, not persistent
|
|
150
|
+
|
|
151
|
+
// Build client data (everything except special keys)
|
|
152
|
+
const data: Record<string, unknown> = {};
|
|
153
|
+
for (const [key, value] of Object.entries(luaResult)) {
|
|
154
|
+
if (key !== 'total_win' && key !== 'variables') {
|
|
155
|
+
data[key] = value;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 6. Apply max win cap
|
|
160
|
+
let cappedWin = totalWin;
|
|
161
|
+
if (this.gameDefinition.max_win) {
|
|
162
|
+
const cap = this.calculateMaxWinCap(bet);
|
|
163
|
+
if (cap !== undefined && totalWin > cap) {
|
|
164
|
+
cappedWin = cap;
|
|
165
|
+
this.variables.max_win_reached = 1;
|
|
166
|
+
data.max_win_reached = true;
|
|
167
|
+
this.sessionManager.markMaxWinReached();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 7. Handle _persist_* and _persist_game_* keys
|
|
172
|
+
this.sessionManager.storePersistData(data);
|
|
173
|
+
this.persistentState.storeGameData(data);
|
|
174
|
+
|
|
175
|
+
// Save cross-spin persistent state
|
|
176
|
+
this.persistentState.saveFromVariables(this.variables);
|
|
177
|
+
|
|
178
|
+
// Add exposed persistent vars to client data
|
|
179
|
+
const exposedVars = this.persistentState.getExposedVars();
|
|
180
|
+
if (exposedVars) {
|
|
181
|
+
data.persistent_state = exposedVars;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Remove _persist_* keys from client data
|
|
185
|
+
for (const key of Object.keys(data)) {
|
|
186
|
+
if (key.startsWith('_persist_')) {
|
|
187
|
+
delete data[key];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 8. Evaluate transitions
|
|
192
|
+
const { rule, nextActions } = this.actionRouter.evaluateTransitions(action, this.variables);
|
|
193
|
+
let creditDeferred = action.credit === 'defer' || rule.credit_override === 'defer';
|
|
194
|
+
let session = this.sessionManager.current;
|
|
195
|
+
|
|
196
|
+
// Handle session creation
|
|
197
|
+
if (rule.creates_session && !this.sessionManager.isActive) {
|
|
198
|
+
session = this.sessionManager.createSession(rule, this.variables, bet);
|
|
199
|
+
creditDeferred = true;
|
|
200
|
+
}
|
|
201
|
+
// Handle session update
|
|
202
|
+
else if (this.sessionManager.isActive) {
|
|
203
|
+
session = this.sessionManager.updateSession(rule, this.variables, cappedWin);
|
|
204
|
+
|
|
205
|
+
// Handle session completion
|
|
206
|
+
if (rule.complete_session || session?.completed) {
|
|
207
|
+
const completed = this.sessionManager.completeSession();
|
|
208
|
+
session = completed.session;
|
|
209
|
+
creditDeferred = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
totalWin: cappedWin,
|
|
215
|
+
data,
|
|
216
|
+
nextActions,
|
|
217
|
+
session,
|
|
218
|
+
variables: { ...this.variables },
|
|
219
|
+
creditDeferred,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Reset all state (sessions, persistent vars, variables) */
|
|
224
|
+
reset(): void {
|
|
225
|
+
this.variables = {};
|
|
226
|
+
this.sessionManager.reset();
|
|
227
|
+
this.persistentState.reset();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Destroy the Lua VM */
|
|
231
|
+
destroy(): void {
|
|
232
|
+
if (this.L) {
|
|
233
|
+
lua.lua_close(this.L);
|
|
234
|
+
this.L = null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Private ──────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
private loadScript(source: string): void {
|
|
241
|
+
const status = lauxlib.luaL_dostring(this.L, to_luastring(source));
|
|
242
|
+
if (status !== lua.LUA_OK) {
|
|
243
|
+
const err = to_jsstring(lua.lua_tostring(this.L, -1));
|
|
244
|
+
lua.lua_pop(this.L, 1);
|
|
245
|
+
throw new Error(`Failed to load Lua script: ${err}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Verify that execute() function exists
|
|
249
|
+
lua.lua_getglobal(this.L, to_luastring('execute'));
|
|
250
|
+
if (lua.lua_type(this.L, -1) !== lua.LUA_TFUNCTION) {
|
|
251
|
+
lua.lua_pop(this.L, 1);
|
|
252
|
+
throw new Error('Lua script must define a global `execute(state)` function');
|
|
253
|
+
}
|
|
254
|
+
lua.lua_pop(this.L, 1);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private callLuaExecute(
|
|
258
|
+
stage: string,
|
|
259
|
+
params: Record<string, unknown>,
|
|
260
|
+
variables: Record<string, number>,
|
|
261
|
+
): Record<string, unknown> {
|
|
262
|
+
// Push the execute function
|
|
263
|
+
lua.lua_getglobal(this.L, to_luastring('execute'));
|
|
264
|
+
|
|
265
|
+
// Build and push the state table
|
|
266
|
+
lua.lua_createtable(this.L, 0, 3);
|
|
267
|
+
|
|
268
|
+
// state.stage
|
|
269
|
+
lua.lua_pushstring(this.L, to_luastring(stage));
|
|
270
|
+
lua.lua_setfield(this.L, -2, to_luastring('stage'));
|
|
271
|
+
|
|
272
|
+
// state.params
|
|
273
|
+
pushJSValue(this.L, params);
|
|
274
|
+
lua.lua_setfield(this.L, -2, to_luastring('params'));
|
|
275
|
+
|
|
276
|
+
// state.variables
|
|
277
|
+
pushJSValue(this.L, variables);
|
|
278
|
+
lua.lua_setfield(this.L, -2, to_luastring('variables'));
|
|
279
|
+
|
|
280
|
+
// Call execute(state) → 1 result
|
|
281
|
+
const status = lua.lua_pcall(this.L, 1, 1, 0);
|
|
282
|
+
if (status !== lua.LUA_OK) {
|
|
283
|
+
const err = to_jsstring(lua.lua_tostring(this.L, -1));
|
|
284
|
+
lua.lua_pop(this.L, 1);
|
|
285
|
+
throw new Error(`Lua execute() failed: ${err}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Marshal result table to JS
|
|
289
|
+
const result = luaToJS(this.L, -1);
|
|
290
|
+
lua.lua_pop(this.L, 1);
|
|
291
|
+
|
|
292
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
293
|
+
throw new Error('Lua execute() must return a table');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return result as Record<string, unknown>;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private calculateMaxWinCap(bet: number): number | undefined {
|
|
300
|
+
const mw = this.gameDefinition.max_win;
|
|
301
|
+
if (!mw) return undefined;
|
|
302
|
+
|
|
303
|
+
const caps: number[] = [];
|
|
304
|
+
if (mw.multiplier !== undefined) caps.push(bet * mw.multiplier);
|
|
305
|
+
if (mw.fixed !== undefined) caps.push(mw.fixed);
|
|
306
|
+
|
|
307
|
+
return caps.length > 0 ? Math.min(...caps) : undefined;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private pickFromDistribution(distribution: Record<string, number>): number {
|
|
311
|
+
const entries = Object.entries(distribution);
|
|
312
|
+
const totalWeight = entries.reduce((sum, [, w]) => sum + w, 0);
|
|
313
|
+
let roll = this.api.randomFloat() * totalWeight;
|
|
314
|
+
|
|
315
|
+
for (const [value, weight] of entries) {
|
|
316
|
+
roll -= weight;
|
|
317
|
+
if (roll < 0) return parseInt(value, 10);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return parseInt(entries[entries.length - 1][0], 10);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import type { GameDefinition } from './types';
|
|
2
|
+
|
|
3
|
+
// fengari is a CJS module — use dynamic import workaround for ESM compatibility
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
5
|
+
declare const require: (module: string) => any;
|
|
6
|
+
const fengari = require('fengari');
|
|
7
|
+
const { lua, lauxlib } = fengari;
|
|
8
|
+
const { to_luastring, to_jsstring } = fengari;
|
|
9
|
+
|
|
10
|
+
export type RngFunction = () => number;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Seeded xoshiro128** PRNG for deterministic simulation/replay.
|
|
14
|
+
* Period: 2^128 - 1
|
|
15
|
+
*/
|
|
16
|
+
export function createSeededRng(seed: number): RngFunction {
|
|
17
|
+
let s0 = (seed >>> 0) | 1;
|
|
18
|
+
let s1 = (seed * 1103515245 + 12345) >>> 0;
|
|
19
|
+
let s2 = (seed * 6364136223846793005 + 1442695040888963407) >>> 0;
|
|
20
|
+
let s3 = (seed * 1442695040888963407 + 6364136223846793005) >>> 0;
|
|
21
|
+
|
|
22
|
+
return (): number => {
|
|
23
|
+
const result = (((s1 * 5) << 7) * 9) >>> 0;
|
|
24
|
+
const t = s1 << 9;
|
|
25
|
+
|
|
26
|
+
s2 ^= s0;
|
|
27
|
+
s3 ^= s1;
|
|
28
|
+
s1 ^= s2;
|
|
29
|
+
s0 ^= s3;
|
|
30
|
+
s2 ^= t;
|
|
31
|
+
s3 = ((s3 << 11) | (s3 >>> 21)) >>> 0;
|
|
32
|
+
|
|
33
|
+
return result / 4294967296;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Implements and registers all platform `engine.*` functions into a Lua state.
|
|
39
|
+
*/
|
|
40
|
+
export class LuaEngineAPI {
|
|
41
|
+
private rng: RngFunction;
|
|
42
|
+
private logger: (level: string, msg: string) => void;
|
|
43
|
+
private gameDefinition: GameDefinition;
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
gameDefinition: GameDefinition,
|
|
47
|
+
rng?: RngFunction,
|
|
48
|
+
logger?: (level: string, msg: string) => void,
|
|
49
|
+
) {
|
|
50
|
+
this.gameDefinition = gameDefinition;
|
|
51
|
+
this.rng = rng ?? Math.random;
|
|
52
|
+
this.logger = logger ?? ((level, msg) => {
|
|
53
|
+
const fn = level === 'error' ? console.error
|
|
54
|
+
: level === 'warn' ? console.warn
|
|
55
|
+
: level === 'debug' ? console.debug
|
|
56
|
+
: console.log;
|
|
57
|
+
fn(`[Lua:${level}] ${msg}`);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Register `engine` global table on the Lua state */
|
|
62
|
+
register(L: any): void {
|
|
63
|
+
// Create the `engine` table
|
|
64
|
+
lua.lua_newtable(L);
|
|
65
|
+
|
|
66
|
+
this.registerFunction(L, 'random', (LS: any) => {
|
|
67
|
+
const min = lauxlib.luaL_checkinteger(LS, 1);
|
|
68
|
+
const max = lauxlib.luaL_checkinteger(LS, 2);
|
|
69
|
+
const result = this.random(Number(min), Number(max));
|
|
70
|
+
lua.lua_pushinteger(LS, result);
|
|
71
|
+
return 1;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
this.registerFunction(L, 'random_float', (LS: any) => {
|
|
75
|
+
lua.lua_pushnumber(LS, this.randomFloat());
|
|
76
|
+
return 1;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
this.registerFunction(L, 'random_weighted', (LS: any) => {
|
|
80
|
+
lauxlib.luaL_checktype(LS, 1, lua.LUA_TTABLE);
|
|
81
|
+
const weights: number[] = [];
|
|
82
|
+
const len = lua.lua_rawlen(LS, 1);
|
|
83
|
+
for (let i = 1; i <= len; i++) {
|
|
84
|
+
lua.lua_rawgeti(LS, 1, i);
|
|
85
|
+
weights.push(lua.lua_tonumber(LS, -1));
|
|
86
|
+
lua.lua_pop(LS, 1);
|
|
87
|
+
}
|
|
88
|
+
const result = this.randomWeighted(weights);
|
|
89
|
+
lua.lua_pushinteger(LS, result);
|
|
90
|
+
return 1;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
this.registerFunction(L, 'shuffle', (LS: any) => {
|
|
94
|
+
lauxlib.luaL_checktype(LS, 1, lua.LUA_TTABLE);
|
|
95
|
+
const arr: unknown[] = [];
|
|
96
|
+
const len = lua.lua_rawlen(LS, 1);
|
|
97
|
+
for (let i = 1; i <= len; i++) {
|
|
98
|
+
lua.lua_rawgeti(LS, 1, i);
|
|
99
|
+
arr.push(luaToJS(LS, -1));
|
|
100
|
+
lua.lua_pop(LS, 1);
|
|
101
|
+
}
|
|
102
|
+
const shuffled = this.shuffle(arr);
|
|
103
|
+
pushJSArray(LS, shuffled);
|
|
104
|
+
return 1;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
this.registerFunction(L, 'log', (LS: any) => {
|
|
108
|
+
const level = to_jsstring(lauxlib.luaL_checkstring(LS, 1));
|
|
109
|
+
const msg = to_jsstring(lauxlib.luaL_checkstring(LS, 2));
|
|
110
|
+
this.logger(level, msg);
|
|
111
|
+
return 0;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
this.registerFunction(L, 'get_config', (LS: any) => {
|
|
115
|
+
const config = this.getConfig();
|
|
116
|
+
pushJSObject(LS, config);
|
|
117
|
+
return 1;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Set the table as global `engine`
|
|
121
|
+
lua.lua_setglobal(L, to_luastring('engine'));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── engine.* implementations ─────────────────────────
|
|
125
|
+
|
|
126
|
+
random(min: number, max: number): number {
|
|
127
|
+
return Math.floor(this.rng() * (max - min + 1)) + min;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
randomFloat(): number {
|
|
131
|
+
return this.rng();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
randomWeighted(weights: number[]): number {
|
|
135
|
+
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
|
136
|
+
let roll = this.rng() * totalWeight;
|
|
137
|
+
for (let i = 0; i < weights.length; i++) {
|
|
138
|
+
roll -= weights[i];
|
|
139
|
+
if (roll < 0) return i + 1; // 1-based index
|
|
140
|
+
}
|
|
141
|
+
return weights.length; // fallback to last
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
shuffle<T>(arr: T[]): T[] {
|
|
145
|
+
const copy = [...arr];
|
|
146
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
147
|
+
const j = Math.floor(this.rng() * (i + 1));
|
|
148
|
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
149
|
+
}
|
|
150
|
+
return copy;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
getConfig(): Record<string, unknown> {
|
|
154
|
+
const def = this.gameDefinition;
|
|
155
|
+
let betLevels: number[] = [];
|
|
156
|
+
if (Array.isArray(def.bet_levels)) {
|
|
157
|
+
betLevels = def.bet_levels;
|
|
158
|
+
} else if (def.bet_levels && 'levels' in def.bet_levels && def.bet_levels.levels) {
|
|
159
|
+
betLevels = def.bet_levels.levels;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
id: def.id,
|
|
163
|
+
type: def.type,
|
|
164
|
+
bet_levels: betLevels,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Helpers ──────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
private registerFunction(L: any, name: string, fn: (L: any) => number): void {
|
|
171
|
+
lua.lua_pushcfunction(L, fn);
|
|
172
|
+
lua.lua_setfield(L, -2, to_luastring(name));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Lua ↔ JS marshalling ───────────────────────────────
|
|
177
|
+
|
|
178
|
+
/** Read a Lua value at the given stack index and return its JS equivalent */
|
|
179
|
+
export function luaToJS(L: any, idx: number): unknown {
|
|
180
|
+
const type = lua.lua_type(L, idx);
|
|
181
|
+
|
|
182
|
+
switch (type) {
|
|
183
|
+
case lua.LUA_TNIL:
|
|
184
|
+
return null;
|
|
185
|
+
|
|
186
|
+
case lua.LUA_TBOOLEAN:
|
|
187
|
+
return lua.lua_toboolean(L, idx);
|
|
188
|
+
|
|
189
|
+
case lua.LUA_TNUMBER:
|
|
190
|
+
if (lua.lua_isinteger(L, idx)) {
|
|
191
|
+
return Number(lua.lua_tointeger(L, idx));
|
|
192
|
+
}
|
|
193
|
+
return lua.lua_tonumber(L, idx);
|
|
194
|
+
|
|
195
|
+
case lua.LUA_TSTRING:
|
|
196
|
+
return to_jsstring(lua.lua_tostring(L, idx));
|
|
197
|
+
|
|
198
|
+
case lua.LUA_TTABLE:
|
|
199
|
+
return luaTableToJS(L, idx);
|
|
200
|
+
|
|
201
|
+
default:
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Convert a Lua table to a JS object or array */
|
|
207
|
+
function luaTableToJS(L: any, idx: number): Record<string, unknown> | unknown[] {
|
|
208
|
+
// Normalize index to absolute
|
|
209
|
+
if (idx < 0) idx = lua.lua_gettop(L) + idx + 1;
|
|
210
|
+
|
|
211
|
+
// Check if it's an array (sequential integer keys starting at 1)
|
|
212
|
+
const len = lua.lua_rawlen(L, idx);
|
|
213
|
+
if (len > 0) {
|
|
214
|
+
// Verify it's a pure array by checking key 1 exists
|
|
215
|
+
lua.lua_rawgeti(L, idx, 1);
|
|
216
|
+
const hasFirst = lua.lua_type(L, -1) !== lua.LUA_TNIL;
|
|
217
|
+
lua.lua_pop(L, 1);
|
|
218
|
+
|
|
219
|
+
if (hasFirst) {
|
|
220
|
+
// Check if there are also string keys (mixed table)
|
|
221
|
+
let hasStringKeys = false;
|
|
222
|
+
lua.lua_pushnil(L);
|
|
223
|
+
while (lua.lua_next(L, idx) !== 0) {
|
|
224
|
+
lua.lua_pop(L, 1); // pop value
|
|
225
|
+
if (lua.lua_type(L, -1) === lua.LUA_TSTRING) {
|
|
226
|
+
hasStringKeys = true;
|
|
227
|
+
lua.lua_pop(L, 1); // pop key
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!hasStringKeys) {
|
|
233
|
+
// Pure array
|
|
234
|
+
const arr: unknown[] = [];
|
|
235
|
+
for (let i = 1; i <= len; i++) {
|
|
236
|
+
lua.lua_rawgeti(L, idx, i);
|
|
237
|
+
arr.push(luaToJS(L, -1));
|
|
238
|
+
lua.lua_pop(L, 1);
|
|
239
|
+
}
|
|
240
|
+
return arr;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Object (or mixed table)
|
|
246
|
+
const obj: Record<string, unknown> = {};
|
|
247
|
+
lua.lua_pushnil(L);
|
|
248
|
+
while (lua.lua_next(L, idx) !== 0) {
|
|
249
|
+
const keyType = lua.lua_type(L, -2);
|
|
250
|
+
let key: string;
|
|
251
|
+
if (keyType === lua.LUA_TSTRING) {
|
|
252
|
+
key = to_jsstring(lua.lua_tostring(L, -2));
|
|
253
|
+
} else if (keyType === lua.LUA_TNUMBER) {
|
|
254
|
+
key = String(lua.lua_tonumber(L, -2));
|
|
255
|
+
} else {
|
|
256
|
+
lua.lua_pop(L, 1);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
obj[key] = luaToJS(L, -1);
|
|
260
|
+
lua.lua_pop(L, 1);
|
|
261
|
+
}
|
|
262
|
+
return obj;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Push a JS value onto the Lua stack */
|
|
266
|
+
export function pushJSValue(L: any, value: unknown): void {
|
|
267
|
+
if (value === null || value === undefined) {
|
|
268
|
+
lua.lua_pushnil(L);
|
|
269
|
+
} else if (typeof value === 'boolean') {
|
|
270
|
+
lua.lua_pushboolean(L, value ? 1 : 0);
|
|
271
|
+
} else if (typeof value === 'number') {
|
|
272
|
+
if (Number.isInteger(value)) {
|
|
273
|
+
lua.lua_pushinteger(L, value);
|
|
274
|
+
} else {
|
|
275
|
+
lua.lua_pushnumber(L, value);
|
|
276
|
+
}
|
|
277
|
+
} else if (typeof value === 'string') {
|
|
278
|
+
lua.lua_pushstring(L, to_luastring(value));
|
|
279
|
+
} else if (Array.isArray(value)) {
|
|
280
|
+
pushJSArray(L, value);
|
|
281
|
+
} else if (typeof value === 'object') {
|
|
282
|
+
pushJSObject(L, value as Record<string, unknown>);
|
|
283
|
+
} else {
|
|
284
|
+
lua.lua_pushnil(L);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Push a JS array as a Lua table (1-based) */
|
|
289
|
+
function pushJSArray(L: any, arr: unknown[]): void {
|
|
290
|
+
lua.lua_createtable(L, arr.length, 0);
|
|
291
|
+
for (let i = 0; i < arr.length; i++) {
|
|
292
|
+
pushJSValue(L, arr[i]);
|
|
293
|
+
lua.lua_rawseti(L, -2, i + 1);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Push a JS object as a Lua table */
|
|
298
|
+
function pushJSObject(L: any, obj: Record<string, unknown>): void {
|
|
299
|
+
const keys = Object.keys(obj);
|
|
300
|
+
lua.lua_createtable(L, 0, keys.length);
|
|
301
|
+
for (const key of keys) {
|
|
302
|
+
pushJSValue(L, obj[key]);
|
|
303
|
+
lua.lua_setfield(L, -2, to_luastring(key));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
}
|