@energy8platform/game-engine 0.9.2 → 0.10.1

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.
@@ -0,0 +1,178 @@
1
+ import type { SessionData } from '@energy8platform/game-sdk';
2
+ import type { TransitionRule } from './types';
3
+
4
+ interface SessionState {
5
+ spinsRemaining: number;
6
+ spinsPlayed: number;
7
+ totalWin: number;
8
+ completed: boolean;
9
+ maxWinReached: boolean;
10
+ bet: number;
11
+ persistentVars: Record<string, number>;
12
+ persistentData: Record<string, unknown>;
13
+ }
14
+
15
+ /**
16
+ * Manages session lifecycle: creation, spin counting, retriggers, and completion.
17
+ * Handles both slot sessions (fixed spin count) and table game sessions (unlimited).
18
+ * Also manages _persist_ data roundtrip between Lua calls.
19
+ */
20
+ export class SessionManager {
21
+ private session: SessionState | null = null;
22
+
23
+ get isActive(): boolean {
24
+ return this.session !== null && !this.session.completed;
25
+ }
26
+
27
+ get current(): SessionData | null {
28
+ if (!this.session) return null;
29
+ return this.toSessionData();
30
+ }
31
+
32
+ get sessionTotalWin(): number {
33
+ return this.session?.totalWin ?? 0;
34
+ }
35
+
36
+ /** Create a new session from a transition rule */
37
+ createSession(
38
+ rule: TransitionRule,
39
+ variables: Record<string, number>,
40
+ bet: number,
41
+ ): SessionData {
42
+ let spinsRemaining = -1; // unlimited by default
43
+ if (rule.session_config?.total_spins_var) {
44
+ const varName = rule.session_config.total_spins_var;
45
+ spinsRemaining = variables[varName] ?? -1;
46
+ }
47
+
48
+ const persistentVars: Record<string, number> = {};
49
+ if (rule.session_config?.persistent_vars) {
50
+ for (const varName of rule.session_config.persistent_vars) {
51
+ persistentVars[varName] = variables[varName] ?? 0;
52
+ }
53
+ }
54
+
55
+ this.session = {
56
+ spinsRemaining,
57
+ spinsPlayed: 0,
58
+ totalWin: 0,
59
+ completed: false,
60
+ maxWinReached: false,
61
+ bet,
62
+ persistentVars,
63
+ persistentData: {},
64
+ };
65
+
66
+ return this.toSessionData();
67
+ }
68
+
69
+ /** Update session after a spin: decrement counter, accumulate win, handle retrigger */
70
+ updateSession(
71
+ rule: TransitionRule,
72
+ variables: Record<string, number>,
73
+ spinWin: number,
74
+ ): SessionData {
75
+ if (!this.session) throw new Error('No active session');
76
+
77
+ // Accumulate win
78
+ this.session.totalWin += spinWin;
79
+ this.session.spinsPlayed++;
80
+
81
+ // Decrement spins (only for non-unlimited sessions)
82
+ if (this.session.spinsRemaining > 0) {
83
+ this.session.spinsRemaining--;
84
+ }
85
+
86
+ // Handle retrigger (add_spins_var)
87
+ if (rule.add_spins_var) {
88
+ const extraSpins = variables[rule.add_spins_var] ?? 0;
89
+ if (extraSpins > 0 && this.session.spinsRemaining >= 0) {
90
+ this.session.spinsRemaining += extraSpins;
91
+ }
92
+ }
93
+
94
+ // Update persistent vars
95
+ if (this.session.persistentVars) {
96
+ for (const key of Object.keys(this.session.persistentVars)) {
97
+ if (key in variables) {
98
+ this.session.persistentVars[key] = variables[key];
99
+ }
100
+ }
101
+ }
102
+
103
+ // Auto-complete if spins exhausted
104
+ if (this.session.spinsRemaining === 0) {
105
+ this.session.completed = true;
106
+ }
107
+
108
+ return this.toSessionData();
109
+ }
110
+
111
+ /** Complete the session, return accumulated totalWin */
112
+ completeSession(): { totalWin: number; session: SessionData } {
113
+ if (!this.session) throw new Error('No active session to complete');
114
+
115
+ this.session.completed = true;
116
+ const totalWin = this.session.totalWin;
117
+ const session = this.toSessionData();
118
+
119
+ return { totalWin, session };
120
+ }
121
+
122
+ /** Mark max win reached — stops the session */
123
+ markMaxWinReached(): void {
124
+ if (this.session) {
125
+ this.session.maxWinReached = true;
126
+ this.session.completed = true;
127
+ }
128
+ }
129
+
130
+ /** Store _persist_* data extracted from Lua result */
131
+ storePersistData(data: Record<string, unknown>): void {
132
+ if (!this.session) return;
133
+
134
+ for (const key of Object.keys(data)) {
135
+ if (key.startsWith('_persist_')) {
136
+ const cleanKey = key.slice('_persist_'.length);
137
+ this.session.persistentData[cleanKey] = data[key];
138
+ }
139
+ }
140
+ }
141
+
142
+ /** Get _ps_* params to inject into next execute() call */
143
+ getPersistentParams(): Record<string, unknown> {
144
+ if (!this.session) return {};
145
+
146
+ const params: Record<string, unknown> = {};
147
+
148
+ // Session persistent vars (float64)
149
+ for (const [key, value] of Object.entries(this.session.persistentVars)) {
150
+ params[key] = value;
151
+ }
152
+
153
+ // _persist_ complex data → _ps_*
154
+ for (const [key, value] of Object.entries(this.session.persistentData)) {
155
+ params[`_ps_${key}`] = value;
156
+ }
157
+
158
+ return params;
159
+ }
160
+
161
+ /** Reset all session state */
162
+ reset(): void {
163
+ this.session = null;
164
+ }
165
+
166
+ private toSessionData(): SessionData {
167
+ if (!this.session) throw new Error('No session');
168
+
169
+ return {
170
+ spinsRemaining: this.session.spinsRemaining,
171
+ spinsPlayed: this.session.spinsPlayed,
172
+ totalWin: Math.round(this.session.totalWin * 100) / 100,
173
+ completed: this.session.completed,
174
+ maxWinReached: this.session.maxWinReached,
175
+ betAmount: this.session.bet,
176
+ };
177
+ }
178
+ }
@@ -0,0 +1,195 @@
1
+ import { LuaEngine } from './LuaEngine';
2
+ import type { SimulationConfig, SimulationResult, GameDefinition } from './types';
3
+
4
+ /**
5
+ * Runs N iterations of a Lua game script and collects RTP statistics.
6
+ * Supports regular spins, buy bonus, and ante bet simulation.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * const runner = new SimulationRunner({
11
+ * script: luaSource,
12
+ * gameDefinition,
13
+ * iterations: 1_000_000,
14
+ * bet: 1.0,
15
+ * seed: 42,
16
+ * onProgress: (done, total) => console.log(`${done}/${total}`),
17
+ * });
18
+ *
19
+ * const result = runner.run();
20
+ * console.log(`RTP: ${result.totalRtp.toFixed(2)}%`);
21
+ * ```
22
+ */
23
+ export class SimulationRunner {
24
+ private config: SimulationConfig;
25
+
26
+ constructor(config: SimulationConfig) {
27
+ this.config = config;
28
+ }
29
+
30
+ run(): SimulationResult {
31
+ const {
32
+ script,
33
+ gameDefinition,
34
+ iterations,
35
+ bet,
36
+ seed,
37
+ action: startAction = 'spin',
38
+ params,
39
+ progressInterval = 100_000,
40
+ onProgress,
41
+ } = this.config;
42
+
43
+ const engine = new LuaEngine({
44
+ script,
45
+ gameDefinition,
46
+ seed,
47
+ logger: () => {}, // suppress Lua logs during simulation
48
+ });
49
+
50
+ const spinCost = this.calculateSpinCost(startAction, bet, gameDefinition, params);
51
+
52
+ let totalWagered = 0;
53
+ let totalWon = 0;
54
+ let baseGameWin = 0;
55
+ let bonusWin = 0;
56
+ let hits = 0;
57
+ let maxWinMultiplier = 0;
58
+ let maxWinHits = 0;
59
+ let bonusTriggered = 0;
60
+ let bonusSpinsPlayed = 0;
61
+
62
+ const startTime = Date.now();
63
+
64
+ try {
65
+ for (let i = 0; i < iterations; i++) {
66
+ totalWagered += spinCost;
67
+ let roundWin = 0;
68
+
69
+ // Execute the starting action
70
+ const result = engine.execute({
71
+ action: startAction,
72
+ bet,
73
+ params,
74
+ });
75
+
76
+ const baseWin = result.totalWin;
77
+ roundWin += baseWin;
78
+
79
+ // If a bonus session was created, play through it
80
+ if (result.session && !result.session.completed) {
81
+ bonusTriggered++;
82
+
83
+ // Find the bonus action from nextActions (different from startAction)
84
+ const bonusAction = result.nextActions.find(a => a !== startAction)
85
+ ?? result.nextActions[0];
86
+
87
+ // Play bonus spins until session completes
88
+ let bonusSessionWin = 0;
89
+ let safetyLimit = 10_000;
90
+ let lastResult = result;
91
+
92
+ while (lastResult.session && !lastResult.session.completed && safetyLimit-- > 0) {
93
+ lastResult = engine.execute({
94
+ action: bonusAction,
95
+ bet,
96
+ });
97
+ bonusSessionWin += lastResult.totalWin;
98
+ bonusSpinsPlayed++;
99
+ }
100
+
101
+ bonusWin += bonusSessionWin;
102
+ roundWin += bonusSessionWin;
103
+ }
104
+
105
+ baseGameWin += baseWin;
106
+ totalWon += roundWin;
107
+
108
+ if (roundWin > 0) hits++;
109
+
110
+ const roundMultiplier = roundWin / bet;
111
+ if (roundMultiplier > maxWinMultiplier) {
112
+ maxWinMultiplier = roundMultiplier;
113
+ }
114
+
115
+ if (result.variables?.max_win_reached === 1) {
116
+ maxWinHits++;
117
+ }
118
+
119
+ // Progress reporting
120
+ if (onProgress && (i + 1) % progressInterval === 0) {
121
+ onProgress(i + 1, iterations);
122
+ }
123
+ }
124
+ } finally {
125
+ engine.destroy();
126
+ }
127
+
128
+ const durationMs = Date.now() - startTime;
129
+
130
+ return {
131
+ gameId: gameDefinition.id,
132
+ action: startAction,
133
+ iterations,
134
+ durationMs,
135
+ totalRtp: totalWagered > 0 ? (totalWon / totalWagered) * 100 : 0,
136
+ baseGameRtp: totalWagered > 0 ? (baseGameWin / totalWagered) * 100 : 0,
137
+ bonusRtp: totalWagered > 0 ? (bonusWin / totalWagered) * 100 : 0,
138
+ hitFrequency: iterations > 0 ? (hits / iterations) * 100 : 0,
139
+ maxWin: Math.round(maxWinMultiplier * 100) / 100,
140
+ maxWinHits,
141
+ bonusTriggered,
142
+ bonusSpinsPlayed,
143
+ };
144
+ }
145
+
146
+ /** Calculate the real cost of one spin (accounting for buy bonus / ante bet) */
147
+ private calculateSpinCost(
148
+ action: string,
149
+ bet: number,
150
+ gameDefinition: GameDefinition,
151
+ params?: Record<string, unknown>,
152
+ ): number {
153
+ // Check if this is a buy bonus action
154
+ const actionDef = gameDefinition.actions[action];
155
+ if (actionDef?.buy_bonus_mode && gameDefinition.buy_bonus) {
156
+ const mode = gameDefinition.buy_bonus.modes[actionDef.buy_bonus_mode];
157
+ if (mode) {
158
+ return bet * mode.cost_multiplier;
159
+ }
160
+ }
161
+
162
+ // Check ante bet
163
+ if (params?.ante_bet && gameDefinition.ante_bet) {
164
+ return bet * gameDefinition.ante_bet.cost_multiplier;
165
+ }
166
+
167
+ return bet;
168
+ }
169
+ }
170
+
171
+ /** Format a SimulationResult for console output */
172
+ export function formatSimulationResult(result: SimulationResult): string {
173
+ const lines: string[] = [
174
+ '',
175
+ '--- Simulation Results ---',
176
+ `Game: ${result.gameId}`,
177
+ `Action: ${result.action}`,
178
+ `Iterations: ${result.iterations.toLocaleString()}`,
179
+ `Duration: ${(result.durationMs / 1000).toFixed(1)}s`,
180
+ `Total RTP: ${result.totalRtp.toFixed(2)}%`,
181
+ `Base Game RTP: ${result.baseGameRtp.toFixed(2)}%`,
182
+ `Bonus RTP: ${result.bonusRtp.toFixed(2)}%`,
183
+ `Hit Frequency: ${result.hitFrequency.toFixed(2)}%`,
184
+ `Max Win: ${result.maxWin.toFixed(2)}x`,
185
+ `Max Win Hits: ${result.maxWinHits} (rounds capped by max_win)`,
186
+ ];
187
+
188
+ if (result.bonusTriggered > 0) {
189
+ const frequency = Math.round(result.iterations / result.bonusTriggered);
190
+ lines.push(`Bonus Triggered: ${result.bonusTriggered.toLocaleString()} (1 in ${frequency} spins)`);
191
+ lines.push(`Bonus Spins Played: ${result.bonusSpinsPlayed.toLocaleString()}`);
192
+ }
193
+
194
+ return lines.join('\n');
195
+ }
@@ -0,0 +1,22 @@
1
+ export { LuaEngine } from './LuaEngine';
2
+ export { LuaEngineAPI, createSeededRng } from './LuaEngineAPI';
3
+ export { ActionRouter, evaluateCondition } from './ActionRouter';
4
+ export { SessionManager } from './SessionManager';
5
+ export { PersistentState } from './PersistentState';
6
+ export { SimulationRunner, formatSimulationResult } from './SimulationRunner';
7
+ export type {
8
+ GameDefinition,
9
+ ActionDefinition,
10
+ TransitionRule,
11
+ SessionConfig,
12
+ LuaEngineConfig,
13
+ LuaPlayResult,
14
+ MaxWinConfig,
15
+ BuyBonusConfig,
16
+ BuyBonusMode,
17
+ AnteBetConfig,
18
+ PersistentStateConfig,
19
+ BetLevelsConfig,
20
+ SimulationConfig,
21
+ SimulationResult,
22
+ } from './types';
@@ -0,0 +1,132 @@
1
+ import type { SessionData, PlayParams } from '@energy8platform/game-sdk';
2
+
3
+ // ─── Game Definition (Platform JSON Config) ─────────────
4
+
5
+ export interface GameDefinition {
6
+ id: string;
7
+ type: 'SLOT' | 'TABLE';
8
+ actions: Record<string, ActionDefinition>;
9
+ bet_levels?: number[] | BetLevelsConfig;
10
+ max_win?: MaxWinConfig;
11
+ buy_bonus?: BuyBonusConfig;
12
+ ante_bet?: AnteBetConfig;
13
+ persistent_state?: PersistentStateConfig;
14
+ }
15
+
16
+ export interface BetLevelsConfig {
17
+ levels?: number[];
18
+ min?: number;
19
+ max?: number;
20
+ }
21
+
22
+ export interface MaxWinConfig {
23
+ multiplier?: number;
24
+ fixed?: number;
25
+ }
26
+
27
+ export interface BuyBonusConfig {
28
+ modes: Record<string, BuyBonusMode>;
29
+ }
30
+
31
+ export interface BuyBonusMode {
32
+ cost_multiplier: number;
33
+ scatter_distribution: Record<string, number>;
34
+ }
35
+
36
+ export interface AnteBetConfig {
37
+ cost_multiplier: number;
38
+ }
39
+
40
+ export interface PersistentStateConfig {
41
+ vars: string[];
42
+ exposed_vars: string[];
43
+ }
44
+
45
+ // ─── Actions & Transitions ──────────────────────────────
46
+
47
+ export interface ActionDefinition {
48
+ stage: string;
49
+ debit: 'bet' | 'buy_bonus_cost' | 'ante_bet_cost' | 'none';
50
+ credit?: 'win' | 'none' | 'defer';
51
+ requires_session?: boolean;
52
+ buy_bonus_mode?: string;
53
+ transitions: TransitionRule[];
54
+ input_schema?: Record<string, unknown>;
55
+ }
56
+
57
+ export interface TransitionRule {
58
+ condition: string;
59
+ creates_session?: boolean;
60
+ complete_session?: boolean;
61
+ credit_override?: 'defer';
62
+ next_actions: string[];
63
+ session_config?: SessionConfig;
64
+ add_spins_var?: string;
65
+ }
66
+
67
+ export interface SessionConfig {
68
+ total_spins_var: string;
69
+ persistent_vars?: string[];
70
+ }
71
+
72
+ // ─── Lua Engine Config & Results ────────────────────────
73
+
74
+ export interface LuaEngineConfig {
75
+ /** Lua script source code */
76
+ script: string;
77
+ /** Platform game definition (actions, transitions, bet levels, etc.) */
78
+ gameDefinition: GameDefinition;
79
+ /** Seed for deterministic RNG (for simulation/replay) */
80
+ seed?: number;
81
+ /** Custom logger function */
82
+ logger?: (level: string, msg: string) => void;
83
+ }
84
+
85
+ export interface LuaPlayResult {
86
+ totalWin: number;
87
+ data: Record<string, unknown>;
88
+ nextActions: string[];
89
+ session: SessionData | null;
90
+ variables: Record<string, number>;
91
+ creditDeferred: boolean;
92
+ }
93
+
94
+ // ─── Simulation ─────────────────────────────────────────
95
+
96
+ export interface SimulationConfig {
97
+ /** Lua script source code */
98
+ script: string;
99
+ /** Platform game definition */
100
+ gameDefinition: GameDefinition;
101
+ /** Number of iterations to run */
102
+ iterations: number;
103
+ /** Bet amount per spin */
104
+ bet: number;
105
+ /** RNG seed for deterministic results */
106
+ seed?: number;
107
+ /** Which action to simulate (default: 'spin') */
108
+ action?: string;
109
+ /** Params for the action (buy_bonus mode, ante_bet, etc.) */
110
+ params?: Record<string, unknown>;
111
+ /** Report progress every N iterations (default: 100_000) */
112
+ progressInterval?: number;
113
+ /** Progress callback */
114
+ onProgress?: (completed: number, total: number) => void;
115
+ }
116
+
117
+ export interface SimulationResult {
118
+ gameId: string;
119
+ action: string;
120
+ iterations: number;
121
+ durationMs: number;
122
+ totalRtp: number;
123
+ baseGameRtp: number;
124
+ bonusRtp: number;
125
+ hitFrequency: number;
126
+ maxWin: number;
127
+ maxWinHits: number;
128
+ bonusTriggered: number;
129
+ bonusSpinsPlayed: number;
130
+ }
131
+
132
+ export type { SessionData, PlayParams };
package/src/vite/index.ts CHANGED
@@ -88,6 +88,35 @@ await import('${entrySrc}');
88
88
  };
89
89
  }
90
90
 
91
+ // ─── Lua Plugin ─────────────────────────────────────────
92
+
93
+ /**
94
+ * Vite plugin that enables importing `.lua` files as raw strings
95
+ * and triggers a full page reload on `.lua` file changes.
96
+ */
97
+ function luaPlugin(): Plugin {
98
+ return {
99
+ name: 'game-engine:lua',
100
+ apply: 'serve',
101
+
102
+ transform(code: string, id: string) {
103
+ if (id.endsWith('.lua')) {
104
+ return {
105
+ code: `export default ${JSON.stringify(code)};`,
106
+ map: null,
107
+ };
108
+ }
109
+ },
110
+
111
+ handleHotUpdate({ file, server }: { file: string; server: any }) {
112
+ if (file.endsWith('.lua')) {
113
+ server.ws.send({ type: 'full-reload' });
114
+ return [];
115
+ }
116
+ },
117
+ };
118
+ }
119
+
91
120
  // ─── defineGameConfig ────────────────────────────────────
92
121
 
93
122
  /**
@@ -116,10 +145,19 @@ export function defineGameConfig(config: GameConfig = {}): UserConfig {
116
145
  if (config.devBridge) {
117
146
  const configPath = config.devBridgeConfig ?? './dev.config';
118
147
  plugins.push(devBridgePlugin(configPath));
148
+ plugins.push(luaPlugin());
119
149
  }
120
150
 
121
151
  const userVite = config.vite ?? {};
122
152
 
153
+ // fengari (Lua 5.3 in JS) is a CJS Node.js package that references `process`
154
+ // and `require('os')` at module level. Provide shims so it works in the browser.
155
+ const fengariShims: Record<string, string> = config.devBridge ? {
156
+ 'process.versions.node': 'undefined',
157
+ 'process.env': '({})',
158
+ 'process.platform': '"browser"',
159
+ } : {};
160
+
123
161
  return {
124
162
  base: config.base ?? '/',
125
163
 
@@ -128,6 +166,11 @@ export function defineGameConfig(config: GameConfig = {}): UserConfig {
128
166
  ...((userVite.plugins as Plugin[]) ?? []),
129
167
  ],
130
168
 
169
+ define: {
170
+ ...fengariShims,
171
+ ...(userVite as Record<string, any>).define,
172
+ },
173
+
131
174
  build: {
132
175
  target: 'esnext',
133
176
  assetsInlineLimit: 8192,