@energy8platform/platform-core 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.
Files changed (57) hide show
  1. package/README.md +482 -0
  2. package/bin/simulate.ts +139 -0
  3. package/dist/dev-bridge.cjs.js +237 -0
  4. package/dist/dev-bridge.cjs.js.map +1 -0
  5. package/dist/dev-bridge.d.ts +141 -0
  6. package/dist/dev-bridge.esm.js +235 -0
  7. package/dist/dev-bridge.esm.js.map +1 -0
  8. package/dist/index.cjs.js +569 -0
  9. package/dist/index.cjs.js.map +1 -0
  10. package/dist/index.d.ts +439 -0
  11. package/dist/index.esm.js +560 -0
  12. package/dist/index.esm.js.map +1 -0
  13. package/dist/loading.cjs.js +190 -0
  14. package/dist/loading.cjs.js.map +1 -0
  15. package/dist/loading.d.ts +86 -0
  16. package/dist/loading.esm.js +185 -0
  17. package/dist/loading.esm.js.map +1 -0
  18. package/dist/lua.cjs.js +1129 -0
  19. package/dist/lua.cjs.js.map +1 -0
  20. package/dist/lua.d.ts +319 -0
  21. package/dist/lua.esm.js +1119 -0
  22. package/dist/lua.esm.js.map +1 -0
  23. package/dist/simulation.cjs.js +374 -0
  24. package/dist/simulation.cjs.js.map +1 -0
  25. package/dist/simulation.d.ts +190 -0
  26. package/dist/simulation.esm.js +368 -0
  27. package/dist/simulation.esm.js.map +1 -0
  28. package/dist/vite.cjs.js +179 -0
  29. package/dist/vite.cjs.js.map +1 -0
  30. package/dist/vite.d.ts +13 -0
  31. package/dist/vite.esm.js +176 -0
  32. package/dist/vite.esm.js.map +1 -0
  33. package/package.json +100 -0
  34. package/scripts/install-simulate.mjs +101 -0
  35. package/src/EventEmitter.ts +55 -0
  36. package/src/PlatformSession.ts +156 -0
  37. package/src/dev-bridge/DevBridge.ts +305 -0
  38. package/src/dev-bridge/index.ts +2 -0
  39. package/src/index.ts +98 -0
  40. package/src/loading/CSSPreloader.ts +129 -0
  41. package/src/loading/index.ts +3 -0
  42. package/src/loading/logo.ts +95 -0
  43. package/src/lua/ActionRouter.ts +132 -0
  44. package/src/lua/LuaEngine.ts +412 -0
  45. package/src/lua/LuaEngineAPI.ts +314 -0
  46. package/src/lua/PersistentState.ts +80 -0
  47. package/src/lua/SessionManager.ts +227 -0
  48. package/src/lua/SimulationRunner.ts +192 -0
  49. package/src/lua/fengari.d.ts +10 -0
  50. package/src/lua/index.ts +28 -0
  51. package/src/lua/types.ts +149 -0
  52. package/src/simulation/NativeSimulationRunner.ts +367 -0
  53. package/src/simulation/ParallelSimulationRunner.ts +156 -0
  54. package/src/simulation/SimulationWorker.ts +44 -0
  55. package/src/simulation/index.ts +21 -0
  56. package/src/types.ts +85 -0
  57. package/src/vite/index.ts +196 -0
@@ -0,0 +1,132 @@
1
+ import type { ActionDefinition, TransitionRule, GameDefinition } from './types';
2
+
3
+ export interface TransitionMatch {
4
+ rule: TransitionRule;
5
+ nextActions: string[];
6
+ }
7
+
8
+ /**
9
+ * Replicates the platform's action dispatch and transition evaluation.
10
+ * Routes play requests to the correct action, evaluates transition conditions
11
+ * against current variables to determine next actions and session operations.
12
+ */
13
+ export class ActionRouter {
14
+ private actions: Record<string, ActionDefinition>;
15
+
16
+ constructor(gameDefinition: GameDefinition) {
17
+ this.actions = gameDefinition.actions;
18
+ }
19
+
20
+ /** Look up action by name and validate prerequisites */
21
+ resolveAction(actionName: string, hasSession: boolean): ActionDefinition {
22
+ const action = this.actions[actionName];
23
+ if (!action) {
24
+ throw new Error(`Unknown action: "${actionName}". Available: ${Object.keys(this.actions).join(', ')}`);
25
+ }
26
+ if (action.requires_session && !hasSession) {
27
+ throw new Error(`Action "${actionName}" requires an active session`);
28
+ }
29
+ return action;
30
+ }
31
+
32
+ /** Evaluate transitions in order, return the first matching rule */
33
+ evaluateTransitions(
34
+ action: ActionDefinition,
35
+ variables: Record<string, number>,
36
+ ): TransitionMatch {
37
+ for (const rule of action.transitions) {
38
+ if (evaluateCondition(rule.condition, variables)) {
39
+ return { rule, nextActions: rule.next_actions };
40
+ }
41
+ }
42
+ throw new Error(
43
+ `No matching transition for action with stage "${action.stage}". ` +
44
+ `Variables: ${JSON.stringify(variables)}`
45
+ );
46
+ }
47
+ }
48
+
49
+ // ─── Condition Evaluator ────────────────────────────────
50
+
51
+ /**
52
+ * Evaluates a transition condition expression against variables.
53
+ *
54
+ * Supports:
55
+ * - "always" → true
56
+ * - Simple comparisons: "var > 0", "var == 1", "var >= 10", "var != 0", "var < 5", "var <= 3"
57
+ * - Logical connectives: "expr && expr", "expr || expr"
58
+ *
59
+ * This covers all patterns used by the platform's govaluate conditions.
60
+ */
61
+ export function evaluateCondition(
62
+ condition: string,
63
+ variables: Record<string, number>,
64
+ ): boolean {
65
+ const trimmed = condition.trim();
66
+
67
+ if (trimmed === 'always') return true;
68
+
69
+ // Handle || (OR) — lowest precedence
70
+ if (trimmed.includes('||')) {
71
+ const parts = splitOnOperator(trimmed, '||');
72
+ return parts.some(part => evaluateCondition(part, variables));
73
+ }
74
+
75
+ // Handle && (AND)
76
+ if (trimmed.includes('&&')) {
77
+ const parts = splitOnOperator(trimmed, '&&');
78
+ return parts.every(part => evaluateCondition(part, variables));
79
+ }
80
+
81
+ // Single comparison: "variable op value"
82
+ return evaluateComparison(trimmed, variables);
83
+ }
84
+
85
+ function splitOnOperator(expr: string, operator: string): string[] {
86
+ const parts: string[] = [];
87
+ let depth = 0;
88
+ let current = '';
89
+
90
+ for (let i = 0; i < expr.length; i++) {
91
+ if (expr[i] === '(') depth++;
92
+ else if (expr[i] === ')') depth--;
93
+
94
+ if (depth === 0 && expr.substring(i, i + operator.length) === operator) {
95
+ parts.push(current);
96
+ current = '';
97
+ i += operator.length - 1;
98
+ } else {
99
+ current += expr[i];
100
+ }
101
+ }
102
+ parts.push(current);
103
+ return parts;
104
+ }
105
+
106
+ function evaluateComparison(
107
+ expr: string,
108
+ variables: Record<string, number>,
109
+ ): boolean {
110
+ // Match: variable_name operator value
111
+ const match = expr.trim().match(
112
+ /^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(>=|<=|!=|==|>|<)\s*(-?\d+(?:\.\d+)?)\s*$/
113
+ );
114
+
115
+ if (!match) {
116
+ throw new Error(`Cannot parse condition: "${expr}"`);
117
+ }
118
+
119
+ const [, varName, op, valueStr] = match;
120
+ const left = variables[varName] ?? 0;
121
+ const right = parseFloat(valueStr);
122
+
123
+ switch (op) {
124
+ case '>': return left > right;
125
+ case '>=': return left >= right;
126
+ case '<': return left < right;
127
+ case '<=': return left <= right;
128
+ case '==': return left === right;
129
+ case '!=': return left !== right;
130
+ default: return false;
131
+ }
132
+ }
@@ -0,0 +1,412 @@
1
+ import type { PlayParams, SessionData } from '@energy8platform/game-sdk';
2
+ import type { LuaEngineConfig, LuaPlayResult, GameDefinition } from './types';
3
+ import { LuaEngineAPI, createSeededRng, luaToJS, pushJSValue, cachedToLuastring } 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
+ import fengari from 'fengari';
10
+
11
+ const { lua, lauxlib, lualib } = fengari;
12
+ const { to_luastring, to_jsstring } = fengari;
13
+
14
+ /** Default engine variables matching the server's NewGameState() */
15
+ const DEFAULT_VARIABLES: Record<string, number> = {
16
+ multiplier: 1,
17
+ total_multiplier: 1,
18
+ global_multiplier: 1,
19
+ last_win_amount: 0,
20
+ free_spins_awarded: 0,
21
+ };
22
+
23
+ /**
24
+ * Runs Lua game scripts locally, replicating the platform's server-side execution.
25
+ *
26
+ * Implements the full lifecycle matching `casino_platform/internal/usecase/game_usecase.go`:
27
+ * action routing → state assembly → Lua execute() → result extraction →
28
+ * transition evaluation → session management.
29
+ */
30
+ export class LuaEngine {
31
+ private L: any;
32
+ private api: LuaEngineAPI;
33
+ private actionRouter: ActionRouter;
34
+ private sessionManager: SessionManager;
35
+ private persistentState: PersistentState;
36
+ private gameDefinition: GameDefinition;
37
+ private variables: Record<string, number> = {};
38
+ private simulationMode: boolean;
39
+ /** Reusable state objects to avoid per-iteration allocation */
40
+ private _stateVars: Record<string, number> = {};
41
+ private _stateParams: Record<string, unknown> = {};
42
+
43
+ constructor(config: LuaEngineConfig) {
44
+ this.gameDefinition = config.gameDefinition;
45
+ this.simulationMode = config.simulationMode ?? false;
46
+
47
+ const rng = config.seed !== undefined
48
+ ? createSeededRng(config.seed)
49
+ : undefined;
50
+
51
+ this.api = new LuaEngineAPI(config.gameDefinition, rng, config.logger);
52
+ this.actionRouter = new ActionRouter(config.gameDefinition);
53
+ this.sessionManager = new SessionManager();
54
+ this.persistentState = new PersistentState(config.gameDefinition.persistent_state);
55
+
56
+ this.L = lauxlib.luaL_newstate();
57
+ lualib.luaL_openlibs(this.L);
58
+
59
+ // Polyfill Lua 5.1/5.2 functions removed in 5.3
60
+ lauxlib.luaL_dostring(this.L, to_luastring(`
61
+ math.pow = function(a, b) return a ^ b end
62
+ math.atan2 = math.atan2 or function(y, x) return math.atan(y, x) end
63
+ math.log10 = math.log10 or function(x) return math.log(x, 10) end
64
+ math.cosh = math.cosh or function(x) return (math.exp(x) + math.exp(-x)) / 2 end
65
+ math.sinh = math.sinh or function(x) return (math.exp(x) - math.exp(-x)) / 2 end
66
+ math.tanh = math.tanh or function(x) return math.sinh(x) / math.cosh(x) end
67
+ math.frexp = math.frexp or function(x)
68
+ if x == 0 then return 0, 0 end
69
+ local e = math.floor(math.log(math.abs(x), 2)) + 1
70
+ return x / (2 ^ e), e
71
+ end
72
+ math.ldexp = math.ldexp or function(m, e) return m * (2 ^ e) end
73
+ unpack = unpack or table.unpack
74
+ loadstring = loadstring or load
75
+ table.getn = table.getn or function(t) return #t end
76
+ `));
77
+
78
+ this.api.register(this.L);
79
+ this.loadScript(config.script);
80
+ }
81
+
82
+ get session(): SessionData | null {
83
+ return this.sessionManager.current;
84
+ }
85
+
86
+ get persistentVars(): Record<string, number> {
87
+ return { ...this.variables };
88
+ }
89
+
90
+ /**
91
+ * Execute a play action — replicates server's Play() function.
92
+ */
93
+ execute(params: PlayParams): LuaPlayResult {
94
+ const { action: actionName, params: clientParams } = params;
95
+
96
+ // 1. Resolve action
97
+ const action = this.actionRouter.resolveAction(
98
+ actionName,
99
+ this.sessionManager.isActive,
100
+ );
101
+
102
+ // 2. Determine bet — server uses session bet for session actions
103
+ let bet = params.bet;
104
+ if (this.sessionManager.isActive && this.sessionManager.sessionBet !== undefined) {
105
+ bet = this.sessionManager.sessionBet;
106
+ }
107
+
108
+ // 3. Build state.variables (matching server's NewGameState + restore)
109
+ // Reuse pooled object to avoid per-iteration allocation
110
+ const stateVars = this._stateVars;
111
+ // Clear previous keys
112
+ for (const key in stateVars) delete stateVars[key];
113
+ // Apply defaults, then engine vars, then bet
114
+ Object.assign(stateVars, DEFAULT_VARIABLES, this.variables);
115
+ stateVars.bet = bet;
116
+
117
+ // Load cross-spin persistent state
118
+ this.persistentState.loadIntoVariables(stateVars);
119
+
120
+ // Load session persistent vars + restore spinsRemaining
121
+ if (this.sessionManager.isActive) {
122
+ const sessionParams = this.sessionManager.getPersistentParams();
123
+ for (const [k, v] of Object.entries(sessionParams)) {
124
+ if (typeof v === 'number') {
125
+ stateVars[k] = v;
126
+ }
127
+ }
128
+ // Restore spinsRemaining into the variable the script reads
129
+ if (this.sessionManager.spinsVarName) {
130
+ stateVars[this.sessionManager.spinsVarName] = this.sessionManager.spinsRemaining;
131
+ }
132
+ // Also set free_spins_remaining for convenience
133
+ stateVars.free_spins_remaining = this.sessionManager.spinsRemaining;
134
+ }
135
+
136
+ // 4. Build state.params (reuse pooled object)
137
+ const stateParams = this._stateParams;
138
+ for (const key in stateParams) delete stateParams[key];
139
+ if (clientParams) Object.assign(stateParams, clientParams);
140
+ stateParams._action = actionName;
141
+
142
+ // Inject session _ps_* persistent data
143
+ if (this.sessionManager.isActive) {
144
+ const sessionParams = this.sessionManager.getPersistentParams();
145
+ for (const [k, v] of Object.entries(sessionParams)) {
146
+ if (typeof v !== 'number') {
147
+ stateParams[k] = v;
148
+ }
149
+ }
150
+ }
151
+
152
+ // Inject cross-spin _ps_* game data
153
+ const gameDataParams = this.persistentState.getGameDataParams();
154
+ Object.assign(stateParams, gameDataParams);
155
+
156
+ // Handle buy bonus
157
+ if (action.buy_bonus_mode && this.gameDefinition.buy_bonus) {
158
+ const mode = this.gameDefinition.buy_bonus.modes[action.buy_bonus_mode];
159
+ if (mode) {
160
+ stateParams.buy_bonus = true;
161
+ stateParams.buy_bonus_mode = action.buy_bonus_mode;
162
+ if (mode.scatter_distribution) {
163
+ stateParams.forced_scatter_count = this.pickFromDistribution(mode.scatter_distribution);
164
+ }
165
+ }
166
+ }
167
+
168
+ // Handle ante bet
169
+ if (clientParams?.ante_bet && this.gameDefinition.ante_bet) {
170
+ stateParams.ante_bet = true;
171
+ }
172
+
173
+ // 5. Execute Lua (server: executor.Execute(stage, state))
174
+ const luaResult = this.callLuaExecute(action.stage, actionName, stateParams, stateVars);
175
+
176
+ // 6. Process result (server: ApplyLuaResult)
177
+ const totalWinMultiplier = typeof luaResult.total_win === 'number' ? luaResult.total_win : 0;
178
+ const resultVariables = (luaResult.variables ?? {}) as Record<string, number>;
179
+ const spinWin = Math.round(totalWinMultiplier * bet * 100) / 100;
180
+
181
+ // Merge ONLY Lua return variables into engine state (not the whole stateVars).
182
+ // On the server, state.Variables is a temporary object rebuilt each call.
183
+ // Only the Lua result's `variables` table persists between calls.
184
+ Object.assign(this.variables, resultVariables);
185
+ // Also update stateVars for transition evaluation below
186
+ Object.assign(stateVars, resultVariables);
187
+
188
+ // Build client data (everything except special keys)
189
+ const data: Record<string, unknown> = {};
190
+ for (const [key, value] of Object.entries(luaResult)) {
191
+ if (key !== 'total_win' && key !== 'variables') {
192
+ data[key] = value;
193
+ }
194
+ }
195
+
196
+ // 7. Handle _persist_* and _persist_game_* keys
197
+ this.sessionManager.storePersistData(data);
198
+ this.persistentState.storeGameData(data);
199
+
200
+ // Save cross-spin persistent state (from stateVars which has Lua result merged)
201
+ this.persistentState.saveFromVariables(stateVars);
202
+
203
+ // Add exposed persistent vars to client data
204
+ const exposedVars = this.persistentState.getExposedVars();
205
+ if (exposedVars) {
206
+ data.persistent_state = exposedVars;
207
+ }
208
+
209
+ // Remove _persist_* keys from client data
210
+ for (const key of Object.keys(data)) {
211
+ if (key.startsWith('_persist_')) {
212
+ delete data[key];
213
+ }
214
+ }
215
+
216
+ // 8. Evaluate transitions (server uses state.Variables which is stateVars)
217
+ const { rule, nextActions } = this.actionRouter.evaluateTransitions(action, stateVars);
218
+
219
+ // 9. Determine credit behavior (server: creditNow logic)
220
+ let creditDeferred = action.credit === 'defer' || rule.credit_override === 'defer';
221
+
222
+ // 10. Session lifecycle (server: create/update/complete session)
223
+ let session = this.sessionManager.current;
224
+ let resultTotalWin = spinWin;
225
+ let sessionCompleted = false;
226
+
227
+ // Calculate max win cap for session
228
+ const maxWinCap = this.calculateMaxWinCap(bet);
229
+
230
+ if (rule.creates_session && !this.sessionManager.isActive) {
231
+ // CREATE SESSION — initial spin counted (server: createSession includes spinWin)
232
+ session = this.sessionManager.createSession(rule, stateVars, bet, spinWin, maxWinCap);
233
+ creditDeferred = true;
234
+ resultTotalWin = spinWin;
235
+
236
+ // Clear the trigger variable — it was consumed to set spinsRemaining
237
+ if (rule.session_config?.total_spins_var) {
238
+ delete this.variables[rule.session_config.total_spins_var];
239
+ }
240
+ } else if (this.sessionManager.isActive) {
241
+ // UPDATE SESSION — accumulate win, check completion
242
+ session = this.sessionManager.updateSession(rule, stateVars, spinWin);
243
+
244
+ if (session?.completed) {
245
+ // SESSION COMPLETED — server returns session.TotalWin as result.TotalWin
246
+ const completed = this.sessionManager.completeSession();
247
+ session = completed.session;
248
+ resultTotalWin = completed.totalWin;
249
+ sessionCompleted = true;
250
+ creditDeferred = false;
251
+
252
+ // Clean up session-scoped variables
253
+ for (const varName of completed.sessionVarNames) {
254
+ delete this.variables[varName];
255
+ }
256
+ } else {
257
+ // Mid-session: totalWin = spinWin, credit deferred
258
+ resultTotalWin = spinWin;
259
+ creditDeferred = true;
260
+ }
261
+ }
262
+ // No session: resultTotalWin = spinWin (already set)
263
+
264
+ // Apply max win cap for non-session spins
265
+ if (!this.sessionManager.isActive && !sessionCompleted && maxWinCap !== undefined && resultTotalWin > maxWinCap) {
266
+ resultTotalWin = maxWinCap;
267
+ this.variables.max_win_reached = 1;
268
+ data.max_win_reached = true;
269
+ }
270
+
271
+ return {
272
+ totalWin: Math.round(resultTotalWin * 100) / 100,
273
+ data,
274
+ nextActions,
275
+ session,
276
+ // In simulation mode, return reference directly (caller only reads, never mutates)
277
+ variables: this.simulationMode ? this.variables : { ...this.variables },
278
+ creditDeferred,
279
+ };
280
+ }
281
+
282
+ reset(): void {
283
+ this.variables = {};
284
+ this.sessionManager.reset();
285
+ this.persistentState.reset();
286
+ }
287
+
288
+ destroy(): void {
289
+ if (this.L) {
290
+ lua.lua_close(this.L);
291
+ this.L = null;
292
+ }
293
+ }
294
+
295
+ // ─── Private ──────────────────────────────────────────
296
+
297
+ private loadScript(source: string): void {
298
+ const status = lauxlib.luaL_dostring(this.L, to_luastring(source));
299
+ if (status !== lua.LUA_OK) {
300
+ const err = to_jsstring(lua.lua_tostring(this.L, -1));
301
+ lua.lua_pop(this.L, 1);
302
+ throw new Error(`Failed to load Lua script: ${err}`);
303
+ }
304
+
305
+ lua.lua_getglobal(this.L, cachedToLuastring('execute'));
306
+ if (lua.lua_type(this.L, -1) !== lua.LUA_TFUNCTION) {
307
+ lua.lua_pop(this.L, 1);
308
+ throw new Error('Lua script must define a global `execute(state)` function');
309
+ }
310
+ lua.lua_pop(this.L, 1);
311
+ }
312
+
313
+ private callLuaExecute(
314
+ stage: string,
315
+ action: string,
316
+ params: Record<string, unknown>,
317
+ variables: Record<string, number>,
318
+ ): Record<string, unknown> {
319
+ lua.lua_getglobal(this.L, cachedToLuastring('execute'));
320
+
321
+ // Build state table: {stage, action, params, variables}
322
+ lua.lua_createtable(this.L, 0, 4);
323
+
324
+ // state.stage
325
+ lua.lua_pushstring(this.L, cachedToLuastring(stage));
326
+ lua.lua_setfield(this.L, -2, cachedToLuastring('stage'));
327
+
328
+ // state.action (server sets this at top level)
329
+ lua.lua_pushstring(this.L, cachedToLuastring(action));
330
+ lua.lua_setfield(this.L, -2, cachedToLuastring('action'));
331
+
332
+ // state.params
333
+ pushJSValue(this.L, params);
334
+ lua.lua_setfield(this.L, -2, cachedToLuastring('params'));
335
+
336
+ // state.variables
337
+ pushJSValue(this.L, variables);
338
+ lua.lua_setfield(this.L, -2, cachedToLuastring('variables'));
339
+
340
+ const status = lua.lua_pcall(this.L, 1, 1, 0);
341
+ if (status !== lua.LUA_OK) {
342
+ const err = to_jsstring(lua.lua_tostring(this.L, -1));
343
+ lua.lua_pop(this.L, 1);
344
+ throw new Error(`Lua execute() failed: ${err}`);
345
+ }
346
+
347
+ if (this.simulationMode) {
348
+ // Fast path: extract only total_win, variables, _persist_* keys
349
+ const result: Record<string, unknown> = {};
350
+
351
+ lua.lua_getfield(this.L, -1, cachedToLuastring('total_win'));
352
+ result.total_win = lua.lua_type(this.L, -1) === lua.LUA_TNUMBER
353
+ ? lua.lua_tonumber(this.L, -1) : 0;
354
+ lua.lua_pop(this.L, 1);
355
+
356
+ lua.lua_getfield(this.L, -1, cachedToLuastring('variables'));
357
+ if (lua.lua_type(this.L, -1) === lua.LUA_TTABLE) {
358
+ result.variables = luaToJS(this.L, -1);
359
+ }
360
+ lua.lua_pop(this.L, 1);
361
+
362
+ // Scan for _persist_* keys (different stages may or may not have them)
363
+ lua.lua_pushnil(this.L);
364
+ while (lua.lua_next(this.L, -2) !== 0) {
365
+ if (lua.lua_type(this.L, -2) === lua.LUA_TSTRING) {
366
+ const key = to_jsstring(lua.lua_tostring(this.L, -2));
367
+ if (key.startsWith('_persist_')) {
368
+ result[key] = luaToJS(this.L, -1);
369
+ }
370
+ }
371
+ lua.lua_pop(this.L, 1);
372
+ }
373
+
374
+ lua.lua_pop(this.L, 1);
375
+ return result;
376
+ }
377
+
378
+ // Full path
379
+ const result = luaToJS(this.L, -1);
380
+ lua.lua_pop(this.L, 1);
381
+
382
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
383
+ throw new Error('Lua execute() must return a table');
384
+ }
385
+
386
+ return result as Record<string, unknown>;
387
+ }
388
+
389
+ private calculateMaxWinCap(bet: number): number | undefined {
390
+ const mw = this.gameDefinition.max_win;
391
+ if (!mw) return undefined;
392
+
393
+ const caps: number[] = [];
394
+ if (mw.multiplier !== undefined) caps.push(bet * mw.multiplier);
395
+ if (mw.fixed !== undefined) caps.push(mw.fixed);
396
+
397
+ return caps.length > 0 ? Math.min(...caps) : undefined;
398
+ }
399
+
400
+ private pickFromDistribution(distribution: Record<string, number>): number {
401
+ const entries = Object.entries(distribution);
402
+ const totalWeight = entries.reduce((sum, [, w]) => sum + w, 0);
403
+ let roll = this.api.randomFloat() * totalWeight;
404
+
405
+ for (const [value, weight] of entries) {
406
+ roll -= weight;
407
+ if (roll < 0) return parseInt(value, 10);
408
+ }
409
+
410
+ return parseInt(entries[entries.length - 1][0], 10);
411
+ }
412
+ }