@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,314 @@
1
+ import type { GameDefinition } from './types';
2
+ import fengari from 'fengari';
3
+
4
+ const { lua, lauxlib } = fengari;
5
+ const { to_luastring, to_jsstring } = fengari;
6
+
7
+ export type RngFunction = () => number;
8
+
9
+ /** Cache for to_luastring() results — avoids re-encoding the same keys every iteration */
10
+ const luaStringCache = new Map<string, Uint8Array>();
11
+
12
+ export function cachedToLuastring(s: string): Uint8Array {
13
+ let cached = luaStringCache.get(s);
14
+ if (!cached) {
15
+ cached = to_luastring(s);
16
+ luaStringCache.set(s, cached);
17
+ }
18
+ return cached;
19
+ }
20
+
21
+ /**
22
+ * Seeded xoshiro128** PRNG for deterministic simulation/replay.
23
+ * Period: 2^128 - 1
24
+ */
25
+ export function createSeededRng(seed: number): RngFunction {
26
+ let s0 = (seed >>> 0) | 1;
27
+ let s1 = (seed * 1103515245 + 12345) >>> 0;
28
+ let s2 = (seed * 6364136223846793005 + 1442695040888963407) >>> 0;
29
+ let s3 = (seed * 1442695040888963407 + 6364136223846793005) >>> 0;
30
+
31
+ return (): number => {
32
+ const result = (((s1 * 5) << 7) * 9) >>> 0;
33
+ const t = s1 << 9;
34
+
35
+ s2 ^= s0;
36
+ s3 ^= s1;
37
+ s1 ^= s2;
38
+ s0 ^= s3;
39
+ s2 ^= t;
40
+ s3 = ((s3 << 11) | (s3 >>> 21)) >>> 0;
41
+
42
+ return result / 4294967296;
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Implements and registers all platform `engine.*` functions into a Lua state.
48
+ */
49
+ export class LuaEngineAPI {
50
+ private rng: RngFunction;
51
+ private logger: (level: string, msg: string) => void;
52
+ private gameDefinition: GameDefinition;
53
+
54
+ constructor(
55
+ gameDefinition: GameDefinition,
56
+ rng?: RngFunction,
57
+ logger?: (level: string, msg: string) => void,
58
+ ) {
59
+ this.gameDefinition = gameDefinition;
60
+ this.rng = rng ?? Math.random;
61
+ this.logger = logger ?? ((level, msg) => {
62
+ const fn = level === 'error' ? console.error
63
+ : level === 'warn' ? console.warn
64
+ : level === 'debug' ? console.debug
65
+ : console.log;
66
+ fn(`[Lua:${level}] ${msg}`);
67
+ });
68
+ }
69
+
70
+ /** Register `engine` global table on the Lua state */
71
+ register(L: any): void {
72
+ // Create the `engine` table
73
+ lua.lua_newtable(L);
74
+
75
+ this.registerFunction(L, 'random', (LS: any) => {
76
+ const min = lauxlib.luaL_checkinteger(LS, 1);
77
+ const max = lauxlib.luaL_checkinteger(LS, 2);
78
+ const result = this.random(Number(min), Number(max));
79
+ lua.lua_pushinteger(LS, result);
80
+ return 1;
81
+ });
82
+
83
+ this.registerFunction(L, 'random_float', (LS: any) => {
84
+ lua.lua_pushnumber(LS, this.randomFloat());
85
+ return 1;
86
+ });
87
+
88
+ this.registerFunction(L, 'random_weighted', (LS: any) => {
89
+ lauxlib.luaL_checktype(LS, 1, lua.LUA_TTABLE);
90
+ const weights: number[] = [];
91
+ const len = lua.lua_rawlen(LS, 1);
92
+ for (let i = 1; i <= len; i++) {
93
+ lua.lua_rawgeti(LS, 1, i);
94
+ weights.push(lua.lua_tonumber(LS, -1));
95
+ lua.lua_pop(LS, 1);
96
+ }
97
+ const result = this.randomWeighted(weights);
98
+ lua.lua_pushinteger(LS, result);
99
+ return 1;
100
+ });
101
+
102
+ this.registerFunction(L, 'shuffle', (LS: any) => {
103
+ lauxlib.luaL_checktype(LS, 1, lua.LUA_TTABLE);
104
+ const arr: unknown[] = [];
105
+ const len = lua.lua_rawlen(LS, 1);
106
+ for (let i = 1; i <= len; i++) {
107
+ lua.lua_rawgeti(LS, 1, i);
108
+ arr.push(luaToJS(LS, -1));
109
+ lua.lua_pop(LS, 1);
110
+ }
111
+ const shuffled = this.shuffle(arr);
112
+ pushJSArray(LS, shuffled);
113
+ return 1;
114
+ });
115
+
116
+ this.registerFunction(L, 'log', (LS: any) => {
117
+ const level = to_jsstring(lauxlib.luaL_checkstring(LS, 1));
118
+ const msg = to_jsstring(lauxlib.luaL_checkstring(LS, 2));
119
+ this.logger(level, msg);
120
+ return 0;
121
+ });
122
+
123
+ this.registerFunction(L, 'get_config', (LS: any) => {
124
+ const config = this.getConfig();
125
+ pushJSObject(LS, config);
126
+ return 1;
127
+ });
128
+
129
+ // Set the table as global `engine`
130
+ lua.lua_setglobal(L, to_luastring('engine'));
131
+ }
132
+
133
+ // ─── engine.* implementations ─────────────────────────
134
+
135
+ random(min: number, max: number): number {
136
+ return Math.floor(this.rng() * (max - min + 1)) + min;
137
+ }
138
+
139
+ randomFloat(): number {
140
+ return this.rng();
141
+ }
142
+
143
+ randomWeighted(weights: number[]): number {
144
+ const totalWeight = weights.reduce((a, b) => a + b, 0);
145
+ let roll = this.rng() * totalWeight;
146
+ for (let i = 0; i < weights.length; i++) {
147
+ roll -= weights[i];
148
+ if (roll < 0) return i + 1; // 1-based index
149
+ }
150
+ return weights.length; // fallback to last
151
+ }
152
+
153
+ shuffle<T>(arr: T[]): T[] {
154
+ const copy = [...arr];
155
+ for (let i = copy.length - 1; i > 0; i--) {
156
+ const j = Math.floor(this.rng() * (i + 1));
157
+ [copy[i], copy[j]] = [copy[j], copy[i]];
158
+ }
159
+ return copy;
160
+ }
161
+
162
+ getConfig(): Record<string, unknown> {
163
+ const def = this.gameDefinition;
164
+ let betLevels: number[] = [];
165
+ if (Array.isArray(def.bet_levels)) {
166
+ betLevels = def.bet_levels;
167
+ } else if (def.bet_levels && 'levels' in def.bet_levels && def.bet_levels.levels) {
168
+ betLevels = def.bet_levels.levels;
169
+ }
170
+ return {
171
+ id: def.id,
172
+ type: def.type,
173
+ bet_levels: betLevels,
174
+ };
175
+ }
176
+
177
+ // ─── Helpers ──────────────────────────────────────────
178
+
179
+ private registerFunction(L: any, name: string, fn: (L: any) => number): void {
180
+ lua.lua_pushcfunction(L, fn);
181
+ lua.lua_setfield(L, -2, to_luastring(name));
182
+ }
183
+ }
184
+
185
+ // ─── Lua ↔ JS marshalling ───────────────────────────────
186
+
187
+ /** Read a Lua value at the given stack index and return its JS equivalent */
188
+ export function luaToJS(L: any, idx: number): unknown {
189
+ const type = lua.lua_type(L, idx);
190
+
191
+ switch (type) {
192
+ case lua.LUA_TNIL:
193
+ return null;
194
+
195
+ case lua.LUA_TBOOLEAN:
196
+ return lua.lua_toboolean(L, idx);
197
+
198
+ case lua.LUA_TNUMBER:
199
+ if (lua.lua_isinteger(L, idx)) {
200
+ return Number(lua.lua_tointeger(L, idx));
201
+ }
202
+ return lua.lua_tonumber(L, idx);
203
+
204
+ case lua.LUA_TSTRING:
205
+ return to_jsstring(lua.lua_tostring(L, idx));
206
+
207
+ case lua.LUA_TTABLE:
208
+ return luaTableToJS(L, idx);
209
+
210
+ default:
211
+ return null;
212
+ }
213
+ }
214
+
215
+ /** Convert a Lua table to a JS object or array */
216
+ function luaTableToJS(L: any, idx: number): Record<string, unknown> | unknown[] {
217
+ // Normalize index to absolute
218
+ if (idx < 0) idx = lua.lua_gettop(L) + idx + 1;
219
+
220
+ // Check if it's an array (sequential integer keys starting at 1)
221
+ const len = lua.lua_rawlen(L, idx);
222
+ if (len > 0) {
223
+ // Verify it's a pure array by checking key 1 exists
224
+ lua.lua_rawgeti(L, idx, 1);
225
+ const hasFirst = lua.lua_type(L, -1) !== lua.LUA_TNIL;
226
+ lua.lua_pop(L, 1);
227
+
228
+ if (hasFirst) {
229
+ // Check if there are also string keys (mixed table)
230
+ let hasStringKeys = false;
231
+ lua.lua_pushnil(L);
232
+ while (lua.lua_next(L, idx) !== 0) {
233
+ lua.lua_pop(L, 1); // pop value
234
+ if (lua.lua_type(L, -1) === lua.LUA_TSTRING) {
235
+ hasStringKeys = true;
236
+ lua.lua_pop(L, 1); // pop key
237
+ break;
238
+ }
239
+ }
240
+
241
+ if (!hasStringKeys) {
242
+ // Pure array
243
+ const arr: unknown[] = [];
244
+ for (let i = 1; i <= len; i++) {
245
+ lua.lua_rawgeti(L, idx, i);
246
+ arr.push(luaToJS(L, -1));
247
+ lua.lua_pop(L, 1);
248
+ }
249
+ return arr;
250
+ }
251
+ }
252
+ }
253
+
254
+ // Object (or mixed table)
255
+ const obj: Record<string, unknown> = {};
256
+ lua.lua_pushnil(L);
257
+ while (lua.lua_next(L, idx) !== 0) {
258
+ const keyType = lua.lua_type(L, -2);
259
+ let key: string;
260
+ if (keyType === lua.LUA_TSTRING) {
261
+ key = to_jsstring(lua.lua_tostring(L, -2));
262
+ } else if (keyType === lua.LUA_TNUMBER) {
263
+ key = String(lua.lua_tonumber(L, -2));
264
+ } else {
265
+ lua.lua_pop(L, 1);
266
+ continue;
267
+ }
268
+ obj[key] = luaToJS(L, -1);
269
+ lua.lua_pop(L, 1);
270
+ }
271
+ return obj;
272
+ }
273
+
274
+ /** Push a JS value onto the Lua stack */
275
+ export function pushJSValue(L: any, value: unknown): void {
276
+ if (value === null || value === undefined) {
277
+ lua.lua_pushnil(L);
278
+ } else if (typeof value === 'boolean') {
279
+ lua.lua_pushboolean(L, value ? 1 : 0);
280
+ } else if (typeof value === 'number') {
281
+ if (Number.isInteger(value)) {
282
+ lua.lua_pushinteger(L, value);
283
+ } else {
284
+ lua.lua_pushnumber(L, value);
285
+ }
286
+ } else if (typeof value === 'string') {
287
+ lua.lua_pushstring(L, cachedToLuastring(value));
288
+ } else if (Array.isArray(value)) {
289
+ pushJSArray(L, value);
290
+ } else if (typeof value === 'object') {
291
+ pushJSObject(L, value as Record<string, unknown>);
292
+ } else {
293
+ lua.lua_pushnil(L);
294
+ }
295
+ }
296
+
297
+ /** Push a JS array as a Lua table (1-based) */
298
+ function pushJSArray(L: any, arr: unknown[]): void {
299
+ lua.lua_createtable(L, arr.length, 0);
300
+ for (let i = 0; i < arr.length; i++) {
301
+ pushJSValue(L, arr[i]);
302
+ lua.lua_rawseti(L, -2, i + 1);
303
+ }
304
+ }
305
+
306
+ /** Push a JS object as a Lua table */
307
+ function pushJSObject(L: any, obj: Record<string, unknown>): void {
308
+ const keys = Object.keys(obj);
309
+ lua.lua_createtable(L, 0, keys.length);
310
+ for (const key of keys) {
311
+ pushJSValue(L, obj[key]);
312
+ lua.lua_setfield(L, -2, cachedToLuastring(key));
313
+ }
314
+ }
@@ -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
+ }
@@ -0,0 +1,227 @@
1
+ import type { SessionData } from '@energy8platform/game-sdk';
2
+ import type { TransitionRule } from './types';
3
+
4
+ interface SessionState {
5
+ spinsRemaining: number;
6
+ spinsPlayed: number;
7
+ totalWin: number;
8
+ completed: boolean;
9
+ maxWinReached: boolean;
10
+ bet: number;
11
+ maxWinCap: number | undefined;
12
+ spinsVarName: string | undefined;
13
+ persistentVarNames: string[];
14
+ persistentVars: Record<string, number>;
15
+ persistentData: Record<string, unknown>;
16
+ }
17
+
18
+ const MAX_SESSION_SPINS = 200;
19
+
20
+ /**
21
+ * Manages session lifecycle matching the platform server behavior:
22
+ * - createSession: initial spin counted (spinsPlayed=1, totalWin=spinWin)
23
+ * - updateSession: accumulates win, decrements spins, checks max win cap on session level
24
+ * - completeSession: returns cumulative totalWin, cleans up session vars
25
+ * - Safety cap: 200 spins max per session
26
+ */
27
+ export class SessionManager {
28
+ private session: SessionState | null = null;
29
+
30
+ get isActive(): boolean {
31
+ return this.session !== null && !this.session.completed;
32
+ }
33
+
34
+ get current(): SessionData | null {
35
+ if (!this.session) return null;
36
+ return this.toSessionData();
37
+ }
38
+
39
+ get sessionTotalWin(): number {
40
+ return this.session?.totalWin ?? 0;
41
+ }
42
+
43
+ /** Get the fixed bet amount from the session (server uses session bet, not request bet) */
44
+ get sessionBet(): number | undefined {
45
+ return this.session?.bet;
46
+ }
47
+
48
+ /** Get spinsVarName to restore free_spins_remaining into variables */
49
+ get spinsVarName(): string | undefined {
50
+ return this.session?.spinsVarName;
51
+ }
52
+
53
+ get spinsRemaining(): number {
54
+ return this.session?.spinsRemaining ?? 0;
55
+ }
56
+
57
+ /**
58
+ * Create a new session from a transition rule.
59
+ * Server behavior: initial spin is already counted (spinsPlayed=1, totalWin includes spinWin).
60
+ */
61
+ createSession(
62
+ rule: TransitionRule,
63
+ variables: Record<string, number>,
64
+ bet: number,
65
+ spinWin: number,
66
+ maxWinCap: number | undefined,
67
+ ): SessionData {
68
+ let spinsRemaining = -1;
69
+ let spinsVarName: string | undefined;
70
+ if (rule.session_config?.total_spins_var) {
71
+ spinsVarName = rule.session_config.total_spins_var;
72
+ spinsRemaining = variables[spinsVarName] ?? -1;
73
+ }
74
+
75
+ const persistentVarNames: string[] = rule.session_config?.persistent_vars ?? [];
76
+ const persistentVars: Record<string, number> = {};
77
+ for (const varName of persistentVarNames) {
78
+ persistentVars[varName] = variables[varName] ?? 0;
79
+ }
80
+
81
+ this.session = {
82
+ spinsRemaining,
83
+ spinsPlayed: 1, // initial spin counts
84
+ totalWin: spinWin, // initial spin win included
85
+ completed: false,
86
+ maxWinReached: false,
87
+ bet,
88
+ maxWinCap,
89
+ spinsVarName,
90
+ persistentVarNames,
91
+ persistentVars,
92
+ persistentData: {},
93
+ };
94
+
95
+ return this.toSessionData();
96
+ }
97
+
98
+ /**
99
+ * Update session after a bonus spin.
100
+ * Server behavior: accumulate win, decrement spins, check retrigger, check max win cap,
101
+ * safety cap at 200 spins.
102
+ */
103
+ updateSession(
104
+ rule: TransitionRule,
105
+ variables: Record<string, number>,
106
+ spinWin: number,
107
+ ): SessionData {
108
+ if (!this.session) throw new Error('No active session');
109
+
110
+ // Accumulate win and count spin
111
+ this.session.totalWin += spinWin;
112
+ this.session.spinsPlayed++;
113
+
114
+ // Decrement spins (only for non-unlimited sessions)
115
+ if (this.session.spinsRemaining > 0) {
116
+ this.session.spinsRemaining--;
117
+ }
118
+
119
+ // Handle retrigger (add_spins_var)
120
+ if (rule.add_spins_var) {
121
+ const extraSpins = variables[rule.add_spins_var] ?? 0;
122
+ if (extraSpins > 0 && this.session.spinsRemaining >= 0) {
123
+ this.session.spinsRemaining += extraSpins;
124
+ }
125
+ }
126
+
127
+ // Safety cap: server limits sessions to 200 spins
128
+ if (this.session.spinsPlayed >= MAX_SESSION_SPINS) {
129
+ this.session.spinsRemaining = 0;
130
+ }
131
+
132
+ // Update session persistent vars from current variables
133
+ for (const varName of this.session.persistentVarNames) {
134
+ if (varName in variables) {
135
+ this.session.persistentVars[varName] = variables[varName];
136
+ }
137
+ }
138
+
139
+ // Check max win cap (on session level, not per spin)
140
+ if (this.session.maxWinCap !== undefined && this.session.totalWin >= this.session.maxWinCap) {
141
+ this.session.totalWin = this.session.maxWinCap;
142
+ this.session.spinsRemaining = 0;
143
+ this.session.maxWinReached = true;
144
+ }
145
+
146
+ // Auto-complete if spins exhausted or explicit complete
147
+ if (this.session.spinsRemaining === 0 || rule.complete_session) {
148
+ this.session.completed = true;
149
+ }
150
+
151
+ return this.toSessionData();
152
+ }
153
+
154
+ /**
155
+ * Complete the session explicitly.
156
+ * Returns cumulative totalWin and list of session-scoped var names to clean up.
157
+ */
158
+ completeSession(): { totalWin: number; session: SessionData; sessionVarNames: string[] } {
159
+ if (!this.session) throw new Error('No active session to complete');
160
+
161
+ this.session.completed = true;
162
+ const totalWin = this.session.totalWin;
163
+ const session = this.toSessionData();
164
+ const sessionVarNames = [...this.session.persistentVarNames];
165
+
166
+ this.session = null;
167
+
168
+ return { totalWin, session, sessionVarNames };
169
+ }
170
+
171
+ /** Mark max win reached — stops the session */
172
+ markMaxWinReached(): void {
173
+ if (this.session) {
174
+ this.session.maxWinReached = true;
175
+ this.session.completed = true;
176
+ }
177
+ }
178
+
179
+ /** Store _persist_* data extracted from Lua result */
180
+ storePersistData(data: Record<string, unknown>): void {
181
+ if (!this.session) return;
182
+
183
+ for (const key of Object.keys(data)) {
184
+ if (key.startsWith('_persist_')) {
185
+ const cleanKey = key.slice('_persist_'.length);
186
+ this.session.persistentData[cleanKey] = data[key];
187
+ }
188
+ }
189
+ }
190
+
191
+ /** Get persistent params to inject into next execute() call */
192
+ getPersistentParams(): Record<string, unknown> {
193
+ if (!this.session) return {};
194
+
195
+ const params: Record<string, unknown> = {};
196
+
197
+ // Session persistent vars (float64) → state.variables
198
+ for (const [key, value] of Object.entries(this.session.persistentVars)) {
199
+ params[key] = value;
200
+ }
201
+
202
+ // _persist_ complex data → _ps_* in state.params
203
+ for (const [key, value] of Object.entries(this.session.persistentData)) {
204
+ params[`_ps_${key}`] = value;
205
+ }
206
+
207
+ return params;
208
+ }
209
+
210
+ /** Reset all session state */
211
+ reset(): void {
212
+ this.session = null;
213
+ }
214
+
215
+ private toSessionData(): SessionData {
216
+ if (!this.session) throw new Error('No session');
217
+
218
+ return {
219
+ spinsRemaining: this.session.spinsRemaining,
220
+ spinsPlayed: this.session.spinsPlayed,
221
+ totalWin: Math.round(this.session.totalWin * 100) / 100,
222
+ completed: this.session.completed,
223
+ maxWinReached: this.session.maxWinReached,
224
+ betAmount: this.session.bet,
225
+ };
226
+ }
227
+ }