@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.
- package/README.md +25 -3
- package/dist/dev-bridge.cjs.js +175 -74
- package/dist/dev-bridge.cjs.js.map +1 -1
- package/dist/dev-bridge.d.ts +50 -33
- package/dist/dev-bridge.esm.js +175 -74
- package/dist/dev-bridge.esm.js.map +1 -1
- package/dist/index.cjs.js +175 -74
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +51 -34
- package/dist/index.esm.js +175 -74
- package/dist/index.esm.js.map +1 -1
- package/dist/lua.cjs.js +142 -37
- package/dist/lua.cjs.js.map +1 -1
- package/dist/lua.d.ts +40 -22
- package/dist/lua.esm.js +142 -37
- package/dist/lua.esm.js.map +1 -1
- package/dist/simulation.d.ts +23 -17
- package/package.json +1 -1
- package/scripts/install-simulate.mjs +1 -1
- package/src/dev-bridge/DevBridge.ts +187 -82
- package/src/index.ts +0 -3
- package/src/lua/LuaEngine.ts +128 -23
- package/src/lua/SessionManager.ts +23 -1
- package/src/lua/SimulationRunner.ts +12 -14
- package/src/lua/index.ts +0 -3
- package/src/lua/types.ts +23 -20
- package/src/types.ts +0 -3
|
@@ -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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
225
|
-
* - debit: 'bet'
|
|
226
|
-
* - debit: '
|
|
227
|
-
* -
|
|
228
|
-
*
|
|
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'/missing → 0
|
|
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
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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
|
-
|
|
393
|
+
_params: Record<string, unknown> | undefined,
|
|
245
394
|
): number {
|
|
246
|
-
const
|
|
247
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
package/src/lua/LuaEngine.ts
CHANGED
|
@@ -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
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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,
|
|
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
|
}
|