@energy8platform/game-engine 0.10.7 → 0.10.9

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,18 @@ 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;
712
- // Merge result variables into engine variables
749
+ const spinWin = Math.round(totalWinMultiplier * bet * 100) / 100;
750
+ // Merge ONLY Lua return variables into engine state (not the whole stateVars).
751
+ // On the server, state.Variables is a temporary object rebuilt each call.
752
+ // Only the Lua result's `variables` table persists between calls.
753
+ Object.assign(this.variables, resultVariables);
754
+ // Also update stateVars for transition evaluation below
713
755
  Object.assign(stateVars, resultVariables);
714
- this.variables = { ...stateVars };
715
- delete this.variables.bet; // bet is per-spin, not persistent
716
756
  // Build client data (everything except special keys)
717
757
  const data = {};
718
758
  for (const [key, value] of Object.entries(luaResult)) {
@@ -720,22 +760,11 @@ class LuaEngine {
720
760
  data[key] = value;
721
761
  }
722
762
  }
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
763
  // 7. Handle _persist_* and _persist_game_* keys
735
764
  this.sessionManager.storePersistData(data);
736
765
  this.persistentState.storeGameData(data);
737
- // Save cross-spin persistent state
738
- this.persistentState.saveFromVariables(this.variables);
766
+ // Save cross-spin persistent state (from stateVars which has Lua result merged)
767
+ this.persistentState.saveFromVariables(stateVars);
739
768
  // Add exposed persistent vars to client data
740
769
  const exposedVars = this.persistentState.getExposedVars();
741
770
  if (exposedVars) {
@@ -747,36 +776,56 @@ class LuaEngine {
747
776
  delete data[key];
748
777
  }
749
778
  }
750
- // 8. Evaluate transitions
751
- const { rule, nextActions } = this.actionRouter.evaluateTransitions(action, this.variables);
779
+ // 8. Evaluate transitions (server uses state.Variables which is stateVars)
780
+ const { rule, nextActions } = this.actionRouter.evaluateTransitions(action, stateVars);
781
+ // 9. Determine credit behavior (server: creditNow logic)
752
782
  let creditDeferred = action.credit === 'defer' || rule.credit_override === 'defer';
783
+ // 10. Session lifecycle (server: create/update/complete session)
753
784
  let session = this.sessionManager.current;
754
- // Handle session creation
785
+ let resultTotalWin = spinWin;
786
+ let sessionCompleted = false;
787
+ // Calculate max win cap for session
788
+ const maxWinCap = this.calculateMaxWinCap(bet);
755
789
  if (rule.creates_session && !this.sessionManager.isActive) {
756
- session = this.sessionManager.createSession(rule, this.variables, bet);
790
+ // CREATE SESSION — initial spin counted (server: createSession includes spinWin)
791
+ session = this.sessionManager.createSession(rule, stateVars, bet, spinWin, maxWinCap);
757
792
  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.
793
+ resultTotalWin = spinWin;
794
+ // Clear the trigger variable it was consumed to set spinsRemaining
760
795
  if (rule.session_config?.total_spins_var) {
761
796
  delete this.variables[rule.session_config.total_spins_var];
762
797
  }
763
798
  }
764
- // Handle session update
765
799
  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) {
800
+ // UPDATE SESSION — accumulate win, check completion
801
+ session = this.sessionManager.updateSession(rule, stateVars, spinWin);
802
+ if (session?.completed) {
803
+ // SESSION COMPLETED — server returns session.TotalWin as result.TotalWin
769
804
  const completed = this.sessionManager.completeSession();
770
805
  session = completed.session;
806
+ resultTotalWin = completed.totalWin;
807
+ sessionCompleted = true;
771
808
  creditDeferred = false;
772
- // Clean up session-scoped variables so they don't leak into next round
809
+ // Clean up session-scoped variables
773
810
  for (const varName of completed.sessionVarNames) {
774
811
  delete this.variables[varName];
775
812
  }
776
813
  }
814
+ else {
815
+ // Mid-session: totalWin = spinWin, credit deferred
816
+ resultTotalWin = spinWin;
817
+ creditDeferred = true;
818
+ }
819
+ }
820
+ // No session: resultTotalWin = spinWin (already set)
821
+ // Apply max win cap for non-session spins
822
+ if (!this.sessionManager.isActive && !sessionCompleted && maxWinCap !== undefined && resultTotalWin > maxWinCap) {
823
+ resultTotalWin = maxWinCap;
824
+ this.variables.max_win_reached = 1;
825
+ data.max_win_reached = true;
777
826
  }
778
827
  return {
779
- totalWin: cappedWin,
828
+ totalWin: Math.round(resultTotalWin * 100) / 100,
780
829
  data,
781
830
  nextActions,
782
831
  session,
@@ -784,13 +833,11 @@ class LuaEngine {
784
833
  creditDeferred,
785
834
  };
786
835
  }
787
- /** Reset all state (sessions, persistent vars, variables) */
788
836
  reset() {
789
837
  this.variables = {};
790
838
  this.sessionManager.reset();
791
839
  this.persistentState.reset();
792
840
  }
793
- /** Destroy the Lua VM */
794
841
  destroy() {
795
842
  if (this.L) {
796
843
  lua.lua_close(this.L);
@@ -805,7 +852,6 @@ class LuaEngine {
805
852
  lua.lua_pop(this.L, 1);
806
853
  throw new Error(`Failed to load Lua script: ${err}`);
807
854
  }
808
- // Verify that execute() function exists
809
855
  lua.lua_getglobal(this.L, to_luastring('execute'));
810
856
  if (lua.lua_type(this.L, -1) !== lua.LUA_TFUNCTION) {
811
857
  lua.lua_pop(this.L, 1);
@@ -813,21 +859,22 @@ class LuaEngine {
813
859
  }
814
860
  lua.lua_pop(this.L, 1);
815
861
  }
816
- callLuaExecute(stage, params, variables) {
817
- // Push the execute function
862
+ callLuaExecute(stage, action, params, variables) {
818
863
  lua.lua_getglobal(this.L, to_luastring('execute'));
819
- // Build and push the state table
820
- lua.lua_createtable(this.L, 0, 3);
864
+ // Build state table: {stage, action, params, variables}
865
+ lua.lua_createtable(this.L, 0, 4);
821
866
  // state.stage
822
867
  lua.lua_pushstring(this.L, to_luastring(stage));
823
868
  lua.lua_setfield(this.L, -2, to_luastring('stage'));
869
+ // state.action (server sets this at top level)
870
+ lua.lua_pushstring(this.L, to_luastring(action));
871
+ lua.lua_setfield(this.L, -2, to_luastring('action'));
824
872
  // state.params
825
873
  pushJSValue(this.L, params);
826
874
  lua.lua_setfield(this.L, -2, to_luastring('params'));
827
875
  // state.variables
828
876
  pushJSValue(this.L, variables);
829
877
  lua.lua_setfield(this.L, -2, to_luastring('variables'));
830
- // Call execute(state) → 1 result
831
878
  const status = lua.lua_pcall(this.L, 1, 1, 0);
832
879
  if (status !== lua.LUA_OK) {
833
880
  const err = to_jsstring(lua.lua_tostring(this.L, -1));
@@ -835,21 +882,17 @@ class LuaEngine {
835
882
  throw new Error(`Lua execute() failed: ${err}`);
836
883
  }
837
884
  if (this.simulationMode) {
838
- // Fast path: extract only total_win and variables without full table marshal
885
+ // Fast path: extract only total_win, variables, _persist_* keys
839
886
  const result = {};
840
- // result.total_win
841
887
  lua.lua_getfield(this.L, -1, to_luastring('total_win'));
842
888
  result.total_win = lua.lua_type(this.L, -1) === lua.LUA_TNUMBER
843
- ? lua.lua_tonumber(this.L, -1)
844
- : 0;
889
+ ? lua.lua_tonumber(this.L, -1) : 0;
845
890
  lua.lua_pop(this.L, 1);
846
- // result.variables
847
891
  lua.lua_getfield(this.L, -1, to_luastring('variables'));
848
892
  if (lua.lua_type(this.L, -1) === lua.LUA_TTABLE) {
849
893
  result.variables = luaToJS(this.L, -1);
850
894
  }
851
895
  lua.lua_pop(this.L, 1);
852
- // result._persist_* keys (needed for session state)
853
896
  lua.lua_pushnil(this.L);
854
897
  while (lua.lua_next(this.L, -2) !== 0) {
855
898
  if (lua.lua_type(this.L, -2) === lua.LUA_TSTRING) {
@@ -860,10 +903,10 @@ class LuaEngine {
860
903
  }
861
904
  lua.lua_pop(this.L, 1);
862
905
  }
863
- lua.lua_pop(this.L, 1); // pop result table
906
+ lua.lua_pop(this.L, 1);
864
907
  return result;
865
908
  }
866
- // Full path: marshal entire result table to JS
909
+ // Full path
867
910
  const result = luaToJS(this.L, -1);
868
911
  lua.lua_pop(this.L, 1);
869
912
  if (!result || typeof result !== 'object' || Array.isArray(result)) {