@energy8platform/platform-core 0.16.1 → 0.17.0

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.
@@ -8,7 +8,56 @@ import {
8
8
  type PlayResultAckPayload,
9
9
  type PlayParams,
10
10
  } from '@energy8platform/game-sdk';
11
- import type { GameDefinition } from '../lua';
11
+ import type { GameDefinition, BetLevelsConfig } from '../lua/types';
12
+
13
+ /** Default session TTL when GameDefinition.session_ttl is omitted (24h). */
14
+ const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
15
+
16
+ /**
17
+ * Parse a Go-style duration string ("24h", "5ms", "30s", "10m") into
18
+ * milliseconds. Mirrors GameDefinition.SessionTTLDuration on the server.
19
+ */
20
+ function parseSessionTtl(ttl: string | undefined): number {
21
+ if (!ttl) return DEFAULT_SESSION_TTL_MS;
22
+ const m = ttl.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)$/);
23
+ if (!m) return DEFAULT_SESSION_TTL_MS;
24
+ const n = parseFloat(m[1]);
25
+ switch (m[2]) {
26
+ case 'ms': return n;
27
+ case 's': return n * 1000;
28
+ case 'm': return n * 60 * 1000;
29
+ case 'h': return n * 60 * 60 * 1000;
30
+ default: return DEFAULT_SESSION_TTL_MS;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Validate a bet against the game's bet_levels config — mirrors the server's
36
+ * validateBet. Levels-list takes priority over min/max range.
37
+ */
38
+ function isBetAllowed(bet: number, levels: number[] | BetLevelsConfig | undefined): boolean {
39
+ if (!levels) return true;
40
+ if (Array.isArray(levels)) {
41
+ return levels.includes(bet);
42
+ }
43
+ if (levels.levels && levels.levels.length > 0) {
44
+ return levels.levels.includes(bet);
45
+ }
46
+ if (levels.min !== undefined && bet < levels.min) return false;
47
+ if (levels.max !== undefined && bet > levels.max) return false;
48
+ return true;
49
+ }
50
+
51
+ /**
52
+ * Generate a server-style UUID for a fresh round. Falls back to a counter
53
+ * suffix if `crypto.randomUUID` isn't available (very old runtimes).
54
+ */
55
+ function generateRoundId(): string {
56
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
57
+ if (c && typeof c.randomUUID === 'function') return c.randomUUID();
58
+ // Minimal fallback — not crypto-strong, but keeps the wire shape sane.
59
+ return 'dev-' + Math.random().toString(16).slice(2) + '-' + Date.now().toString(16);
60
+ }
12
61
 
13
62
  export interface DevBridgeConfig {
14
63
  /** Mock initial balance */
@@ -84,11 +133,20 @@ export class DevBridge {
84
133
  private _roundCounter = 0;
85
134
  private _bridge: Bridge | null = null;
86
135
  private _useLuaServer: boolean;
136
+ /** Last PlayResult sent — mirrors what `GET /games/{id}/session` returns. */
137
+ private _lastPlayResult: PlayResultData | null = null;
138
+ /** Active session round id; non-null while a session is in progress. */
139
+ private _activeRoundId: string | null = null;
140
+ /** Wall-clock expiry timestamp for the active session. */
141
+ private _sessionExpiresAt: number | null = null;
142
+ /** Pre-parsed session TTL from gameDefinition.session_ttl. */
143
+ private _sessionTtlMs: number;
87
144
 
88
145
  constructor(config: DevBridgeConfig = {}) {
89
146
  this._config = { ...DEFAULT_CONFIG, ...config };
90
147
  this._balance = this._config.balance;
91
148
  this._useLuaServer = !!(this._config.luaScript && this._config.gameDefinition);
149
+ this._sessionTtlMs = parseSessionTtl(this._config.gameDefinition?.session_ttl);
92
150
  }
93
151
 
94
152
  /** Current mock balance */
@@ -175,27 +233,95 @@ export class DevBridge {
175
233
  payload: PlayParams,
176
234
  id?: string,
177
235
  ): void {
178
- const { action, bet, roundId, params } = payload;
236
+ const { action, bet, params } = payload;
179
237
  this._roundCounter++;
180
238
 
181
239
  if (this._useLuaServer) {
240
+ const def = this._config.gameDefinition!;
241
+ // Mirror the server's INVALID_INPUT short-circuit: an unknown action
242
+ // is rejected before any wallet movement, with no PLAY_RESULT.
243
+ const actionDef = def.actions?.[action];
244
+ if (!actionDef) {
245
+ this.sendError(id, 'INVALID_INPUT', `unknown action "${action}"`);
246
+ return;
247
+ }
248
+
249
+ // Bet validation — server returns 400 INVALID_AMOUNT before the
250
+ // engine. We mirror that so games can't silently accept bad bets.
251
+ if (!isBetAllowed(bet, def.bet_levels)) {
252
+ this.sendError(id, 'INVALID_AMOUNT', `bet ${bet} is not in allowed bet_levels`);
253
+ return;
254
+ }
255
+
256
+ // Session-state guards (server: 409 ACTIVE_SESSION_EXISTS / 404 NoActiveSession / 410 ExpiredSession).
257
+ const sessionExpired =
258
+ this._activeRoundId !== null &&
259
+ this._sessionExpiresAt !== null &&
260
+ Date.now() > this._sessionExpiresAt;
261
+
262
+ if (actionDef.requires_session) {
263
+ if (sessionExpired) {
264
+ this.clearSessionState();
265
+ this.sendError(id, 'SESSION_EXPIRED', 'game session has expired');
266
+ return;
267
+ }
268
+ if (this._activeRoundId === null) {
269
+ this.sendError(id, 'NO_ACTIVE_SESSION', `action "${action}" requires an active session`);
270
+ return;
271
+ }
272
+ } else {
273
+ // Non-session action over an active (non-expired) session — server's
274
+ // acquireSession would fail with ACTIVE_SESSION_EXISTS.
275
+ if (this._activeRoundId !== null && !sessionExpired) {
276
+ this.sendError(id, 'ACTIVE_SESSION_EXISTS', 'an active game session already exists');
277
+ return;
278
+ }
279
+ if (sessionExpired) {
280
+ // Drop stale session state so a fresh non-session action can proceed.
281
+ this.clearSessionState();
282
+ }
283
+ }
284
+
182
285
  // Compute the debit amount for this action — mirrors the platform's
183
286
  // server-side rules so Buy Bonus / Ante Bet actions debit the right
184
287
  // multiple of bet instead of just the base bet.
185
288
  const debit = this.computeDebit(action, bet, params);
289
+
290
+ // Server returns 402 INSUFFICIENT_FUNDS before the wallet is touched
291
+ // and never reaches the engine. DevBridge must do the same so the
292
+ // SDK's play() rejects with the right SDKError code.
293
+ if (debit > this._balance) {
294
+ this.sendError(
295
+ id,
296
+ 'INSUFFICIENT_FUNDS',
297
+ `insufficient funds (need ${debit}, have ${this._balance})`,
298
+ );
299
+ return;
300
+ }
301
+
186
302
  this._balance -= debit;
187
303
 
188
- this.executeLuaOnServer({ action, bet, roundId, params })
304
+ // Round id rules mirror server's playRound:
305
+ // non-session → fresh UUID, client-supplied id is ignored
306
+ // session-based → reuse the active session's round id
307
+ const serverRoundId = actionDef.requires_session
308
+ ? this._activeRoundId!
309
+ : generateRoundId();
310
+
311
+ this.executeLuaOnServer({ action, bet, roundId: serverRoundId, params })
189
312
  .then((result) => {
313
+ this._lastPlayResult = result;
314
+ this.updateSessionState(result);
190
315
  this._bridge?.send('PLAY_RESULT', result, id);
191
316
  })
192
317
  .catch((err) => {
193
318
  console.error('[DevBridge] Lua server error:', err);
194
319
  this._balance += debit;
195
- this._bridge?.send('PLAY_RESULT', this.buildFallbackResult(action, bet, roundId), id);
320
+ this.sendError(id, 'ENGINE_ERROR', err?.message ?? 'lua execution failed');
196
321
  });
197
322
  } else {
198
323
  // Fallback to onPlay callback
324
+ const { roundId } = payload;
199
325
  const customResult = this._config.onPlay({ action, bet, roundId, params });
200
326
  const totalWin = customResult.totalWin ?? (Math.random() > 0.6 ? bet * (1 + Math.random() * 10) : 0);
201
327
 
@@ -215,81 +341,66 @@ export class DevBridge {
215
341
  gameId: this._config.gameConfig?.id ?? 'dev-game',
216
342
  };
217
343
 
344
+ this._lastPlayResult = result;
218
345
  this.delayedSend('PLAY_RESULT', result, id);
219
346
  }
220
347
  }
221
348
 
349
+ /** Send a PLAY_ERROR correlated to the original PLAY_REQUEST id. */
350
+ private sendError(id: string | undefined, code: string, message: string): void {
351
+ this._bridge?.send('PLAY_ERROR', { code, message }, id);
352
+ }
353
+
354
+ /**
355
+ * Refresh tracked session state from the latest PlayResult.
356
+ * - new/ongoing session → remember roundId + (re)set expiry
357
+ * - completed/no session → clear tracking
358
+ */
359
+ private updateSessionState(result: PlayResultData): void {
360
+ const session = result.session;
361
+ if (session && !session.completed) {
362
+ if (this._activeRoundId === null) {
363
+ this._activeRoundId = result.roundId;
364
+ this._sessionExpiresAt = Date.now() + this._sessionTtlMs;
365
+ }
366
+ } else {
367
+ this.clearSessionState();
368
+ }
369
+ }
370
+
371
+ /** Drop active-session tracking (called on completion or expiry sweep). */
372
+ private clearSessionState(): void {
373
+ this._activeRoundId = null;
374
+ this._sessionExpiresAt = null;
375
+ }
376
+
222
377
  /**
223
378
  * Compute the wallet debit for a play request, mirroring the platform's
224
- * server-side rules:
225
- * - debit: 'bet' → bet
226
- * - debit: 'buy_bonus_cost' → bet × buy_bonus.modes[mode].cost_multiplier
227
- * - debit: 'ante_bet_cost' bet × ante_bet.cost_multiplier
228
- * - debit: 'none' → 0 (session continuations, e.g. free spins)
229
- *
230
- * Without a gameDefinition (or when the action is unknown), falls back to
231
- * `bet` and warns. This is the dev-mode behavior the previous
232
- * implementation always used for every action — kept here as a safe
233
- * default so existing setups don't silently change semantics.
379
+ * v5 ActionDefinition.DebitAmount:
380
+ * - debit: 'bet' → bet × (cost_multiplier || 1)
381
+ * - debit: 'none'/missing0
382
+ * - any other value 0 (legacy v4 modes like 'buy_bonus_cost'
383
+ * are no longer recognized; surfacing as 0
384
+ * forces config breakage to the surface)
234
385
  *
235
- * Note: in the platform protocol the client sends the *base* bet only
236
- * (`PlayParams.bet`); the cost multiplier lives in the GameDefinition.
237
- * Session continuations (free_spin, feature_spin) must be invoked with
238
- * `bet: 0` — LuaEngine pulls the active session's bet from the persisted
239
- * session state.
386
+ * The action carries its own cost (cost_multiplier + opaque feature_data).
387
+ * No top-level buy_bonus/ante_bet blocks. No params.ante_bet flag — ante
388
+ * is just a separate action with its own cost_multiplier.
240
389
  */
241
390
  private computeDebit(
242
391
  action: string,
243
392
  bet: number,
244
- params: Record<string, unknown> | undefined,
393
+ _params: Record<string, unknown> | undefined,
245
394
  ): number {
246
- const def = this._config.gameDefinition;
247
- const actionDef = def?.actions?.[action];
248
-
249
- if (!def || !actionDef) {
250
- if (this._config.debug) {
251
- console.warn(`[DevBridge] Unknown action "${action}" — debiting base bet as fallback.`);
252
- }
253
- return bet;
395
+ const actionDef = this._config.gameDefinition?.actions?.[action];
396
+ if (!actionDef || actionDef.debit !== 'bet') {
397
+ return 0;
254
398
  }
255
-
256
- switch (actionDef.debit) {
257
- case 'none':
258
- return 0;
259
- case 'buy_bonus_cost': {
260
- const modeName = actionDef.buy_bonus_mode;
261
- const mode = modeName ? def.buy_bonus?.modes?.[modeName] : undefined;
262
- if (!mode) {
263
- console.warn(
264
- `[DevBridge] Action "${action}" has debit: "buy_bonus_cost" but no matching buy_bonus mode (${modeName ?? '<unset>'}). Falling back to base bet.`,
265
- );
266
- return bet;
267
- }
268
- return bet * mode.cost_multiplier;
269
- }
270
- case 'ante_bet_cost': {
271
- const multiplier = def.ante_bet?.cost_multiplier;
272
- if (typeof multiplier !== 'number') {
273
- console.warn(
274
- `[DevBridge] Action "${action}" has debit: "ante_bet_cost" but no ante_bet.cost_multiplier defined. Falling back to base bet.`,
275
- );
276
- return bet;
277
- }
278
- return bet * multiplier;
279
- }
280
- case 'bet':
281
- default: {
282
- // The platform also debits ante_bet pricing on regular `bet` actions
283
- // when the client signals it via params.ante_bet. Mirror that here.
284
- if (params && (params as Record<string, unknown>)['ante_bet'] === true) {
285
- const multiplier = def.ante_bet?.cost_multiplier;
286
- if (typeof multiplier === 'number') {
287
- return bet * multiplier;
288
- }
289
- }
290
- return bet;
291
- }
399
+ const mult = actionDef.cost_multiplier;
400
+ if (typeof mult === 'number' && mult > 0 && mult !== 1) {
401
+ return bet * mult;
292
402
  }
403
+ return bet;
293
404
  }
294
405
 
295
406
  private async executeLuaOnServer(params: PlayParams): Promise<PlayResultData> {
@@ -322,22 +433,9 @@ export class DevBridge {
322
433
  data: luaResult.data,
323
434
  nextActions: luaResult.nextActions,
324
435
  session: luaResult.session,
325
- creditPending: !shouldCredit,
326
- bonusFreeSpin: null,
327
- currency: this._config.currency,
328
- gameId: this._config.gameConfig?.id ?? 'dev-game',
329
- };
330
- }
331
-
332
- private buildFallbackResult(action: string, bet: number, roundId?: string): PlayResultData {
333
- return {
334
- roundId: roundId ?? `dev-round-${this._roundCounter}`,
335
- action,
336
- balanceAfter: this._balance,
337
- totalWin: 0,
338
- data: { error: 'Lua execution failed' },
339
- nextActions: ['spin'],
340
- session: null,
436
+ // creditPending=true on the wire means "wallet credit failed, queued
437
+ // for retry" — not "credit deferred until session completes". DevBridge
438
+ // never simulates credit failures, so this is always false.
341
439
  creditPending: false,
342
440
  bonusFreeSpin: null,
343
441
  currency: this._config.currency,
@@ -356,7 +454,14 @@ export class DevBridge {
356
454
  }
357
455
 
358
456
  private handleGetState(id?: string): void {
359
- this.delayedSend('STATE_RESPONSE', { session: this._config.session ?? null }, id);
457
+ // Mirror the platform's GET /games/{id}/session: the response wraps the
458
+ // last PlayResult-shaped snapshot, which the SDK reads back as
459
+ // `payload.session.session` (SessionData) and `payload.session.balanceAfter`.
460
+ // Only surface it while a session is active and not yet completed —
461
+ // matches GameUseCase.GetActiveSession.
462
+ const last = this._lastPlayResult;
463
+ const session = last && last.session && !last.session.completed ? last : null;
464
+ this.delayedSend('STATE_RESPONSE', { session }, id);
360
465
  }
361
466
 
362
467
  private handleOpenDeposit(): void {
package/src/index.ts CHANGED
@@ -72,9 +72,6 @@ export type {
72
72
  LuaEngineConfig,
73
73
  LuaPlayResult,
74
74
  MaxWinConfig,
75
- BuyBonusConfig,
76
- BuyBonusMode,
77
- AnteBetConfig,
78
75
  PersistentStateConfig,
79
76
  BetLevelsConfig,
80
77
  SimulationConfig,
@@ -1,5 +1,5 @@
1
1
  import type { PlayParams, SessionData } from '@energy8platform/game-sdk';
2
- import type { LuaEngineConfig, LuaPlayResult, GameDefinition } from './types';
2
+ import type { LuaEngineConfig, LuaPlayResult, GameDefinition, ActionDefinition } from './types';
3
3
  import { LuaEngineAPI, createSeededRng, luaToJS, pushJSValue, cachedToLuastring } from './LuaEngineAPI';
4
4
  import { ActionRouter } from './ActionRouter';
5
5
  import { SessionManager } from './SessionManager';
@@ -153,25 +153,16 @@ export class LuaEngine {
153
153
  const gameDataParams = this.persistentState.getGameDataParams();
154
154
  Object.assign(stateParams, gameDataParams);
155
155
 
156
- // Handle buy bonus
157
- if (action.buy_bonus_mode && this.gameDefinition.buy_bonus) {
158
- const mode = this.gameDefinition.buy_bonus.modes[action.buy_bonus_mode];
159
- if (mode) {
160
- stateParams.buy_bonus = true;
161
- stateParams.buy_bonus_mode = action.buy_bonus_mode;
162
- if (mode.scatter_distribution) {
163
- stateParams.forced_scatter_count = this.pickFromDistribution(mode.scatter_distribution);
164
- }
165
- }
166
- }
167
-
168
- // Handle ante bet
169
- if (clientParams?.ante_bet && this.gameDefinition.ante_bet) {
170
- stateParams.ante_bet = true;
156
+ // v5: forced scatter rolls come from the action's own feature_data.
157
+ // Output of the roll stays in state.params (it's random per-call), while
158
+ // the static config flows through state.action_config.feature_data.
159
+ const scatterDist = readScatterDistribution(action.feature_data);
160
+ if (scatterDist) {
161
+ stateParams.forced_scatter_count = this.pickFromDistribution(scatterDist);
171
162
  }
172
163
 
173
164
  // 5. Execute Lua (server: executor.Execute(stage, state))
174
- const luaResult = this.callLuaExecute(action.stage, actionName, stateParams, stateVars);
165
+ const luaResult = this.callLuaExecute(action.stage, actionName, stateParams, stateVars, action);
175
166
 
176
167
  // 6. Process result (server: ApplyLuaResult)
177
168
  const totalWinMultiplier = typeof luaResult.total_win === 'number' ? luaResult.total_win : 0;
@@ -193,6 +184,12 @@ export class LuaEngine {
193
184
  }
194
185
  }
195
186
 
187
+ // Apply MapState parity — server's state_mapper.go injects these
188
+ // variable-derived keys into the client data so scripts don't have to
189
+ // surface them manually. Lua-provided values take precedence (server
190
+ // also overwrites variable-derived keys with state.Data on merge).
191
+ this.applyMapStateInjection(stateVars, data);
192
+
196
193
  // 7. Handle _persist_* and _persist_game_* keys
197
194
  this.sessionManager.storePersistData(data);
198
195
  this.persistentState.storeGameData(data);
@@ -214,7 +211,8 @@ export class LuaEngine {
214
211
  }
215
212
 
216
213
  // 8. Evaluate transitions (server uses state.Variables which is stateVars)
217
- const { rule, nextActions } = this.actionRouter.evaluateTransitions(action, stateVars);
214
+ const { rule } = this.actionRouter.evaluateTransitions(action, stateVars);
215
+ let nextActions = rule.next_actions;
218
216
 
219
217
  // 9. Determine credit behavior (server: creditNow logic)
220
218
  let creditDeferred = action.credit === 'defer' || rule.credit_override === 'defer';
@@ -227,9 +225,15 @@ export class LuaEngine {
227
225
  // Calculate max win cap for session
228
226
  const maxWinCap = this.calculateMaxWinCap(bet);
229
227
 
228
+ // Snapshot the round data for history (matches server's MapStateForHistory:
229
+ // strip _persist_* keys, but those are already removed below before
230
+ // returning — at this point in the flow they may still be in `data`,
231
+ // so we filter inline).
232
+ const roundData = stripPersistKeys(data);
233
+
230
234
  if (rule.creates_session && !this.sessionManager.isActive) {
231
235
  // CREATE SESSION — initial spin counted (server: createSession includes spinWin)
232
- session = this.sessionManager.createSession(rule, stateVars, bet, spinWin, maxWinCap);
236
+ session = this.sessionManager.createSession(rule, stateVars, bet, spinWin, maxWinCap, roundData);
233
237
  creditDeferred = true;
234
238
  resultTotalWin = spinWin;
235
239
 
@@ -239,16 +243,23 @@ export class LuaEngine {
239
243
  }
240
244
  } else if (this.sessionManager.isActive) {
241
245
  // UPDATE SESSION — accumulate win, check completion
242
- session = this.sessionManager.updateSession(rule, stateVars, spinWin);
246
+ session = this.sessionManager.updateSession(rule, stateVars, spinWin, roundData);
243
247
 
244
248
  if (session?.completed) {
245
- // SESSION COMPLETED — server returns session.TotalWin as result.TotalWin
249
+ // SESSION COMPLETED — server returns session.TotalWin as result.TotalWin,
250
+ // and pulls next_actions from the explicit completion transition
251
+ // (findCompletionNextActions) rather than the matched 'continue' rule.
246
252
  const completed = this.sessionManager.completeSession();
247
253
  session = completed.session;
248
254
  resultTotalWin = completed.totalWin;
249
255
  sessionCompleted = true;
250
256
  creditDeferred = false;
251
257
 
258
+ const completionNext = findCompletionNextActions(action);
259
+ if (completionNext) {
260
+ nextActions = completionNext;
261
+ }
262
+
252
263
  // Clean up session-scoped variables
253
264
  for (const varName of completed.sessionVarNames) {
254
265
  delete this.variables[varName];
@@ -315,11 +326,12 @@ export class LuaEngine {
315
326
  action: string,
316
327
  params: Record<string, unknown>,
317
328
  variables: Record<string, number>,
329
+ actionDef: ActionDefinition,
318
330
  ): Record<string, unknown> {
319
331
  lua.lua_getglobal(this.L, cachedToLuastring('execute'));
320
332
 
321
- // Build state table: {stage, action, params, variables}
322
- lua.lua_createtable(this.L, 0, 4);
333
+ // Build state table: {stage, action, action_config, params, variables}
334
+ lua.lua_createtable(this.L, 0, 5);
323
335
 
324
336
  // state.stage
325
337
  lua.lua_pushstring(this.L, cachedToLuastring(stage));
@@ -329,6 +341,18 @@ export class LuaEngine {
329
341
  lua.lua_pushstring(this.L, cachedToLuastring(action));
330
342
  lua.lua_setfield(this.L, -2, cachedToLuastring('action'));
331
343
 
344
+ // state.action_config — v5: { cost_multiplier, feature_data }.
345
+ // Server's lua_runtime.go substitutes 1.0 when cost_multiplier is unset
346
+ // so scripts never see 0; mirror that default.
347
+ const mult = typeof actionDef.cost_multiplier === 'number' && actionDef.cost_multiplier > 0
348
+ ? actionDef.cost_multiplier
349
+ : 1;
350
+ pushJSValue(this.L, {
351
+ cost_multiplier: mult,
352
+ feature_data: actionDef.feature_data ?? {},
353
+ });
354
+ lua.lua_setfield(this.L, -2, cachedToLuastring('action_config'));
355
+
332
356
  // state.params
333
357
  pushJSValue(this.L, params);
334
358
  lua.lua_setfield(this.L, -2, cachedToLuastring('params'));
@@ -386,6 +410,35 @@ export class LuaEngine {
386
410
  return result as Record<string, unknown>;
387
411
  }
388
412
 
413
+ /**
414
+ * Mirror server's state_mapper.go MapState — surface variable-derived
415
+ * fields so scripts that don't manually echo them in the result table
416
+ * still produce a server-shaped data map. Lua keys win on conflict.
417
+ */
418
+ private applyMapStateInjection(
419
+ vars: Record<string, number>,
420
+ data: Record<string, unknown>,
421
+ ): void {
422
+ const m = vars.multiplier;
423
+ if (typeof m === 'number' && m > 1 && data.multiplier === undefined) {
424
+ data.multiplier = m;
425
+ }
426
+
427
+ const gm = vars.global_multiplier;
428
+ if (typeof gm === 'number' && gm > 1 && data.global_multiplier === undefined) {
429
+ data.global_multiplier = gm;
430
+ }
431
+
432
+ const fs = vars.free_spins_remaining;
433
+ if (typeof fs === 'number' && fs > 0 && data.free_spins_total === undefined) {
434
+ data.free_spins_total = Math.trunc(fs);
435
+ }
436
+
437
+ if (vars.max_win_reached === 1 && data.max_win_reached === undefined) {
438
+ data.max_win_reached = true;
439
+ }
440
+ }
441
+
389
442
  private calculateMaxWinCap(bet: number): number | undefined {
390
443
  const mw = this.gameDefinition.max_win;
391
444
  if (!mw) return undefined;
@@ -410,3 +463,55 @@ export class LuaEngine {
410
463
  return parseInt(entries[entries.length - 1][0], 10);
411
464
  }
412
465
  }
466
+
467
+ // ─── Module helpers ─────────────────────────────────────
468
+
469
+ /**
470
+ * Pull a `scatter_distribution` map out of an action's feature_data, if any.
471
+ * Returns null when missing or wrong-shaped — caller falls through with no
472
+ * forced_scatter_count injection (matches server behavior).
473
+ */
474
+ function readScatterDistribution(
475
+ featureData: Record<string, unknown> | undefined,
476
+ ): Record<string, number> | null {
477
+ if (!featureData) return null;
478
+ const dist = featureData['scatter_distribution'];
479
+ if (!dist || typeof dist !== 'object') return null;
480
+ const out: Record<string, number> = {};
481
+ for (const [k, v] of Object.entries(dist as Record<string, unknown>)) {
482
+ if (typeof v === 'number') out[k] = v;
483
+ }
484
+ return Object.keys(out).length > 0 ? out : null;
485
+ }
486
+
487
+ /**
488
+ * Strip _persist_* and _persist_game_* keys from a data map — matches
489
+ * server's MapStateForHistory used when recording session round history.
490
+ */
491
+ function stripPersistKeys(data: Record<string, unknown>): Record<string, unknown> {
492
+ const out: Record<string, unknown> = {};
493
+ for (const k of Object.keys(data)) {
494
+ if (k.startsWith('_persist_') || k.startsWith('_persist_game_')) continue;
495
+ out[k] = data[k];
496
+ }
497
+ return out;
498
+ }
499
+
500
+ /**
501
+ * Mirror server's findCompletionNextActions: when a session naturally
502
+ * completes, the matched 'continue' rule's next_actions are NOT what
503
+ * the client should see — the explicit complete_session transition wins,
504
+ * with a fallback to the 'always' transition.
505
+ */
506
+ function findCompletionNextActions(action: ActionDefinition): string[] | null {
507
+ let alwaysFallback: string[] | null = null;
508
+ for (const t of action.transitions) {
509
+ if (t.complete_session && t.next_actions && t.next_actions.length > 0) {
510
+ return t.next_actions;
511
+ }
512
+ if (t.condition.trim() === 'always' && t.next_actions && t.next_actions.length > 0 && alwaysFallback === null) {
513
+ alwaysFallback = t.next_actions;
514
+ }
515
+ }
516
+ return alwaysFallback;
517
+ }
@@ -1,6 +1,13 @@
1
1
  import type { SessionData } from '@energy8platform/game-sdk';
2
2
  import type { TransitionRule } from './types';
3
3
 
4
+ /** Per-round history entry — wire shape mirrors server SessionInfo.History. */
5
+ export interface SessionRound {
6
+ spinIndex: number;
7
+ win: number;
8
+ data: Record<string, unknown>;
9
+ }
10
+
4
11
  interface SessionState {
5
12
  spinsRemaining: number;
6
13
  spinsPlayed: number;
@@ -13,6 +20,7 @@ interface SessionState {
13
20
  persistentVarNames: string[];
14
21
  persistentVars: Record<string, number>;
15
22
  persistentData: Record<string, unknown>;
23
+ history: SessionRound[];
16
24
  }
17
25
 
18
26
  const MAX_SESSION_SPINS = 200;
@@ -56,7 +64,8 @@ export class SessionManager {
56
64
 
57
65
  /**
58
66
  * Create a new session from a transition rule.
59
- * Server behavior: initial spin is already counted (spinsPlayed=1, totalWin includes spinWin).
67
+ * Server behavior: initial spin is already counted (spinsPlayed=1, totalWin includes spinWin),
68
+ * and history[0] holds the trigger spin's data.
60
69
  */
61
70
  createSession(
62
71
  rule: TransitionRule,
@@ -64,6 +73,7 @@ export class SessionManager {
64
73
  bet: number,
65
74
  spinWin: number,
66
75
  maxWinCap: number | undefined,
76
+ triggerData: Record<string, unknown>,
67
77
  ): SessionData {
68
78
  let spinsRemaining = -1;
69
79
  let spinsVarName: string | undefined;
@@ -90,6 +100,7 @@ export class SessionManager {
90
100
  persistentVarNames,
91
101
  persistentVars,
92
102
  persistentData: {},
103
+ history: [{ spinIndex: 0, win: spinWin, data: triggerData }],
93
104
  };
94
105
 
95
106
  return this.toSessionData();
@@ -104,9 +115,18 @@ export class SessionManager {
104
115
  rule: TransitionRule,
105
116
  variables: Record<string, number>,
106
117
  spinWin: number,
118
+ roundData: Record<string, unknown>,
107
119
  ): SessionData {
108
120
  if (!this.session) throw new Error('No active session');
109
121
 
122
+ // Append history with PRE-increment spin index (matches server's
123
+ // updateSession: append round, THEN increment SpinsPlayed).
124
+ this.session.history.push({
125
+ spinIndex: this.session.spinsPlayed,
126
+ win: spinWin,
127
+ data: roundData,
128
+ });
129
+
110
130
  // Accumulate win and count spin
111
131
  this.session.totalWin += spinWin;
112
132
  this.session.spinsPlayed++;
@@ -222,6 +242,8 @@ export class SessionManager {
222
242
  completed: this.session.completed,
223
243
  maxWinReached: this.session.maxWinReached,
224
244
  betAmount: this.session.bet,
245
+ // Snapshot the array so downstream mutation can't corrupt internal state.
246
+ history: [...this.session.history],
225
247
  };
226
248
  }
227
249
  }