@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.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;
@@ -606,22 +638,20 @@ class LuaEngine {
606
638
  persistentState;
607
639
  gameDefinition;
608
640
  variables = {};
641
+ simulationMode;
609
642
  constructor(config) {
610
643
  this.gameDefinition = config.gameDefinition;
611
- // Set up RNG
644
+ this.simulationMode = config.simulationMode ?? false;
612
645
  const rng = config.seed !== undefined
613
646
  ? createSeededRng(config.seed)
614
647
  : undefined;
615
- // Initialize sub-managers
616
648
  this.api = new LuaEngineAPI(config.gameDefinition, rng, config.logger);
617
649
  this.actionRouter = new ActionRouter(config.gameDefinition);
618
650
  this.sessionManager = new SessionManager();
619
651
  this.persistentState = new PersistentState(config.gameDefinition.persistent_state);
620
- // Create Lua state and load standard libraries
621
652
  this.L = lauxlib.luaL_newstate();
622
653
  lualib.luaL_openlibs(this.L);
623
- // Polyfill Lua 5.1/5.2 functions removed in 5.3.
624
- // Platform server may use gopher-lua (5.1) — these ensure compatibility.
654
+ // Polyfill Lua 5.1/5.2 functions removed in 5.3
625
655
  lauxlib.luaL_dostring(this.L, to_luastring(`
626
656
  math.pow = function(a, b) return a ^ b end
627
657
  math.atan2 = math.atan2 or function(y, x) return math.atan(y, x) end
@@ -639,32 +669,36 @@ class LuaEngine {
639
669
  loadstring = loadstring or load
640
670
  table.getn = table.getn or function(t) return #t end
641
671
  `));
642
- // Register engine.* API
643
672
  this.api.register(this.L);
644
- // Load and compile the script
645
673
  this.loadScript(config.script);
646
674
  }
647
- /** Current session data (if any) */
648
675
  get session() {
649
676
  return this.sessionManager.current;
650
677
  }
651
- /** Current persistent state values */
652
678
  get persistentVars() {
653
679
  return { ...this.variables };
654
680
  }
655
681
  /**
656
- * Execute a play action — the main entry point.
657
- * This is what DevBridge calls on each PLAY_REQUEST.
682
+ * Execute a play action — replicates server's Play() function.
658
683
  */
659
684
  execute(params) {
660
- const { action: actionName, bet, params: clientParams } = params;
661
- // 1. Resolve the action definition
685
+ const { action: actionName, params: clientParams } = params;
686
+ // 1. Resolve action
662
687
  const action = this.actionRouter.resolveAction(actionName, this.sessionManager.isActive);
663
- // 2. Build state.variables
664
- 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
+ };
665
699
  // Load cross-spin persistent state
666
700
  this.persistentState.loadIntoVariables(stateVars);
667
- // Load session persistent vars
701
+ // Load session persistent vars + restore spinsRemaining
668
702
  if (this.sessionManager.isActive) {
669
703
  const sessionParams = this.sessionManager.getPersistentParams();
670
704
  for (const [k, v] of Object.entries(sessionParams)) {
@@ -672,8 +706,14 @@ class LuaEngine {
672
706
  stateVars[k] = v;
673
707
  }
674
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;
675
715
  }
676
- // 3. Build state.params
716
+ // 4. Build state.params
677
717
  const stateParams = { ...clientParams };
678
718
  stateParams._action = actionName;
679
719
  // Inject session _ps_* persistent data
@@ -701,16 +741,16 @@ class LuaEngine {
701
741
  if (clientParams?.ante_bet && this.gameDefinition.ante_bet) {
702
742
  stateParams.ante_bet = true;
703
743
  }
704
- // 4. Build the state table and call Lua execute()
705
- const luaResult = this.callLuaExecute(action.stage, stateParams, stateVars);
706
- // 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)
707
747
  const totalWinMultiplier = typeof luaResult.total_win === 'number' ? luaResult.total_win : 0;
708
748
  const resultVariables = (luaResult.variables ?? {});
709
- const totalWin = Math.round(totalWinMultiplier * bet * 100) / 100;
749
+ const spinWin = Math.round(totalWinMultiplier * bet * 100) / 100;
710
750
  // Merge result variables into engine variables
711
751
  Object.assign(stateVars, resultVariables);
712
752
  this.variables = { ...stateVars };
713
- delete this.variables.bet; // bet is per-spin, not persistent
753
+ delete this.variables.bet;
714
754
  // Build client data (everything except special keys)
715
755
  const data = {};
716
756
  for (const [key, value] of Object.entries(luaResult)) {
@@ -718,17 +758,6 @@ class LuaEngine {
718
758
  data[key] = value;
719
759
  }
720
760
  }
721
- // 6. Apply max win cap
722
- let cappedWin = totalWin;
723
- if (this.gameDefinition.max_win) {
724
- const cap = this.calculateMaxWinCap(bet);
725
- if (cap !== undefined && totalWin > cap) {
726
- cappedWin = cap;
727
- this.variables.max_win_reached = 1;
728
- data.max_win_reached = true;
729
- this.sessionManager.markMaxWinReached();
730
- }
731
- }
732
761
  // 7. Handle _persist_* and _persist_game_* keys
733
762
  this.sessionManager.storePersistData(data);
734
763
  this.persistentState.storeGameData(data);
@@ -745,31 +774,56 @@ class LuaEngine {
745
774
  delete data[key];
746
775
  }
747
776
  }
748
- // 8. Evaluate transitions
777
+ // 8. Evaluate transitions (server: evaluateTransitions)
749
778
  const { rule, nextActions } = this.actionRouter.evaluateTransitions(action, this.variables);
779
+ // 9. Determine credit behavior (server: creditNow logic)
750
780
  let creditDeferred = action.credit === 'defer' || rule.credit_override === 'defer';
781
+ // 10. Session lifecycle (server: create/update/complete session)
751
782
  let session = this.sessionManager.current;
752
- // Handle session creation
783
+ let resultTotalWin = spinWin;
784
+ let sessionCompleted = false;
785
+ // Calculate max win cap for session
786
+ const maxWinCap = this.calculateMaxWinCap(bet);
753
787
  if (rule.creates_session && !this.sessionManager.isActive) {
754
- 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);
755
790
  creditDeferred = true;
791
+ resultTotalWin = spinWin;
792
+ // Clear the trigger variable — it was consumed to set spinsRemaining
793
+ if (rule.session_config?.total_spins_var) {
794
+ delete this.variables[rule.session_config.total_spins_var];
795
+ }
756
796
  }
757
- // Handle session update
758
797
  else if (this.sessionManager.isActive) {
759
- session = this.sessionManager.updateSession(rule, this.variables, cappedWin);
760
- // Handle session completion
761
- 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
762
802
  const completed = this.sessionManager.completeSession();
763
803
  session = completed.session;
804
+ resultTotalWin = completed.totalWin;
805
+ sessionCompleted = true;
764
806
  creditDeferred = false;
765
- // Clean up session-scoped variables so they don't leak into next round
807
+ // Clean up session-scoped variables
766
808
  for (const varName of completed.sessionVarNames) {
767
809
  delete this.variables[varName];
768
810
  }
769
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;
770
824
  }
771
825
  return {
772
- totalWin: cappedWin,
826
+ totalWin: Math.round(resultTotalWin * 100) / 100,
773
827
  data,
774
828
  nextActions,
775
829
  session,
@@ -777,13 +831,11 @@ class LuaEngine {
777
831
  creditDeferred,
778
832
  };
779
833
  }
780
- /** Reset all state (sessions, persistent vars, variables) */
781
834
  reset() {
782
835
  this.variables = {};
783
836
  this.sessionManager.reset();
784
837
  this.persistentState.reset();
785
838
  }
786
- /** Destroy the Lua VM */
787
839
  destroy() {
788
840
  if (this.L) {
789
841
  lua.lua_close(this.L);
@@ -798,7 +850,6 @@ class LuaEngine {
798
850
  lua.lua_pop(this.L, 1);
799
851
  throw new Error(`Failed to load Lua script: ${err}`);
800
852
  }
801
- // Verify that execute() function exists
802
853
  lua.lua_getglobal(this.L, to_luastring('execute'));
803
854
  if (lua.lua_type(this.L, -1) !== lua.LUA_TFUNCTION) {
804
855
  lua.lua_pop(this.L, 1);
@@ -806,28 +857,54 @@ class LuaEngine {
806
857
  }
807
858
  lua.lua_pop(this.L, 1);
808
859
  }
809
- callLuaExecute(stage, params, variables) {
810
- // Push the execute function
860
+ callLuaExecute(stage, action, params, variables) {
811
861
  lua.lua_getglobal(this.L, to_luastring('execute'));
812
- // Build and push the state table
813
- lua.lua_createtable(this.L, 0, 3);
862
+ // Build state table: {stage, action, params, variables}
863
+ lua.lua_createtable(this.L, 0, 4);
814
864
  // state.stage
815
865
  lua.lua_pushstring(this.L, to_luastring(stage));
816
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'));
817
870
  // state.params
818
871
  pushJSValue(this.L, params);
819
872
  lua.lua_setfield(this.L, -2, to_luastring('params'));
820
873
  // state.variables
821
874
  pushJSValue(this.L, variables);
822
875
  lua.lua_setfield(this.L, -2, to_luastring('variables'));
823
- // Call execute(state) → 1 result
824
876
  const status = lua.lua_pcall(this.L, 1, 1, 0);
825
877
  if (status !== lua.LUA_OK) {
826
878
  const err = to_jsstring(lua.lua_tostring(this.L, -1));
827
879
  lua.lua_pop(this.L, 1);
828
880
  throw new Error(`Lua execute() failed: ${err}`);
829
881
  }
830
- // Marshal result table to JS
882
+ if (this.simulationMode) {
883
+ // Fast path: extract only total_win, variables, _persist_* keys
884
+ const result = {};
885
+ lua.lua_getfield(this.L, -1, to_luastring('total_win'));
886
+ result.total_win = lua.lua_type(this.L, -1) === lua.LUA_TNUMBER
887
+ ? lua.lua_tonumber(this.L, -1) : 0;
888
+ lua.lua_pop(this.L, 1);
889
+ lua.lua_getfield(this.L, -1, to_luastring('variables'));
890
+ if (lua.lua_type(this.L, -1) === lua.LUA_TTABLE) {
891
+ result.variables = luaToJS(this.L, -1);
892
+ }
893
+ lua.lua_pop(this.L, 1);
894
+ lua.lua_pushnil(this.L);
895
+ while (lua.lua_next(this.L, -2) !== 0) {
896
+ if (lua.lua_type(this.L, -2) === lua.LUA_TSTRING) {
897
+ const key = to_jsstring(lua.lua_tostring(this.L, -2));
898
+ if (key.startsWith('_persist_')) {
899
+ result[key] = luaToJS(this.L, -1);
900
+ }
901
+ }
902
+ lua.lua_pop(this.L, 1);
903
+ }
904
+ lua.lua_pop(this.L, 1);
905
+ return result;
906
+ }
907
+ // Full path
831
908
  const result = luaToJS(this.L, -1);
832
909
  lua.lua_pop(this.L, 1);
833
910
  if (!result || typeof result !== 'object' || Array.isArray(result)) {
@@ -889,7 +966,8 @@ class SimulationRunner {
889
966
  script,
890
967
  gameDefinition,
891
968
  seed,
892
- logger: () => { }, // suppress Lua logs during simulation
969
+ logger: () => { },
970
+ simulationMode: true,
893
971
  });
894
972
  const spinCost = this.calculateSpinCost(startAction, bet, gameDefinition, params);
895
973
  let totalWagered = 0;
@@ -906,36 +984,29 @@ class SimulationRunner {
906
984
  for (let i = 0; i < iterations; i++) {
907
985
  totalWagered += spinCost;
908
986
  let roundWin = 0;
987
+ let roundBonusWin = 0;
909
988
  // Execute the starting action
910
- const result = engine.execute({
989
+ let result = engine.execute({
911
990
  action: startAction,
912
991
  bet,
913
992
  params,
914
993
  });
915
994
  const baseWin = result.totalWin;
916
995
  roundWin += baseWin;
917
- // If a bonus session was created, play through it
996
+ // If a session was created, play through it using nextActions from the engine
918
997
  if (result.session && !result.session.completed) {
919
998
  bonusTriggered++;
920
- // Find the bonus action from nextActions (different from startAction)
921
- const bonusAction = result.nextActions.find(a => a !== startAction)
922
- ?? result.nextActions[0];
923
- // Play bonus spins until session completes
924
- let bonusSessionWin = 0;
925
999
  let safetyLimit = 10_000;
926
- let lastResult = result;
927
- while (lastResult.session && !lastResult.session.completed && safetyLimit-- > 0) {
928
- lastResult = engine.execute({
929
- action: bonusAction,
930
- bet,
931
- });
932
- bonusSessionWin += lastResult.totalWin;
1000
+ while (result.session && !result.session.completed && safetyLimit-- > 0) {
1001
+ const nextAction = result.nextActions[0];
1002
+ result = engine.execute({ action: nextAction, bet });
1003
+ roundBonusWin += result.totalWin;
933
1004
  bonusSpinsPlayed++;
934
1005
  }
935
- bonusWin += bonusSessionWin;
936
- roundWin += bonusSessionWin;
1006
+ roundWin += roundBonusWin;
937
1007
  }
938
1008
  baseGameWin += baseWin;
1009
+ bonusWin += roundBonusWin;
939
1010
  totalWon += roundWin;
940
1011
  if (roundWin > 0)
941
1012
  hits++;