@energy8platform/game-engine 0.10.7 → 0.10.8

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/dist/lua.d.ts CHANGED
@@ -113,19 +113,9 @@ interface SimulationResult {
113
113
  /**
114
114
  * Runs Lua game scripts locally, replicating the platform's server-side execution.
115
115
  *
116
- * Implements the full lifecycle: action routing → state assembly → Lua execute() →
117
- * result extractiontransition evaluationsession management.
118
- *
119
- * @example
120
- * ```ts
121
- * const engine = new LuaEngine({
122
- * script: luaSource,
123
- * gameDefinition: { id: 'my-slot', type: 'SLOT', actions: { ... } },
124
- * });
125
- *
126
- * const result = engine.execute({ action: 'spin', bet: 1.0 });
127
- * // result.data.matrix, result.totalWin, result.nextActions, etc.
128
- * ```
116
+ * Implements the full lifecycle matching `casino_platform/internal/usecase/game_usecase.go`:
117
+ * action routingstate assemblyLua execute() → result extraction →
118
+ * transition evaluation → session management.
129
119
  */
130
120
  declare class LuaEngine {
131
121
  private L;
@@ -137,18 +127,13 @@ declare class LuaEngine {
137
127
  private variables;
138
128
  private simulationMode;
139
129
  constructor(config: LuaEngineConfig);
140
- /** Current session data (if any) */
141
130
  get session(): SessionData | null;
142
- /** Current persistent state values */
143
131
  get persistentVars(): Record<string, number>;
144
132
  /**
145
- * Execute a play action — the main entry point.
146
- * This is what DevBridge calls on each PLAY_REQUEST.
133
+ * Execute a play action — replicates server's Play() function.
147
134
  */
148
135
  execute(params: PlayParams): LuaPlayResult;
149
- /** Reset all state (sessions, persistent vars, variables) */
150
136
  reset(): void;
151
- /** Destroy the Lua VM */
152
137
  destroy(): void;
153
138
  private loadScript;
154
139
  private callLuaExecute;
@@ -210,20 +195,37 @@ declare class ActionRouter {
210
195
  declare function evaluateCondition(condition: string, variables: Record<string, number>): boolean;
211
196
 
212
197
  /**
213
- * Manages session lifecycle: creation, spin counting, retriggers, and completion.
214
- * Handles both slot sessions (fixed spin count) and table game sessions (unlimited).
215
- * Also manages _persist_ data roundtrip between Lua calls.
198
+ * Manages session lifecycle matching the platform server behavior:
199
+ * - createSession: initial spin counted (spinsPlayed=1, totalWin=spinWin)
200
+ * - updateSession: accumulates win, decrements spins, checks max win cap on session level
201
+ * - completeSession: returns cumulative totalWin, cleans up session vars
202
+ * - Safety cap: 200 spins max per session
216
203
  */
217
204
  declare class SessionManager {
218
205
  private session;
219
206
  get isActive(): boolean;
220
207
  get current(): SessionData | null;
221
208
  get sessionTotalWin(): number;
222
- /** Create a new session from a transition rule */
223
- createSession(rule: TransitionRule, variables: Record<string, number>, bet: number): SessionData;
224
- /** Update session after a spin: decrement counter, accumulate win, handle retrigger */
209
+ /** Get the fixed bet amount from the session (server uses session bet, not request bet) */
210
+ get sessionBet(): number | undefined;
211
+ /** Get spinsVarName to restore free_spins_remaining into variables */
212
+ get spinsVarName(): string | undefined;
213
+ get spinsRemaining(): number;
214
+ /**
215
+ * Create a new session from a transition rule.
216
+ * Server behavior: initial spin is already counted (spinsPlayed=1, totalWin includes spinWin).
217
+ */
218
+ createSession(rule: TransitionRule, variables: Record<string, number>, bet: number, spinWin: number, maxWinCap: number | undefined): SessionData;
219
+ /**
220
+ * Update session after a bonus spin.
221
+ * Server behavior: accumulate win, decrement spins, check retrigger, check max win cap,
222
+ * safety cap at 200 spins.
223
+ */
225
224
  updateSession(rule: TransitionRule, variables: Record<string, number>, spinWin: number): SessionData;
226
- /** Complete the session, return accumulated totalWin and list of session-scoped vars to clean up */
225
+ /**
226
+ * Complete the session explicitly.
227
+ * Returns cumulative totalWin and list of session-scoped var names to clean up.
228
+ */
227
229
  completeSession(): {
228
230
  totalWin: number;
229
231
  session: SessionData;
@@ -233,7 +235,7 @@ declare class SessionManager {
233
235
  markMaxWinReached(): void;
234
236
  /** Store _persist_* data extracted from Lua result */
235
237
  storePersistData(data: Record<string, unknown>): void;
236
- /** Get _ps_* params to inject into next execute() call */
238
+ /** Get persistent params to inject into next execute() call */
237
239
  getPersistentParams(): Record<string, unknown>;
238
240
  /** Reset all session state */
239
241
  reset(): void;
package/dist/lua.esm.js CHANGED
@@ -369,10 +369,13 @@ function evaluateComparison(expr, variables) {
369
369
  }
370
370
  }
371
371
 
372
+ const MAX_SESSION_SPINS = 200;
372
373
  /**
373
- * Manages session lifecycle: creation, spin counting, retriggers, and completion.
374
- * Handles both slot sessions (fixed spin count) and table game sessions (unlimited).
375
- * Also manages _persist_ data roundtrip between Lua calls.
374
+ * Manages session lifecycle matching the platform server behavior:
375
+ * - createSession: initial spin counted (spinsPlayed=1, totalWin=spinWin)
376
+ * - updateSession: accumulates win, decrements spins, checks max win cap on session level
377
+ * - completeSession: returns cumulative totalWin, cleans up session vars
378
+ * - Safety cap: 200 spins max per session
376
379
  */
377
380
  class SessionManager {
378
381
  session = null;
@@ -387,36 +390,57 @@ class SessionManager {
387
390
  get sessionTotalWin() {
388
391
  return this.session?.totalWin ?? 0;
389
392
  }
390
- /** Create a new session from a transition rule */
391
- createSession(rule, variables, bet) {
392
- let spinsRemaining = -1; // unlimited by default
393
+ /** Get the fixed bet amount from the session (server uses session bet, not request bet) */
394
+ get sessionBet() {
395
+ return this.session?.bet;
396
+ }
397
+ /** Get spinsVarName to restore free_spins_remaining into variables */
398
+ get spinsVarName() {
399
+ return this.session?.spinsVarName;
400
+ }
401
+ get spinsRemaining() {
402
+ return this.session?.spinsRemaining ?? 0;
403
+ }
404
+ /**
405
+ * Create a new session from a transition rule.
406
+ * Server behavior: initial spin is already counted (spinsPlayed=1, totalWin includes spinWin).
407
+ */
408
+ createSession(rule, variables, bet, spinWin, maxWinCap) {
409
+ let spinsRemaining = -1;
410
+ let spinsVarName;
393
411
  if (rule.session_config?.total_spins_var) {
394
- const varName = rule.session_config.total_spins_var;
395
- spinsRemaining = variables[varName] ?? -1;
412
+ spinsVarName = rule.session_config.total_spins_var;
413
+ spinsRemaining = variables[spinsVarName] ?? -1;
396
414
  }
415
+ const persistentVarNames = rule.session_config?.persistent_vars ?? [];
397
416
  const persistentVars = {};
398
- if (rule.session_config?.persistent_vars) {
399
- for (const varName of rule.session_config.persistent_vars) {
400
- persistentVars[varName] = variables[varName] ?? 0;
401
- }
417
+ for (const varName of persistentVarNames) {
418
+ persistentVars[varName] = variables[varName] ?? 0;
402
419
  }
403
420
  this.session = {
404
421
  spinsRemaining,
405
- spinsPlayed: 0,
406
- totalWin: 0,
422
+ spinsPlayed: 1, // initial spin counts
423
+ totalWin: spinWin, // initial spin win included
407
424
  completed: false,
408
425
  maxWinReached: false,
409
426
  bet,
427
+ maxWinCap,
428
+ spinsVarName,
429
+ persistentVarNames,
410
430
  persistentVars,
411
431
  persistentData: {},
412
432
  };
413
433
  return this.toSessionData();
414
434
  }
415
- /** Update session after a spin: decrement counter, accumulate win, handle retrigger */
435
+ /**
436
+ * Update session after a bonus spin.
437
+ * Server behavior: accumulate win, decrement spins, check retrigger, check max win cap,
438
+ * safety cap at 200 spins.
439
+ */
416
440
  updateSession(rule, variables, spinWin) {
417
441
  if (!this.session)
418
442
  throw new Error('No active session');
419
- // Accumulate win
443
+ // Accumulate win and count spin
420
444
  this.session.totalWin += spinWin;
421
445
  this.session.spinsPlayed++;
422
446
  // Decrement spins (only for non-unlimited sessions)
@@ -430,29 +454,39 @@ class SessionManager {
430
454
  this.session.spinsRemaining += extraSpins;
431
455
  }
432
456
  }
433
- // Update persistent vars
434
- if (this.session.persistentVars) {
435
- for (const key of Object.keys(this.session.persistentVars)) {
436
- if (key in variables) {
437
- this.session.persistentVars[key] = variables[key];
438
- }
457
+ // Safety cap: server limits sessions to 200 spins
458
+ if (this.session.spinsPlayed >= MAX_SESSION_SPINS) {
459
+ this.session.spinsRemaining = 0;
460
+ }
461
+ // Update session persistent vars from current variables
462
+ for (const varName of this.session.persistentVarNames) {
463
+ if (varName in variables) {
464
+ this.session.persistentVars[varName] = variables[varName];
439
465
  }
440
466
  }
441
- // Auto-complete if spins exhausted
442
- if (this.session.spinsRemaining === 0) {
467
+ // Check max win cap (on session level, not per spin)
468
+ if (this.session.maxWinCap !== undefined && this.session.totalWin >= this.session.maxWinCap) {
469
+ this.session.totalWin = this.session.maxWinCap;
470
+ this.session.spinsRemaining = 0;
471
+ this.session.maxWinReached = true;
472
+ }
473
+ // Auto-complete if spins exhausted or explicit complete
474
+ if (this.session.spinsRemaining === 0 || rule.complete_session) {
443
475
  this.session.completed = true;
444
476
  }
445
477
  return this.toSessionData();
446
478
  }
447
- /** Complete the session, return accumulated totalWin and list of session-scoped vars to clean up */
479
+ /**
480
+ * Complete the session explicitly.
481
+ * Returns cumulative totalWin and list of session-scoped var names to clean up.
482
+ */
448
483
  completeSession() {
449
484
  if (!this.session)
450
485
  throw new Error('No active session to complete');
451
486
  this.session.completed = true;
452
487
  const totalWin = this.session.totalWin;
453
488
  const session = this.toSessionData();
454
- const sessionVarNames = Object.keys(this.session.persistentVars);
455
- // Clear session state so it doesn't leak into the next round
489
+ const sessionVarNames = [...this.session.persistentVarNames];
456
490
  this.session = null;
457
491
  return { totalWin, session, sessionVarNames };
458
492
  }
@@ -474,16 +508,16 @@ class SessionManager {
474
508
  }
475
509
  }
476
510
  }
477
- /** Get _ps_* params to inject into next execute() call */
511
+ /** Get persistent params to inject into next execute() call */
478
512
  getPersistentParams() {
479
513
  if (!this.session)
480
514
  return {};
481
515
  const params = {};
482
- // Session persistent vars (float64)
516
+ // Session persistent vars (float64) → state.variables
483
517
  for (const [key, value] of Object.entries(this.session.persistentVars)) {
484
518
  params[key] = value;
485
519
  }
486
- // _persist_ complex data → _ps_*
520
+ // _persist_ complex data → _ps_* in state.params
487
521
  for (const [key, value] of Object.entries(this.session.persistentData)) {
488
522
  params[`_ps_${key}`] = value;
489
523
  }
@@ -581,22 +615,20 @@ class PersistentState {
581
615
 
582
616
  const { lua, lauxlib, lualib } = fengari;
583
617
  const { to_luastring, to_jsstring } = fengari;
618
+ /** Default engine variables matching the server's NewGameState() */
619
+ const DEFAULT_VARIABLES = {
620
+ multiplier: 1,
621
+ total_multiplier: 1,
622
+ global_multiplier: 1,
623
+ last_win_amount: 0,
624
+ free_spins_awarded: 0,
625
+ };
584
626
  /**
585
627
  * Runs Lua game scripts locally, replicating the platform's server-side execution.
586
628
  *
587
- * Implements the full lifecycle: action routing → state assembly → Lua execute() →
588
- * result extractiontransition evaluationsession management.
589
- *
590
- * @example
591
- * ```ts
592
- * const engine = new LuaEngine({
593
- * script: luaSource,
594
- * gameDefinition: { id: 'my-slot', type: 'SLOT', actions: { ... } },
595
- * });
596
- *
597
- * const result = engine.execute({ action: 'spin', bet: 1.0 });
598
- * // result.data.matrix, result.totalWin, result.nextActions, etc.
599
- * ```
629
+ * Implements the full lifecycle matching `casino_platform/internal/usecase/game_usecase.go`:
630
+ * action routingstate assemblyLua execute() → result extraction →
631
+ * transition evaluation → session management.
600
632
  */
601
633
  class LuaEngine {
602
634
  L;
@@ -610,20 +642,16 @@ class LuaEngine {
610
642
  constructor(config) {
611
643
  this.gameDefinition = config.gameDefinition;
612
644
  this.simulationMode = config.simulationMode ?? false;
613
- // Set up RNG
614
645
  const rng = config.seed !== undefined
615
646
  ? createSeededRng(config.seed)
616
647
  : undefined;
617
- // Initialize sub-managers
618
648
  this.api = new LuaEngineAPI(config.gameDefinition, rng, config.logger);
619
649
  this.actionRouter = new ActionRouter(config.gameDefinition);
620
650
  this.sessionManager = new SessionManager();
621
651
  this.persistentState = new PersistentState(config.gameDefinition.persistent_state);
622
- // Create Lua state and load standard libraries
623
652
  this.L = lauxlib.luaL_newstate();
624
653
  lualib.luaL_openlibs(this.L);
625
- // Polyfill Lua 5.1/5.2 functions removed in 5.3.
626
- // Platform server may use gopher-lua (5.1) — these ensure compatibility.
654
+ // Polyfill Lua 5.1/5.2 functions removed in 5.3
627
655
  lauxlib.luaL_dostring(this.L, to_luastring(`
628
656
  math.pow = function(a, b) return a ^ b end
629
657
  math.atan2 = math.atan2 or function(y, x) return math.atan(y, x) end
@@ -641,32 +669,36 @@ class LuaEngine {
641
669
  loadstring = loadstring or load
642
670
  table.getn = table.getn or function(t) return #t end
643
671
  `));
644
- // Register engine.* API
645
672
  this.api.register(this.L);
646
- // Load and compile the script
647
673
  this.loadScript(config.script);
648
674
  }
649
- /** Current session data (if any) */
650
675
  get session() {
651
676
  return this.sessionManager.current;
652
677
  }
653
- /** Current persistent state values */
654
678
  get persistentVars() {
655
679
  return { ...this.variables };
656
680
  }
657
681
  /**
658
- * Execute a play action — the main entry point.
659
- * This is what DevBridge calls on each PLAY_REQUEST.
682
+ * Execute a play action — replicates server's Play() function.
660
683
  */
661
684
  execute(params) {
662
- const { action: actionName, bet, params: clientParams } = params;
663
- // 1. Resolve the action definition
685
+ const { action: actionName, params: clientParams } = params;
686
+ // 1. Resolve action
664
687
  const action = this.actionRouter.resolveAction(actionName, this.sessionManager.isActive);
665
- // 2. Build state.variables
666
- const stateVars = { ...this.variables, bet };
688
+ // 2. Determine bet — server uses session bet for session actions
689
+ let bet = params.bet;
690
+ if (this.sessionManager.isActive && this.sessionManager.sessionBet !== undefined) {
691
+ bet = this.sessionManager.sessionBet;
692
+ }
693
+ // 3. Build state.variables (matching server's NewGameState + restore)
694
+ const stateVars = {
695
+ ...DEFAULT_VARIABLES,
696
+ ...this.variables,
697
+ bet,
698
+ };
667
699
  // Load cross-spin persistent state
668
700
  this.persistentState.loadIntoVariables(stateVars);
669
- // Load session persistent vars
701
+ // Load session persistent vars + restore spinsRemaining
670
702
  if (this.sessionManager.isActive) {
671
703
  const sessionParams = this.sessionManager.getPersistentParams();
672
704
  for (const [k, v] of Object.entries(sessionParams)) {
@@ -674,8 +706,14 @@ class LuaEngine {
674
706
  stateVars[k] = v;
675
707
  }
676
708
  }
709
+ // Restore spinsRemaining into the variable the script reads
710
+ if (this.sessionManager.spinsVarName) {
711
+ stateVars[this.sessionManager.spinsVarName] = this.sessionManager.spinsRemaining;
712
+ }
713
+ // Also set free_spins_remaining for convenience
714
+ stateVars.free_spins_remaining = this.sessionManager.spinsRemaining;
677
715
  }
678
- // 3. Build state.params
716
+ // 4. Build state.params
679
717
  const stateParams = { ...clientParams };
680
718
  stateParams._action = actionName;
681
719
  // Inject session _ps_* persistent data
@@ -703,16 +741,16 @@ class LuaEngine {
703
741
  if (clientParams?.ante_bet && this.gameDefinition.ante_bet) {
704
742
  stateParams.ante_bet = true;
705
743
  }
706
- // 4. Build the state table and call Lua execute()
707
- const luaResult = this.callLuaExecute(action.stage, stateParams, stateVars);
708
- // 5. Extract special fields from Lua result
744
+ // 5. Execute Lua (server: executor.Execute(stage, state))
745
+ const luaResult = this.callLuaExecute(action.stage, actionName, stateParams, stateVars);
746
+ // 6. Process result (server: ApplyLuaResult)
709
747
  const totalWinMultiplier = typeof luaResult.total_win === 'number' ? luaResult.total_win : 0;
710
748
  const resultVariables = (luaResult.variables ?? {});
711
- const totalWin = Math.round(totalWinMultiplier * bet * 100) / 100;
749
+ const spinWin = Math.round(totalWinMultiplier * bet * 100) / 100;
712
750
  // Merge result variables into engine variables
713
751
  Object.assign(stateVars, resultVariables);
714
752
  this.variables = { ...stateVars };
715
- delete this.variables.bet; // bet is per-spin, not persistent
753
+ delete this.variables.bet;
716
754
  // Build client data (everything except special keys)
717
755
  const data = {};
718
756
  for (const [key, value] of Object.entries(luaResult)) {
@@ -720,17 +758,6 @@ class LuaEngine {
720
758
  data[key] = value;
721
759
  }
722
760
  }
723
- // 6. Apply max win cap
724
- let cappedWin = totalWin;
725
- if (this.gameDefinition.max_win) {
726
- const cap = this.calculateMaxWinCap(bet);
727
- if (cap !== undefined && totalWin > cap) {
728
- cappedWin = cap;
729
- this.variables.max_win_reached = 1;
730
- data.max_win_reached = true;
731
- this.sessionManager.markMaxWinReached();
732
- }
733
- }
734
761
  // 7. Handle _persist_* and _persist_game_* keys
735
762
  this.sessionManager.storePersistData(data);
736
763
  this.persistentState.storeGameData(data);
@@ -747,36 +774,56 @@ class LuaEngine {
747
774
  delete data[key];
748
775
  }
749
776
  }
750
- // 8. Evaluate transitions
777
+ // 8. Evaluate transitions (server: evaluateTransitions)
751
778
  const { rule, nextActions } = this.actionRouter.evaluateTransitions(action, this.variables);
779
+ // 9. Determine credit behavior (server: creditNow logic)
752
780
  let creditDeferred = action.credit === 'defer' || rule.credit_override === 'defer';
781
+ // 10. Session lifecycle (server: create/update/complete session)
753
782
  let session = this.sessionManager.current;
754
- // Handle session creation
783
+ let resultTotalWin = spinWin;
784
+ let sessionCompleted = false;
785
+ // Calculate max win cap for session
786
+ const maxWinCap = this.calculateMaxWinCap(bet);
755
787
  if (rule.creates_session && !this.sessionManager.isActive) {
756
- session = this.sessionManager.createSession(rule, this.variables, bet);
788
+ // CREATE SESSION — initial spin counted (server: createSession includes spinWin)
789
+ session = this.sessionManager.createSession(rule, this.variables, bet, spinWin, maxWinCap);
757
790
  creditDeferred = true;
758
- // Clear the trigger variable (e.g. free_spins_awarded) — it was consumed
759
- // to set session spinsRemaining. On the server this is one-shot.
791
+ resultTotalWin = spinWin;
792
+ // Clear the trigger variable it was consumed to set spinsRemaining
760
793
  if (rule.session_config?.total_spins_var) {
761
794
  delete this.variables[rule.session_config.total_spins_var];
762
795
  }
763
796
  }
764
- // Handle session update
765
797
  else if (this.sessionManager.isActive) {
766
- session = this.sessionManager.updateSession(rule, this.variables, cappedWin);
767
- // Handle session completion
768
- if (rule.complete_session || session?.completed) {
798
+ // UPDATE SESSION — accumulate win, check completion
799
+ session = this.sessionManager.updateSession(rule, this.variables, spinWin);
800
+ if (session?.completed) {
801
+ // SESSION COMPLETED — server returns session.TotalWin as result.TotalWin
769
802
  const completed = this.sessionManager.completeSession();
770
803
  session = completed.session;
804
+ resultTotalWin = completed.totalWin;
805
+ sessionCompleted = true;
771
806
  creditDeferred = false;
772
- // Clean up session-scoped variables so they don't leak into next round
807
+ // Clean up session-scoped variables
773
808
  for (const varName of completed.sessionVarNames) {
774
809
  delete this.variables[varName];
775
810
  }
776
811
  }
812
+ else {
813
+ // Mid-session: totalWin = spinWin, credit deferred
814
+ resultTotalWin = spinWin;
815
+ creditDeferred = true;
816
+ }
817
+ }
818
+ // No session: resultTotalWin = spinWin (already set)
819
+ // Apply max win cap for non-session spins
820
+ if (!this.sessionManager.isActive && !sessionCompleted && maxWinCap !== undefined && resultTotalWin > maxWinCap) {
821
+ resultTotalWin = maxWinCap;
822
+ this.variables.max_win_reached = 1;
823
+ data.max_win_reached = true;
777
824
  }
778
825
  return {
779
- totalWin: cappedWin,
826
+ totalWin: Math.round(resultTotalWin * 100) / 100,
780
827
  data,
781
828
  nextActions,
782
829
  session,
@@ -784,13 +831,11 @@ class LuaEngine {
784
831
  creditDeferred,
785
832
  };
786
833
  }
787
- /** Reset all state (sessions, persistent vars, variables) */
788
834
  reset() {
789
835
  this.variables = {};
790
836
  this.sessionManager.reset();
791
837
  this.persistentState.reset();
792
838
  }
793
- /** Destroy the Lua VM */
794
839
  destroy() {
795
840
  if (this.L) {
796
841
  lua.lua_close(this.L);
@@ -805,7 +850,6 @@ class LuaEngine {
805
850
  lua.lua_pop(this.L, 1);
806
851
  throw new Error(`Failed to load Lua script: ${err}`);
807
852
  }
808
- // Verify that execute() function exists
809
853
  lua.lua_getglobal(this.L, to_luastring('execute'));
810
854
  if (lua.lua_type(this.L, -1) !== lua.LUA_TFUNCTION) {
811
855
  lua.lua_pop(this.L, 1);
@@ -813,21 +857,22 @@ class LuaEngine {
813
857
  }
814
858
  lua.lua_pop(this.L, 1);
815
859
  }
816
- callLuaExecute(stage, params, variables) {
817
- // Push the execute function
860
+ callLuaExecute(stage, action, params, variables) {
818
861
  lua.lua_getglobal(this.L, to_luastring('execute'));
819
- // Build and push the state table
820
- lua.lua_createtable(this.L, 0, 3);
862
+ // Build state table: {stage, action, params, variables}
863
+ lua.lua_createtable(this.L, 0, 4);
821
864
  // state.stage
822
865
  lua.lua_pushstring(this.L, to_luastring(stage));
823
866
  lua.lua_setfield(this.L, -2, to_luastring('stage'));
867
+ // state.action (server sets this at top level)
868
+ lua.lua_pushstring(this.L, to_luastring(action));
869
+ lua.lua_setfield(this.L, -2, to_luastring('action'));
824
870
  // state.params
825
871
  pushJSValue(this.L, params);
826
872
  lua.lua_setfield(this.L, -2, to_luastring('params'));
827
873
  // state.variables
828
874
  pushJSValue(this.L, variables);
829
875
  lua.lua_setfield(this.L, -2, to_luastring('variables'));
830
- // Call execute(state) → 1 result
831
876
  const status = lua.lua_pcall(this.L, 1, 1, 0);
832
877
  if (status !== lua.LUA_OK) {
833
878
  const err = to_jsstring(lua.lua_tostring(this.L, -1));
@@ -835,21 +880,17 @@ class LuaEngine {
835
880
  throw new Error(`Lua execute() failed: ${err}`);
836
881
  }
837
882
  if (this.simulationMode) {
838
- // Fast path: extract only total_win and variables without full table marshal
883
+ // Fast path: extract only total_win, variables, _persist_* keys
839
884
  const result = {};
840
- // result.total_win
841
885
  lua.lua_getfield(this.L, -1, to_luastring('total_win'));
842
886
  result.total_win = lua.lua_type(this.L, -1) === lua.LUA_TNUMBER
843
- ? lua.lua_tonumber(this.L, -1)
844
- : 0;
887
+ ? lua.lua_tonumber(this.L, -1) : 0;
845
888
  lua.lua_pop(this.L, 1);
846
- // result.variables
847
889
  lua.lua_getfield(this.L, -1, to_luastring('variables'));
848
890
  if (lua.lua_type(this.L, -1) === lua.LUA_TTABLE) {
849
891
  result.variables = luaToJS(this.L, -1);
850
892
  }
851
893
  lua.lua_pop(this.L, 1);
852
- // result._persist_* keys (needed for session state)
853
894
  lua.lua_pushnil(this.L);
854
895
  while (lua.lua_next(this.L, -2) !== 0) {
855
896
  if (lua.lua_type(this.L, -2) === lua.LUA_TSTRING) {
@@ -860,10 +901,10 @@ class LuaEngine {
860
901
  }
861
902
  lua.lua_pop(this.L, 1);
862
903
  }
863
- lua.lua_pop(this.L, 1); // pop result table
904
+ lua.lua_pop(this.L, 1);
864
905
  return result;
865
906
  }
866
- // Full path: marshal entire result table to JS
907
+ // Full path
867
908
  const result = luaToJS(this.L, -1);
868
909
  lua.lua_pop(this.L, 1);
869
910
  if (!result || typeof result !== 'object' || Array.isArray(result)) {