@energy8platform/game-engine 0.10.6 → 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.cjs.js CHANGED
@@ -371,10 +371,13 @@ function evaluateComparison(expr, variables) {
371
371
  }
372
372
  }
373
373
 
374
+ const MAX_SESSION_SPINS = 200;
374
375
  /**
375
- * Manages session lifecycle: creation, spin counting, retriggers, and completion.
376
- * Handles both slot sessions (fixed spin count) and table game sessions (unlimited).
377
- * Also manages _persist_ data roundtrip between Lua calls.
376
+ * Manages session lifecycle matching the platform server behavior:
377
+ * - createSession: initial spin counted (spinsPlayed=1, totalWin=spinWin)
378
+ * - updateSession: accumulates win, decrements spins, checks max win cap on session level
379
+ * - completeSession: returns cumulative totalWin, cleans up session vars
380
+ * - Safety cap: 200 spins max per session
378
381
  */
379
382
  class SessionManager {
380
383
  session = null;
@@ -389,36 +392,57 @@ class SessionManager {
389
392
  get sessionTotalWin() {
390
393
  return this.session?.totalWin ?? 0;
391
394
  }
392
- /** Create a new session from a transition rule */
393
- createSession(rule, variables, bet) {
394
- let spinsRemaining = -1; // unlimited by default
395
+ /** Get the fixed bet amount from the session (server uses session bet, not request bet) */
396
+ get sessionBet() {
397
+ return this.session?.bet;
398
+ }
399
+ /** Get spinsVarName to restore free_spins_remaining into variables */
400
+ get spinsVarName() {
401
+ return this.session?.spinsVarName;
402
+ }
403
+ get spinsRemaining() {
404
+ return this.session?.spinsRemaining ?? 0;
405
+ }
406
+ /**
407
+ * Create a new session from a transition rule.
408
+ * Server behavior: initial spin is already counted (spinsPlayed=1, totalWin includes spinWin).
409
+ */
410
+ createSession(rule, variables, bet, spinWin, maxWinCap) {
411
+ let spinsRemaining = -1;
412
+ let spinsVarName;
395
413
  if (rule.session_config?.total_spins_var) {
396
- const varName = rule.session_config.total_spins_var;
397
- spinsRemaining = variables[varName] ?? -1;
414
+ spinsVarName = rule.session_config.total_spins_var;
415
+ spinsRemaining = variables[spinsVarName] ?? -1;
398
416
  }
417
+ const persistentVarNames = rule.session_config?.persistent_vars ?? [];
399
418
  const persistentVars = {};
400
- if (rule.session_config?.persistent_vars) {
401
- for (const varName of rule.session_config.persistent_vars) {
402
- persistentVars[varName] = variables[varName] ?? 0;
403
- }
419
+ for (const varName of persistentVarNames) {
420
+ persistentVars[varName] = variables[varName] ?? 0;
404
421
  }
405
422
  this.session = {
406
423
  spinsRemaining,
407
- spinsPlayed: 0,
408
- totalWin: 0,
424
+ spinsPlayed: 1, // initial spin counts
425
+ totalWin: spinWin, // initial spin win included
409
426
  completed: false,
410
427
  maxWinReached: false,
411
428
  bet,
429
+ maxWinCap,
430
+ spinsVarName,
431
+ persistentVarNames,
412
432
  persistentVars,
413
433
  persistentData: {},
414
434
  };
415
435
  return this.toSessionData();
416
436
  }
417
- /** Update session after a spin: decrement counter, accumulate win, handle retrigger */
437
+ /**
438
+ * Update session after a bonus spin.
439
+ * Server behavior: accumulate win, decrement spins, check retrigger, check max win cap,
440
+ * safety cap at 200 spins.
441
+ */
418
442
  updateSession(rule, variables, spinWin) {
419
443
  if (!this.session)
420
444
  throw new Error('No active session');
421
- // Accumulate win
445
+ // Accumulate win and count spin
422
446
  this.session.totalWin += spinWin;
423
447
  this.session.spinsPlayed++;
424
448
  // Decrement spins (only for non-unlimited sessions)
@@ -432,29 +456,39 @@ class SessionManager {
432
456
  this.session.spinsRemaining += extraSpins;
433
457
  }
434
458
  }
435
- // Update persistent vars
436
- if (this.session.persistentVars) {
437
- for (const key of Object.keys(this.session.persistentVars)) {
438
- if (key in variables) {
439
- this.session.persistentVars[key] = variables[key];
440
- }
459
+ // Safety cap: server limits sessions to 200 spins
460
+ if (this.session.spinsPlayed >= MAX_SESSION_SPINS) {
461
+ this.session.spinsRemaining = 0;
462
+ }
463
+ // Update session persistent vars from current variables
464
+ for (const varName of this.session.persistentVarNames) {
465
+ if (varName in variables) {
466
+ this.session.persistentVars[varName] = variables[varName];
441
467
  }
442
468
  }
443
- // Auto-complete if spins exhausted
444
- if (this.session.spinsRemaining === 0) {
469
+ // Check max win cap (on session level, not per spin)
470
+ if (this.session.maxWinCap !== undefined && this.session.totalWin >= this.session.maxWinCap) {
471
+ this.session.totalWin = this.session.maxWinCap;
472
+ this.session.spinsRemaining = 0;
473
+ this.session.maxWinReached = true;
474
+ }
475
+ // Auto-complete if spins exhausted or explicit complete
476
+ if (this.session.spinsRemaining === 0 || rule.complete_session) {
445
477
  this.session.completed = true;
446
478
  }
447
479
  return this.toSessionData();
448
480
  }
449
- /** Complete the session, return accumulated totalWin and list of session-scoped vars to clean up */
481
+ /**
482
+ * Complete the session explicitly.
483
+ * Returns cumulative totalWin and list of session-scoped var names to clean up.
484
+ */
450
485
  completeSession() {
451
486
  if (!this.session)
452
487
  throw new Error('No active session to complete');
453
488
  this.session.completed = true;
454
489
  const totalWin = this.session.totalWin;
455
490
  const session = this.toSessionData();
456
- const sessionVarNames = Object.keys(this.session.persistentVars);
457
- // Clear session state so it doesn't leak into the next round
491
+ const sessionVarNames = [...this.session.persistentVarNames];
458
492
  this.session = null;
459
493
  return { totalWin, session, sessionVarNames };
460
494
  }
@@ -476,16 +510,16 @@ class SessionManager {
476
510
  }
477
511
  }
478
512
  }
479
- /** Get _ps_* params to inject into next execute() call */
513
+ /** Get persistent params to inject into next execute() call */
480
514
  getPersistentParams() {
481
515
  if (!this.session)
482
516
  return {};
483
517
  const params = {};
484
- // Session persistent vars (float64)
518
+ // Session persistent vars (float64) → state.variables
485
519
  for (const [key, value] of Object.entries(this.session.persistentVars)) {
486
520
  params[key] = value;
487
521
  }
488
- // _persist_ complex data → _ps_*
522
+ // _persist_ complex data → _ps_* in state.params
489
523
  for (const [key, value] of Object.entries(this.session.persistentData)) {
490
524
  params[`_ps_${key}`] = value;
491
525
  }
@@ -583,22 +617,20 @@ class PersistentState {
583
617
 
584
618
  const { lua, lauxlib, lualib } = fengari;
585
619
  const { to_luastring, to_jsstring } = fengari;
620
+ /** Default engine variables matching the server's NewGameState() */
621
+ const DEFAULT_VARIABLES = {
622
+ multiplier: 1,
623
+ total_multiplier: 1,
624
+ global_multiplier: 1,
625
+ last_win_amount: 0,
626
+ free_spins_awarded: 0,
627
+ };
586
628
  /**
587
629
  * Runs Lua game scripts locally, replicating the platform's server-side execution.
588
630
  *
589
- * Implements the full lifecycle: action routing → state assembly → Lua execute() →
590
- * result extractiontransition evaluationsession management.
591
- *
592
- * @example
593
- * ```ts
594
- * const engine = new LuaEngine({
595
- * script: luaSource,
596
- * gameDefinition: { id: 'my-slot', type: 'SLOT', actions: { ... } },
597
- * });
598
- *
599
- * const result = engine.execute({ action: 'spin', bet: 1.0 });
600
- * // result.data.matrix, result.totalWin, result.nextActions, etc.
601
- * ```
631
+ * Implements the full lifecycle matching `casino_platform/internal/usecase/game_usecase.go`:
632
+ * action routingstate assemblyLua execute() → result extraction →
633
+ * transition evaluation → session management.
602
634
  */
603
635
  class LuaEngine {
604
636
  L;
@@ -608,22 +640,20 @@ class LuaEngine {
608
640
  persistentState;
609
641
  gameDefinition;
610
642
  variables = {};
643
+ simulationMode;
611
644
  constructor(config) {
612
645
  this.gameDefinition = config.gameDefinition;
613
- // Set up RNG
646
+ this.simulationMode = config.simulationMode ?? false;
614
647
  const rng = config.seed !== undefined
615
648
  ? createSeededRng(config.seed)
616
649
  : undefined;
617
- // Initialize sub-managers
618
650
  this.api = new LuaEngineAPI(config.gameDefinition, rng, config.logger);
619
651
  this.actionRouter = new ActionRouter(config.gameDefinition);
620
652
  this.sessionManager = new SessionManager();
621
653
  this.persistentState = new PersistentState(config.gameDefinition.persistent_state);
622
- // Create Lua state and load standard libraries
623
654
  this.L = lauxlib.luaL_newstate();
624
655
  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.
656
+ // Polyfill Lua 5.1/5.2 functions removed in 5.3
627
657
  lauxlib.luaL_dostring(this.L, to_luastring(`
628
658
  math.pow = function(a, b) return a ^ b end
629
659
  math.atan2 = math.atan2 or function(y, x) return math.atan(y, x) end
@@ -641,32 +671,36 @@ class LuaEngine {
641
671
  loadstring = loadstring or load
642
672
  table.getn = table.getn or function(t) return #t end
643
673
  `));
644
- // Register engine.* API
645
674
  this.api.register(this.L);
646
- // Load and compile the script
647
675
  this.loadScript(config.script);
648
676
  }
649
- /** Current session data (if any) */
650
677
  get session() {
651
678
  return this.sessionManager.current;
652
679
  }
653
- /** Current persistent state values */
654
680
  get persistentVars() {
655
681
  return { ...this.variables };
656
682
  }
657
683
  /**
658
- * Execute a play action — the main entry point.
659
- * This is what DevBridge calls on each PLAY_REQUEST.
684
+ * Execute a play action — replicates server's Play() function.
660
685
  */
661
686
  execute(params) {
662
- const { action: actionName, bet, params: clientParams } = params;
663
- // 1. Resolve the action definition
687
+ const { action: actionName, params: clientParams } = params;
688
+ // 1. Resolve action
664
689
  const action = this.actionRouter.resolveAction(actionName, this.sessionManager.isActive);
665
- // 2. Build state.variables
666
- const stateVars = { ...this.variables, bet };
690
+ // 2. Determine bet — server uses session bet for session actions
691
+ let bet = params.bet;
692
+ if (this.sessionManager.isActive && this.sessionManager.sessionBet !== undefined) {
693
+ bet = this.sessionManager.sessionBet;
694
+ }
695
+ // 3. Build state.variables (matching server's NewGameState + restore)
696
+ const stateVars = {
697
+ ...DEFAULT_VARIABLES,
698
+ ...this.variables,
699
+ bet,
700
+ };
667
701
  // Load cross-spin persistent state
668
702
  this.persistentState.loadIntoVariables(stateVars);
669
- // Load session persistent vars
703
+ // Load session persistent vars + restore spinsRemaining
670
704
  if (this.sessionManager.isActive) {
671
705
  const sessionParams = this.sessionManager.getPersistentParams();
672
706
  for (const [k, v] of Object.entries(sessionParams)) {
@@ -674,8 +708,14 @@ class LuaEngine {
674
708
  stateVars[k] = v;
675
709
  }
676
710
  }
711
+ // Restore spinsRemaining into the variable the script reads
712
+ if (this.sessionManager.spinsVarName) {
713
+ stateVars[this.sessionManager.spinsVarName] = this.sessionManager.spinsRemaining;
714
+ }
715
+ // Also set free_spins_remaining for convenience
716
+ stateVars.free_spins_remaining = this.sessionManager.spinsRemaining;
677
717
  }
678
- // 3. Build state.params
718
+ // 4. Build state.params
679
719
  const stateParams = { ...clientParams };
680
720
  stateParams._action = actionName;
681
721
  // Inject session _ps_* persistent data
@@ -703,16 +743,16 @@ class LuaEngine {
703
743
  if (clientParams?.ante_bet && this.gameDefinition.ante_bet) {
704
744
  stateParams.ante_bet = true;
705
745
  }
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
746
+ // 5. Execute Lua (server: executor.Execute(stage, state))
747
+ const luaResult = this.callLuaExecute(action.stage, actionName, stateParams, stateVars);
748
+ // 6. Process result (server: ApplyLuaResult)
709
749
  const totalWinMultiplier = typeof luaResult.total_win === 'number' ? luaResult.total_win : 0;
710
750
  const resultVariables = (luaResult.variables ?? {});
711
- const totalWin = Math.round(totalWinMultiplier * bet * 100) / 100;
751
+ const spinWin = Math.round(totalWinMultiplier * bet * 100) / 100;
712
752
  // Merge result variables into engine variables
713
753
  Object.assign(stateVars, resultVariables);
714
754
  this.variables = { ...stateVars };
715
- delete this.variables.bet; // bet is per-spin, not persistent
755
+ delete this.variables.bet;
716
756
  // Build client data (everything except special keys)
717
757
  const data = {};
718
758
  for (const [key, value] of Object.entries(luaResult)) {
@@ -720,17 +760,6 @@ 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);
@@ -747,31 +776,56 @@ class LuaEngine {
747
776
  delete data[key];
748
777
  }
749
778
  }
750
- // 8. Evaluate transitions
779
+ // 8. Evaluate transitions (server: evaluateTransitions)
751
780
  const { rule, nextActions } = this.actionRouter.evaluateTransitions(action, this.variables);
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, this.variables, bet, spinWin, maxWinCap);
757
792
  creditDeferred = true;
793
+ resultTotalWin = spinWin;
794
+ // Clear the trigger variable — it was consumed to set spinsRemaining
795
+ if (rule.session_config?.total_spins_var) {
796
+ delete this.variables[rule.session_config.total_spins_var];
797
+ }
758
798
  }
759
- // Handle session update
760
799
  else if (this.sessionManager.isActive) {
761
- session = this.sessionManager.updateSession(rule, this.variables, cappedWin);
762
- // Handle session completion
763
- if (rule.complete_session || session?.completed) {
800
+ // UPDATE SESSION — accumulate win, check completion
801
+ session = this.sessionManager.updateSession(rule, this.variables, spinWin);
802
+ if (session?.completed) {
803
+ // SESSION COMPLETED — server returns session.TotalWin as result.TotalWin
764
804
  const completed = this.sessionManager.completeSession();
765
805
  session = completed.session;
806
+ resultTotalWin = completed.totalWin;
807
+ sessionCompleted = true;
766
808
  creditDeferred = false;
767
- // Clean up session-scoped variables so they don't leak into next round
809
+ // Clean up session-scoped variables
768
810
  for (const varName of completed.sessionVarNames) {
769
811
  delete this.variables[varName];
770
812
  }
771
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;
772
826
  }
773
827
  return {
774
- totalWin: cappedWin,
828
+ totalWin: Math.round(resultTotalWin * 100) / 100,
775
829
  data,
776
830
  nextActions,
777
831
  session,
@@ -779,13 +833,11 @@ class LuaEngine {
779
833
  creditDeferred,
780
834
  };
781
835
  }
782
- /** Reset all state (sessions, persistent vars, variables) */
783
836
  reset() {
784
837
  this.variables = {};
785
838
  this.sessionManager.reset();
786
839
  this.persistentState.reset();
787
840
  }
788
- /** Destroy the Lua VM */
789
841
  destroy() {
790
842
  if (this.L) {
791
843
  lua.lua_close(this.L);
@@ -800,7 +852,6 @@ class LuaEngine {
800
852
  lua.lua_pop(this.L, 1);
801
853
  throw new Error(`Failed to load Lua script: ${err}`);
802
854
  }
803
- // Verify that execute() function exists
804
855
  lua.lua_getglobal(this.L, to_luastring('execute'));
805
856
  if (lua.lua_type(this.L, -1) !== lua.LUA_TFUNCTION) {
806
857
  lua.lua_pop(this.L, 1);
@@ -808,28 +859,54 @@ class LuaEngine {
808
859
  }
809
860
  lua.lua_pop(this.L, 1);
810
861
  }
811
- callLuaExecute(stage, params, variables) {
812
- // Push the execute function
862
+ callLuaExecute(stage, action, params, variables) {
813
863
  lua.lua_getglobal(this.L, to_luastring('execute'));
814
- // Build and push the state table
815
- lua.lua_createtable(this.L, 0, 3);
864
+ // Build state table: {stage, action, params, variables}
865
+ lua.lua_createtable(this.L, 0, 4);
816
866
  // state.stage
817
867
  lua.lua_pushstring(this.L, to_luastring(stage));
818
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'));
819
872
  // state.params
820
873
  pushJSValue(this.L, params);
821
874
  lua.lua_setfield(this.L, -2, to_luastring('params'));
822
875
  // state.variables
823
876
  pushJSValue(this.L, variables);
824
877
  lua.lua_setfield(this.L, -2, to_luastring('variables'));
825
- // Call execute(state) → 1 result
826
878
  const status = lua.lua_pcall(this.L, 1, 1, 0);
827
879
  if (status !== lua.LUA_OK) {
828
880
  const err = to_jsstring(lua.lua_tostring(this.L, -1));
829
881
  lua.lua_pop(this.L, 1);
830
882
  throw new Error(`Lua execute() failed: ${err}`);
831
883
  }
832
- // Marshal result table to JS
884
+ if (this.simulationMode) {
885
+ // Fast path: extract only total_win, variables, _persist_* keys
886
+ const result = {};
887
+ lua.lua_getfield(this.L, -1, to_luastring('total_win'));
888
+ result.total_win = lua.lua_type(this.L, -1) === lua.LUA_TNUMBER
889
+ ? lua.lua_tonumber(this.L, -1) : 0;
890
+ lua.lua_pop(this.L, 1);
891
+ lua.lua_getfield(this.L, -1, to_luastring('variables'));
892
+ if (lua.lua_type(this.L, -1) === lua.LUA_TTABLE) {
893
+ result.variables = luaToJS(this.L, -1);
894
+ }
895
+ lua.lua_pop(this.L, 1);
896
+ lua.lua_pushnil(this.L);
897
+ while (lua.lua_next(this.L, -2) !== 0) {
898
+ if (lua.lua_type(this.L, -2) === lua.LUA_TSTRING) {
899
+ const key = to_jsstring(lua.lua_tostring(this.L, -2));
900
+ if (key.startsWith('_persist_')) {
901
+ result[key] = luaToJS(this.L, -1);
902
+ }
903
+ }
904
+ lua.lua_pop(this.L, 1);
905
+ }
906
+ lua.lua_pop(this.L, 1);
907
+ return result;
908
+ }
909
+ // Full path
833
910
  const result = luaToJS(this.L, -1);
834
911
  lua.lua_pop(this.L, 1);
835
912
  if (!result || typeof result !== 'object' || Array.isArray(result)) {
@@ -891,7 +968,8 @@ class SimulationRunner {
891
968
  script,
892
969
  gameDefinition,
893
970
  seed,
894
- logger: () => { }, // suppress Lua logs during simulation
971
+ logger: () => { },
972
+ simulationMode: true,
895
973
  });
896
974
  const spinCost = this.calculateSpinCost(startAction, bet, gameDefinition, params);
897
975
  let totalWagered = 0;
@@ -908,36 +986,29 @@ class SimulationRunner {
908
986
  for (let i = 0; i < iterations; i++) {
909
987
  totalWagered += spinCost;
910
988
  let roundWin = 0;
989
+ let roundBonusWin = 0;
911
990
  // Execute the starting action
912
- const result = engine.execute({
991
+ let result = engine.execute({
913
992
  action: startAction,
914
993
  bet,
915
994
  params,
916
995
  });
917
996
  const baseWin = result.totalWin;
918
997
  roundWin += baseWin;
919
- // If a bonus session was created, play through it
998
+ // If a session was created, play through it using nextActions from the engine
920
999
  if (result.session && !result.session.completed) {
921
1000
  bonusTriggered++;
922
- // Find the bonus action from nextActions (different from startAction)
923
- const bonusAction = result.nextActions.find(a => a !== startAction)
924
- ?? result.nextActions[0];
925
- // Play bonus spins until session completes
926
- let bonusSessionWin = 0;
927
1001
  let safetyLimit = 10_000;
928
- let lastResult = result;
929
- while (lastResult.session && !lastResult.session.completed && safetyLimit-- > 0) {
930
- lastResult = engine.execute({
931
- action: bonusAction,
932
- bet,
933
- });
934
- bonusSessionWin += lastResult.totalWin;
1002
+ while (result.session && !result.session.completed && safetyLimit-- > 0) {
1003
+ const nextAction = result.nextActions[0];
1004
+ result = engine.execute({ action: nextAction, bet });
1005
+ roundBonusWin += result.totalWin;
935
1006
  bonusSpinsPlayed++;
936
1007
  }
937
- bonusWin += bonusSessionWin;
938
- roundWin += bonusSessionWin;
1008
+ roundWin += roundBonusWin;
939
1009
  }
940
1010
  baseGameWin += baseWin;
1011
+ bonusWin += roundBonusWin;
941
1012
  totalWon += roundWin;
942
1013
  if (roundWin > 0)
943
1014
  hits++;