@energy8platform/game-engine 0.17.0 → 0.18.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/dist/core.cjs.js +62 -1
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +33 -2
- package/dist/core.esm.js +63 -3
- package/dist/core.esm.js.map +1 -1
- package/dist/game-spec.cjs.js +13 -0
- package/dist/game-spec.cjs.js.map +1 -0
- package/dist/game-spec.d.ts +1 -0
- package/dist/game-spec.esm.js +2 -0
- package/dist/game-spec.esm.js.map +1 -0
- package/dist/host.cjs.js +3346 -0
- package/dist/host.cjs.js.map +1 -0
- package/dist/host.d.ts +915 -0
- package/dist/host.esm.js +3337 -0
- package/dist/host.esm.js.map +1 -0
- package/dist/index.cjs.js +13 -1
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.esm.js +13 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/react.cjs.js.map +1 -1
- package/dist/react.d.ts +6 -1
- package/dist/react.esm.js.map +1 -1
- package/dist/shell.cjs.js +19 -0
- package/dist/shell.cjs.js.map +1 -0
- package/dist/shell.d.ts +1 -0
- package/dist/shell.esm.js +2 -0
- package/dist/shell.esm.js.map +1 -0
- package/dist/slot.cjs.js +998 -0
- package/dist/slot.cjs.js.map +1 -0
- package/dist/slot.d.ts +333 -0
- package/dist/slot.esm.js +985 -0
- package/dist/slot.esm.js.map +1 -0
- package/package.json +26 -1
- package/src/core/GameApplication.ts +16 -1
- package/src/core/index.ts +2 -0
- package/src/game-spec/index.ts +1 -0
- package/src/host/autoplay.ts +78 -0
- package/src/host/balanceGate.ts +46 -0
- package/src/host/buildConfig.ts +28 -0
- package/src/host/createSlotGame.ts +423 -0
- package/src/host/fatalError.ts +104 -0
- package/src/host/freeSpinsCounter.ts +44 -0
- package/src/host/index.ts +18 -0
- package/src/host/playError.ts +64 -0
- package/src/host/preboot.ts +25 -0
- package/src/host/replay.ts +9 -0
- package/src/host/runRound.ts +63 -0
- package/src/host/sceneController.ts +31 -0
- package/src/host/sceneStart.ts +25 -0
- package/src/host/shellConfig.ts +379 -0
- package/src/host/slotPlay.ts +62 -0
- package/src/host/types.ts +71 -0
- package/src/scenes/IntroScene.ts +66 -0
- package/src/shell/index.ts +20 -0
- package/src/slot/anim/CascadeController.ts +102 -0
- package/src/slot/anim/ReelSpinController.ts +81 -0
- package/src/slot/anim/easing-map.ts +14 -0
- package/src/slot/freeSpins/FreeSpinsSession.ts +40 -0
- package/src/slot/grid/AnimatedSymbol.ts +68 -0
- package/src/slot/grid/ReelGrid.ts +92 -0
- package/src/slot/grid/SymbolCell.ts +127 -0
- package/src/slot/grid/SymbolView.ts +13 -0
- package/src/slot/index.ts +21 -0
- package/src/slot/multiplier/MultiplierAccumulator.ts +29 -0
- package/src/slot/overlay/BigWinOverlay.ts +89 -0
- package/src/slot/overlay/CountUpDisplay.ts +56 -0
- package/src/slot/overlay/tiers.ts +29 -0
- package/src/types.ts +3 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { TextureSource } from 'pixi.js';
|
|
2
|
+
|
|
3
|
+
/** Preload web fonts so Pixi text rasterizes with the right glyphs. Never throws. */
|
|
4
|
+
export async function loadFonts(specs?: string[]): Promise<void> {
|
|
5
|
+
if (!specs || specs.length === 0) return;
|
|
6
|
+
try {
|
|
7
|
+
await Promise.all(specs.map((s) => document.fonts.load(s)));
|
|
8
|
+
await document.fonts.ready;
|
|
9
|
+
} catch {
|
|
10
|
+
/* font CDN unreachable → fall back to system fonts */
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Smoother default downscaling for art-heavy slots. Pixel-art games omit this. */
|
|
15
|
+
export function applyTextureDefaults(): void {
|
|
16
|
+
TextureSource.defaultOptions.autoGenerateMipmaps = true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Idempotent double-boot guard. Returns true the first time, false thereafter. */
|
|
20
|
+
export function bootGuard(flag = '__e8SlotBooted__'): boolean {
|
|
21
|
+
const w = window as unknown as Record<string, boolean>;
|
|
22
|
+
if (w[flag]) return false;
|
|
23
|
+
w[flag] = true;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GameModel } from '@energy8platform/platform-core/game-spec';
|
|
2
|
+
|
|
3
|
+
/** Reverse the model's modeMap (Stake bet mode → SDK action key) for replay labelling/cost. */
|
|
4
|
+
export function resolveReplayBonusId(model: GameModel, stakeMode: string): string {
|
|
5
|
+
for (const [action, mode] of Object.entries(model.modeMap)) {
|
|
6
|
+
if (mode === stakeMode) return action;
|
|
7
|
+
}
|
|
8
|
+
return stakeMode;
|
|
9
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
2
|
+
import type { RenderContext, SlotSceneController } from './sceneController';
|
|
3
|
+
|
|
4
|
+
/** Injected dependencies for one round. All host-agnostic + unit-testable. */
|
|
5
|
+
export interface RunRoundDeps<T extends SlotSpinResultBase> {
|
|
6
|
+
/** play → normalize → enrich (roundId/nextActions/complete). From createSlotPlay. */
|
|
7
|
+
play(action: string, bet: number, roundId?: string): Promise<T>;
|
|
8
|
+
/** Settle the most recent result (post-animation). From createSlotPlay. */
|
|
9
|
+
ack(): void;
|
|
10
|
+
/** The scene to render into (resolved by the caller at call time — it can change between rounds). */
|
|
11
|
+
scene: Pick<SlotSceneController<T>, 'present'>;
|
|
12
|
+
/** Build the per-round render context for the trigger action. */
|
|
13
|
+
context(action: string): RenderContext;
|
|
14
|
+
/** Role of an action from the spec ('base'|'buy'|'feature'|'free'); drives bonus detection. */
|
|
15
|
+
roleOf(action: string): string | undefined;
|
|
16
|
+
/** Fires after each segment is presented + acked. The host updates HUD readouts (win/balance)
|
|
17
|
+
* here so they change WITH the animation, never eagerly when the play result arrives. */
|
|
18
|
+
afterPresent?(result: T): void;
|
|
19
|
+
/** Fires EXACTLY before the first free spin of a bonus. The host drives the shell free-spins mode
|
|
20
|
+
* + counter and delegates to the scene's onBonusEnter. `trigger` is the round's trigger result. */
|
|
21
|
+
onBonusEnter?(trigger: T, ctx: RenderContext): Promise<void>;
|
|
22
|
+
/** Fires after the last free spin of a bonus. The host exits the shell free-spins mode + delegates
|
|
23
|
+
* to the scene's onBonusExit. `last` is the final free-spin result (cumulative totalWin). */
|
|
24
|
+
onBonusExit?(last: T, ctx: RenderContext): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Drive ONE round end-to-end: play the trigger, present it, ack; then drain the remaining segments
|
|
29
|
+
* (a bonus's free spins) by replaying nextActions[0] with the SAME roundId until the round reports
|
|
30
|
+
* `complete`. Fires `onBonusEnter` EXACTLY before the first free-role segment and `onBonusExit`
|
|
31
|
+
* after the last. A plain spin with no bonus is already `complete`, so the while-loop is a no-op.
|
|
32
|
+
*
|
|
33
|
+
* `ctx.bet` is captured once (bet can't change mid-round); `ctx.turbo` is a live getter so a
|
|
34
|
+
* mid-round toggle is honoured on the next segment.
|
|
35
|
+
*/
|
|
36
|
+
export async function runRound<T extends SlotSpinResultBase>(
|
|
37
|
+
deps: RunRoundDeps<T>,
|
|
38
|
+
action: string,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const ctx = deps.context(action);
|
|
41
|
+
let r = await deps.play(action, ctx.bet);
|
|
42
|
+
await deps.scene.present(r, ctx);
|
|
43
|
+
deps.ack();
|
|
44
|
+
deps.afterPresent?.(r); // HUD readouts update AFTER the animation, not before
|
|
45
|
+
|
|
46
|
+
let inBonus = false;
|
|
47
|
+
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
48
|
+
const next = r.nextActions[0];
|
|
49
|
+
if (!inBonus && deps.roleOf(next) === 'free') {
|
|
50
|
+
inBonus = true;
|
|
51
|
+
await deps.onBonusEnter?.(r, ctx);
|
|
52
|
+
}
|
|
53
|
+
// Snapshot the TRIGGER context per segment: { ... } freezes the live `turbo` getter into a data
|
|
54
|
+
// property (so a mid-round toggle is reflected on the NEXT segment), while action/mode/bet stay
|
|
55
|
+
// the round's (the trigger's) identity — a scene must see the same bonus identity all round.
|
|
56
|
+
const segCtx = { ...deps.context(action) } as RenderContext;
|
|
57
|
+
r = await deps.play(next, ctx.bet, r.roundId);
|
|
58
|
+
await deps.scene.present(r, segCtx);
|
|
59
|
+
deps.ack();
|
|
60
|
+
deps.afterPresent?.(r);
|
|
61
|
+
}
|
|
62
|
+
if (inBonus) await deps.onBonusExit?.(r, ctx);
|
|
63
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
2
|
+
|
|
3
|
+
/** Everything a scene needs to render one result. The host builds it once per round. */
|
|
4
|
+
export interface RenderContext {
|
|
5
|
+
/** Bet for this round (major units). Stable for the whole round (a bonus is one round). */
|
|
6
|
+
bet: number;
|
|
7
|
+
/** Trigger action in the game's own vocabulary (gameSpec.actions keys): 'spin' | 'ante' |
|
|
8
|
+
* 'buy_bonus' | … Stable for the whole round. */
|
|
9
|
+
action: string;
|
|
10
|
+
/** Stake bet-mode of the round (model.spec.modeMap[action]): 'BASE' | 'ANTE' | 'BONUS' | …
|
|
11
|
+
* Canonical per-round identifier of WHICH bonus/feature this is. Stable for the whole round. */
|
|
12
|
+
mode: string;
|
|
13
|
+
/** Currency-aware money formatter. win/totalWin get variable decimals (0.0041 stays 0.0041). */
|
|
14
|
+
formatAmount(value: number): string;
|
|
15
|
+
/** LIVE turbo level (0 = off, 1..3 = escalating speed), matching the shell's state.turbo. Read at
|
|
16
|
+
* the moment of access (getter) so a mid-round toggle is reflected. */
|
|
17
|
+
readonly turbo: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The contract a slot scene implements. The HOST owns the play→present→ack→drain loop and calls
|
|
21
|
+
* these; the scene only renders. The game never sees play/ack/roundId. */
|
|
22
|
+
export interface SlotSceneController<T extends SlotSpinResultBase = SlotSpinResultBase> {
|
|
23
|
+
/** Render ONE segment (a spin, or one free spin). All pacing/pauses/overlays live here
|
|
24
|
+
* (await your own animations). The host calls this once per segment. */
|
|
25
|
+
present(result: T, ctx: RenderContext): Promise<void>;
|
|
26
|
+
/** Optional. Fires EXACTLY before the first free spin of a bonus (intro; spin counts in
|
|
27
|
+
* trigger.freeSpins). */
|
|
28
|
+
onBonusEnter?(trigger: T, ctx: RenderContext): Promise<void>;
|
|
29
|
+
/** Optional. Fires after the last free spin of a bonus (summary; last.totalWin = bonus total). */
|
|
30
|
+
onBonusExit?(last: T, ctx: RenderContext): Promise<void>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SceneRegistration } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pick the scene to START with, given the registered scenes (in order) and the launch mode.
|
|
5
|
+
*
|
|
6
|
+
* Rules:
|
|
7
|
+
* - On a replay launch, scenes flagged `skipOnReplay` are NOT eligible to start (they stay
|
|
8
|
+
* registered for `goto`, they just aren't auto-started) — so a leading intro is skipped and
|
|
9
|
+
* the game scene starts directly.
|
|
10
|
+
* - An explicit `startScene` wins, but only if that scene is itself eligible; otherwise the first
|
|
11
|
+
* eligible scene wins.
|
|
12
|
+
* - Falls back to the first scene unconditionally if nothing is eligible (degenerate config).
|
|
13
|
+
*/
|
|
14
|
+
export function resolveStartScene(
|
|
15
|
+
scenes: SceneRegistration[],
|
|
16
|
+
isReplay: boolean,
|
|
17
|
+
explicitStart?: string,
|
|
18
|
+
): string {
|
|
19
|
+
const eligible = scenes.filter((s) => !(isReplay && s.skipOnReplay));
|
|
20
|
+
if (explicitStart) {
|
|
21
|
+
const ok = eligible.find((s) => s.key === explicitStart);
|
|
22
|
+
if (ok) return ok.key;
|
|
23
|
+
}
|
|
24
|
+
return eligible[0]?.key ?? scenes[0]?.key;
|
|
25
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// packages/game-engine/src/host/shellConfig.ts
|
|
2
|
+
import { socialize } from '@energy8platform/platform-core/shell';
|
|
3
|
+
import type {
|
|
4
|
+
ShellConfig, ShellMode, CurrencyConfig, GameInfoContent, GameInfoSection, PaytableRow,
|
|
5
|
+
BonusOption, ShellFeatures, GameMode,
|
|
6
|
+
} from '@energy8platform/platform-core/shell';
|
|
7
|
+
import type { GameModel } from '@energy8platform/platform-core/game-spec';
|
|
8
|
+
import type { WinTier } from '../slot';
|
|
9
|
+
|
|
10
|
+
export interface SlotShellOptions {
|
|
11
|
+
mount?: HTMLElement;
|
|
12
|
+
/** Override the derived currency (normally taken from initData). */
|
|
13
|
+
currency?: CurrencyConfig;
|
|
14
|
+
/** Author-supplied info sections, MERGED over the host-derived set by section identity (an author
|
|
15
|
+
* section REPLACES the derived one of the same `type`/`kind`; a new `type` is appended; derived
|
|
16
|
+
* sections without an override are kept).
|
|
17
|
+
*
|
|
18
|
+
* Pass a plain `GameInfoContent`, OR a function `(t) => GameInfoContent` where `t` is the
|
|
19
|
+
* social-aware translator (it rewrites restricted gambling words when in social mode, and is the
|
|
20
|
+
* identity otherwise) — wrap player-facing copy in `t(...)` so it socializes. Either way the
|
|
21
|
+
* merged result is also run through `socialize` in social mode as a safety net, so forbidden
|
|
22
|
+
* words never leak even if `t()` was forgotten. */
|
|
23
|
+
gameInfo?: GameInfoContent | ((t: (text: string) => string) => GameInfoContent);
|
|
24
|
+
/** Override the derived buy/ante options. In social mode the card copy is socialized too. */
|
|
25
|
+
buyBonus?: BonusOption[];
|
|
26
|
+
tiers?: WinTier[];
|
|
27
|
+
features?: Partial<ShellFeatures>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The currency metadata surfaced on `initData.config.currency` (game-sdk `CurrencyMetaData`).
|
|
31
|
+
* Built by the Stake bridge via `lookupCurrency(code)` from the authoritative `CURRENCY_META`
|
|
32
|
+
* table — the single source of truth for symbol/decimals/placement. */
|
|
33
|
+
export interface CurrencyMeta {
|
|
34
|
+
code: string;
|
|
35
|
+
symbol: string;
|
|
36
|
+
decimals: number;
|
|
37
|
+
symbolAfter?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Runtime context from the SDK handshake (initData) + the resolved mode. */
|
|
41
|
+
export interface ShellRuntime {
|
|
42
|
+
balance: number;
|
|
43
|
+
/** Resolved shell currency, derived from `initData.config.currency` (the SAME meta the Stake
|
|
44
|
+
* bridge builds). Pass a full `CurrencyConfig` — see `resolveCurrency`. */
|
|
45
|
+
currency?: CurrencyConfig;
|
|
46
|
+
language?: string;
|
|
47
|
+
mode: ShellMode;
|
|
48
|
+
/** Social-casino mode from initData (`config.socialMode`); swaps shell vocabulary. */
|
|
49
|
+
social?: boolean;
|
|
50
|
+
/** Stake-required disclaimer lines from initData (`config.disclaimerLines`); when
|
|
51
|
+
* absent (non-stake/dev) no disclaimer section is rendered. */
|
|
52
|
+
disclaimerLines?: string[];
|
|
53
|
+
/** Jurisdiction flags from initData (`config.jurisdiction`). Restrict shell features — applied
|
|
54
|
+
* OVER the author's features so a jurisdiction restriction always wins. */
|
|
55
|
+
jurisdiction?: JurisdictionRestrictions;
|
|
56
|
+
/** Bet ladder from `/wallet/authenticate` (`initData.config.betLevels`, major units). Stake ladders
|
|
57
|
+
* are CURRENCY-SPECIFIC (us_/non_us_/social_), so this overrides the spec's static `betLevels` on a
|
|
58
|
+
* Stake launch; falls back to the spec on dev/devBridge. */
|
|
59
|
+
betLevels?: number[];
|
|
60
|
+
/** Per-currency default bet from `/wallet/authenticate` (the bridge surfaces it as
|
|
61
|
+
* `config.stake.defaultBetLevel`). Stake requires the selector to start here on every entry. */
|
|
62
|
+
defaultBet?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The subset of Stake's jurisdiction flags the shell can enforce via `ShellFeatures`. */
|
|
66
|
+
export interface JurisdictionRestrictions {
|
|
67
|
+
/** No turbo at all → `features.turbo = 0`. */
|
|
68
|
+
disabledTurbo?: boolean;
|
|
69
|
+
/** Basic turbo allowed, but no super-turbo → cap `features.turbo` at 1. */
|
|
70
|
+
disabledSuperTurbo?: boolean;
|
|
71
|
+
/** No spacebar quick-spin → `features.spacebar = false`. */
|
|
72
|
+
disabledSpacebar?: boolean;
|
|
73
|
+
/** No autoplay → `features.autoplay = null`. */
|
|
74
|
+
disabledAutoplay?: boolean;
|
|
75
|
+
/** No buy-feature → `features.buyBonus = false`. */
|
|
76
|
+
disabledBuyFeature?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Apply jurisdiction restrictions over the resolved shell features, in place. A restriction ALWAYS
|
|
81
|
+
* wins over the author's intent (a forbidden control must stay off even if the game enabled it).
|
|
82
|
+
*/
|
|
83
|
+
export function applyJurisdiction(features: ShellFeatures, j?: JurisdictionRestrictions): void {
|
|
84
|
+
if (!j) return;
|
|
85
|
+
if (j.disabledTurbo) features.turbo = 0;
|
|
86
|
+
else if (j.disabledSuperTurbo && features.turbo > 1) features.turbo = 1;
|
|
87
|
+
if (j.disabledSpacebar) features.spacebar = false;
|
|
88
|
+
if (j.disabledAutoplay) features.autoplay = null;
|
|
89
|
+
if (j.disabledBuyFeature) features.buyBonus = false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the shell `CurrencyConfig` from the SAME data the Stake bridge uses — the
|
|
94
|
+
* `CurrencyMetaData` it puts on `initData.config.currency` (symbol + placement from
|
|
95
|
+
* `symbolAfter`). No second symbol table lives here.
|
|
96
|
+
*
|
|
97
|
+
* Fallback chain (dev/devBridge with no Stake meta): `initData.config.currency`
|
|
98
|
+
* → the spec's currency `code` (neutral `{ symbol: code, position: 'left' }`)
|
|
99
|
+
* → `{ symbol: '€', position: 'left' }`.
|
|
100
|
+
*/
|
|
101
|
+
/** Extra precision for WIN / TOTAL-WIN readouts so small-bet wins (e.g. 0.0041 on a 0.01 bet) are
|
|
102
|
+
* not rounded away to 0.00. Balance / bet stay at the currency's own decimals (`minDecimals`). */
|
|
103
|
+
const WIN_MAX_DECIMALS = 4;
|
|
104
|
+
|
|
105
|
+
/** Attach decimals: `minDecimals` (balance/bet/prices, fixed) = the currency's decimals; `maxDecimals`
|
|
106
|
+
* (win/total-win, variable, trailing zeros trimmed) = up to WIN_MAX_DECIMALS — but only when the
|
|
107
|
+
* currency actually has fraction digits (a 0-decimal currency like JPY keeps wins integer). */
|
|
108
|
+
function withDecimals(base: { symbol: string; position: 'left' | 'right' }, decimals: number): CurrencyConfig {
|
|
109
|
+
return {
|
|
110
|
+
...base,
|
|
111
|
+
minDecimals: decimals,
|
|
112
|
+
maxDecimals: decimals > 0 ? Math.max(decimals, WIN_MAX_DECIMALS) : 0,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function resolveCurrency(meta?: CurrencyMeta | null, specCurrency?: string): CurrencyConfig {
|
|
117
|
+
const hasMeta = !!(meta && meta.symbol);
|
|
118
|
+
// Single expression, no early-return branches (the bundler was treeshaking the meta branch away).
|
|
119
|
+
const symbol = hasMeta ? meta!.symbol : (specCurrency || '€');
|
|
120
|
+
const position: 'left' | 'right' = hasMeta && meta!.symbolAfter ? 'right' : 'left';
|
|
121
|
+
const decimals = hasMeta && typeof meta!.decimals === 'number' ? meta!.decimals : 2;
|
|
122
|
+
return withDecimals({ symbol, position }, decimals);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Total stake for an action = bet × the action's cost multiplier (1 for a base spin; e.g. 100 for
|
|
126
|
+
* a buy bonus). The host uses this to block a play the balance can't cover. */
|
|
127
|
+
export function stakeForAction(model: GameModel, action: string, bet: number): number {
|
|
128
|
+
const cost = (model.spec.actions?.[action]?.cost ?? 1) as number;
|
|
129
|
+
return cost * bet;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT). */
|
|
133
|
+
export function toBonusOptions(model: GameModel): BonusOption[] {
|
|
134
|
+
const out: BonusOption[] = [];
|
|
135
|
+
for (const [key, action] of Object.entries(model.spec.actions)) {
|
|
136
|
+
const role = action.role ?? 'base';
|
|
137
|
+
if (role !== 'buy' && role !== 'feature') continue;
|
|
138
|
+
out.push({
|
|
139
|
+
id: key,
|
|
140
|
+
type: role === 'buy' ? 'bonus' : 'feature',
|
|
141
|
+
title: action.title ?? key.replace(/_/g, ' ').toUpperCase(),
|
|
142
|
+
description: action.description ?? '',
|
|
143
|
+
priceMultiplier: action.cost ?? (role === 'buy' ? 100 : 1),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Build a paytable section from the model's derived paytable view (multipliers per symbol count). */
|
|
150
|
+
function paytableSection(model: GameModel): GameInfoSection | null {
|
|
151
|
+
const symbols = model.paytable?.symbols ?? [];
|
|
152
|
+
const rows: PaytableRow[] = [];
|
|
153
|
+
for (const s of symbols) {
|
|
154
|
+
const wins = Object.entries(s.pay ?? {})
|
|
155
|
+
.map(([count, multiplier]) => ({ count: String(count), multiplier: Number(multiplier) }))
|
|
156
|
+
.filter((w) => Number.isFinite(w.multiplier) && w.multiplier > 0)
|
|
157
|
+
.sort((a, b) => Number(a.count) - Number(b.count));
|
|
158
|
+
if (!wins.length) continue;
|
|
159
|
+
rows.push({ symbol: { text: s.name ?? s.id }, wins });
|
|
160
|
+
}
|
|
161
|
+
if (!rows.length) return null;
|
|
162
|
+
return { type: 'paytable', title: 'PAYTABLE', rows };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint. */
|
|
166
|
+
function winsSection(model: GameModel): GameInfoSection {
|
|
167
|
+
const { cols, rows } = model.spec.grid;
|
|
168
|
+
const grid = { cols, rows };
|
|
169
|
+
switch (model.spec.mechanic) {
|
|
170
|
+
case 'cluster':
|
|
171
|
+
return { type: 'wins', kind: 'cluster', minCount: 5, grid } as GameInfoSection;
|
|
172
|
+
case 'ways':
|
|
173
|
+
return { type: 'wins', kind: 'ways', grid } as GameInfoSection;
|
|
174
|
+
default:
|
|
175
|
+
return { type: 'wins', kind: 'anywhere', minCount: 3, grid } as GameInfoSection;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Title of the legal disclaimer section — used to build it and to exempt it from socialization. */
|
|
180
|
+
const DISCLAIMER_TITLE = 'DISCLAIMER';
|
|
181
|
+
|
|
182
|
+
/** A disclaimer section from initData's disclaimer lines; null when none supplied. */
|
|
183
|
+
function disclaimerSection(lines?: string[]): GameInfoSection | null {
|
|
184
|
+
const clean = (lines ?? []).map((l) => l.trim()).filter(Boolean);
|
|
185
|
+
if (!clean.length) return null;
|
|
186
|
+
const html = clean.map((l) => `<p>${l}</p>`).join('');
|
|
187
|
+
return { type: 'custom', title: DISCLAIMER_TITLE, html };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The legal disclaimer must be shown verbatim — this identifies it so socialization skips it. */
|
|
191
|
+
function isDisclaimerSection(s: GameInfoSection): boolean {
|
|
192
|
+
return s.type === 'custom' && (s as { title?: string }).title === DISCLAIMER_TITLE;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Move the legal disclaimer to the very END of the section list — it must always render last,
|
|
196
|
+
* regardless of where an author merge or an extra section would otherwise place it. */
|
|
197
|
+
function orderDisclaimerLast(sections: GameInfoSection[]): GameInfoSection[] {
|
|
198
|
+
const disclaimer = sections.filter(isDisclaimerSection);
|
|
199
|
+
if (!disclaimer.length) return sections;
|
|
200
|
+
return [...sections.filter((s) => !isDisclaimerSection(s)), ...disclaimer];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Pure: derive a maximal default GameInfoContent from the model + runtime so every game
|
|
205
|
+
* gets a real info panel for free (paytable, win illustration, controls, and the Stake
|
|
206
|
+
* disclaimer when present). Author-supplied `opts.gameInfo` is MERGED over this set by
|
|
207
|
+
* section identity (see `mergeGameInfo`), not wholesale-replaced.
|
|
208
|
+
*/
|
|
209
|
+
export function defaultGameInfo(model: GameModel, runtime: ShellRuntime): GameInfoContent {
|
|
210
|
+
const sections: GameInfoSection[] = [];
|
|
211
|
+
sections.push(winsSection(model));
|
|
212
|
+
const pay = paytableSection(model);
|
|
213
|
+
if (pay) sections.push(pay);
|
|
214
|
+
const modes = modesSection(model);
|
|
215
|
+
if (modes) sections.push(modes);
|
|
216
|
+
sections.push({ type: 'controls' });
|
|
217
|
+
const disclaimer = disclaimerSection(runtime.disclaimerLines);
|
|
218
|
+
if (disclaimer) sections.push(disclaimer);
|
|
219
|
+
return { sections };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Per-mode info table (BASE / ANTE / each buy tier) derived from the spec's modes — the SAME SSOT
|
|
223
|
+
* (`model.mathModes` + `spec.actions`) that drives the buy cards and the math pipeline. Stake
|
|
224
|
+
* compliance requires Cost / RTP / Max Win per mode; deriving it here means the author declares a
|
|
225
|
+
* mode once (in game.spec) and the info table can't drift. `free` actions are excluded (mathModes
|
|
226
|
+
* already drops them — free spins are part of a bonus, not a purchasable mode). */
|
|
227
|
+
function modesSection(model: GameModel): GameInfoSection | null {
|
|
228
|
+
const modes = model.mathModes ?? [];
|
|
229
|
+
if (!modes.length) return null;
|
|
230
|
+
const rows: GameMode[] = modes.map((m) => {
|
|
231
|
+
const action = model.spec.actions[m.action];
|
|
232
|
+
const isBase = (action?.role ?? 'base') === 'base' || m.mode === 'BASE';
|
|
233
|
+
const row: GameMode = {
|
|
234
|
+
title: action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' ')),
|
|
235
|
+
maxWin: `${m.maxWin.toLocaleString('en-US')}×`,
|
|
236
|
+
};
|
|
237
|
+
// Cost is a bet-multiplier; a base spin (1×) reads as no premium, so only show it for buys/features.
|
|
238
|
+
if (m.costMultiplier && m.costMultiplier !== 1) row.price = `${m.costMultiplier}×`;
|
|
239
|
+
if (typeof m.rtp === 'number') row.rtp = Math.round(m.rtp * 1000) / 10; // 0.965 → 96.5 (%)
|
|
240
|
+
if (action?.description) row.description = action.description;
|
|
241
|
+
return row;
|
|
242
|
+
});
|
|
243
|
+
return { type: 'modes', title: 'MODES', modes: rows };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Identity key for merge. `wins` is keyed by `kind` (different mechanics coexist). `custom` has
|
|
247
|
+
* no structural discriminant and several can coexist (MAX WIN, DISCLAIMER, …) so it is keyed by
|
|
248
|
+
* its `title` (an author `custom` with a matching title replaces that derived block; a new title
|
|
249
|
+
* is added). Every other type is a singleton keyed by `type`. */
|
|
250
|
+
function sectionKey(s: GameInfoSection): string {
|
|
251
|
+
if (s.type === 'wins') return `wins:${s.kind}`;
|
|
252
|
+
if (s.type === 'custom') return `custom:${s.title ?? ''}`;
|
|
253
|
+
return s.type;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Merge author `gameInfo` over the host-derived set by section identity: an author section
|
|
258
|
+
* REPLACES the derived section of the same identity (same `type`, or same `wins` `kind`); a new
|
|
259
|
+
* identity is APPENDED (after the derived ones, in author order); derived sections without an
|
|
260
|
+
* author override are KEPT. `override` undefined → the pure derived set.
|
|
261
|
+
*/
|
|
262
|
+
export function mergeGameInfo(derived: GameInfoContent, override?: GameInfoContent): GameInfoContent {
|
|
263
|
+
if (!override) return derived;
|
|
264
|
+
const authorByKey = new Map<string, GameInfoSection>();
|
|
265
|
+
for (const s of override.sections ?? []) authorByKey.set(sectionKey(s), s);
|
|
266
|
+
|
|
267
|
+
const out: GameInfoSection[] = [];
|
|
268
|
+
const used = new Set<string>();
|
|
269
|
+
// Keep derived order; swap in the author's version where identities collide.
|
|
270
|
+
for (const s of derived.sections ?? []) {
|
|
271
|
+
const k = sectionKey(s);
|
|
272
|
+
const replacement = authorByKey.get(k);
|
|
273
|
+
if (replacement) { out.push(replacement); used.add(k); }
|
|
274
|
+
else out.push(s);
|
|
275
|
+
}
|
|
276
|
+
// Append author sections whose identity wasn't in the derived set, in author order.
|
|
277
|
+
for (const s of override.sections ?? []) {
|
|
278
|
+
const k = sectionKey(s);
|
|
279
|
+
if (!used.has(k)) { out.push(s); used.add(k); }
|
|
280
|
+
}
|
|
281
|
+
return { sections: out };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Run a section's player-facing text through `socialize`. Applied to the full MERGED set
|
|
285
|
+
* (host-derived + author) in social mode. Covers section titles, custom HTML, and PAYTABLE row
|
|
286
|
+
* symbol labels — the paytable's symbol text comes straight from the gameSpec's `symbols[].name`,
|
|
287
|
+
* so a forbidden word in a spec symbol name is rewritten here too. A `node`-based custom section is
|
|
288
|
+
* returned untouched — its DOM is author-owned and not introspected. */
|
|
289
|
+
function socializeSection(s: GameInfoSection): GameInfoSection {
|
|
290
|
+
const next = { ...s } as GameInfoSection;
|
|
291
|
+
if ('title' in next && typeof next.title === 'string') {
|
|
292
|
+
(next as { title?: string }).title = socialize(next.title);
|
|
293
|
+
}
|
|
294
|
+
if (next.type === 'custom' && typeof next.html === 'string') {
|
|
295
|
+
(next as { html?: string }).html = socialize(next.html);
|
|
296
|
+
}
|
|
297
|
+
if (next.type === 'paytable' && Array.isArray((next as { rows?: PaytableRow[] }).rows)) {
|
|
298
|
+
(next as { rows: PaytableRow[] }).rows = (next as { rows: PaytableRow[] }).rows.map((r) =>
|
|
299
|
+
typeof r.symbol?.text === 'string'
|
|
300
|
+
? { ...r, symbol: { ...r.symbol, text: socialize(r.symbol.text) } }
|
|
301
|
+
: r,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
if (next.type === 'modes' && Array.isArray((next as { modes?: GameMode[] }).modes)) {
|
|
305
|
+
(next as { modes: GameMode[] }).modes = (next as { modes: GameMode[] }).modes.map((m) => ({
|
|
306
|
+
...m,
|
|
307
|
+
title: socialize(m.title),
|
|
308
|
+
...(m.description ? { description: socialize(m.description) } : {}),
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
return next;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Socialize buy-bonus card copy (title/description) when in social mode; a no-op otherwise.
|
|
315
|
+
* Applied to the final option set (author override or spec-derived) so forbidden words in author
|
|
316
|
+
* card copy are rewritten too. */
|
|
317
|
+
function socializeBonusOptions(options: BonusOption[], isSocial: boolean): BonusOption[] {
|
|
318
|
+
if (!isSocial) return options;
|
|
319
|
+
return options.map((o) => ({ ...o, title: socialize(o.title), description: socialize(o.description) }));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Pure: assemble a ShellConfig from the model + runtime context (currency/balance/language/mode). */
|
|
323
|
+
export function buildShellConfig(opts: SlotShellOptions, model: GameModel, runtime: ShellRuntime): ShellConfig {
|
|
324
|
+
// Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
|
|
325
|
+
const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
|
|
326
|
+
// Stake requires the default to come from authenticate on every entry; spec default is the dev fallback.
|
|
327
|
+
const defaultBet = runtime.defaultBet ?? model.spec.defaultBet ?? betLevels[0];
|
|
328
|
+
// runtime.currency is the resolved CurrencyConfig (derived from initData.config.currency by the
|
|
329
|
+
// host); opts.currency still wins. Fall back to the spec code, then a neutral euro.
|
|
330
|
+
const currency =
|
|
331
|
+
opts.currency ?? runtime.currency ?? resolveCurrency(null, model.spec.currency);
|
|
332
|
+
const isSocial = runtime.social ?? false;
|
|
333
|
+
// Merge author sections over the host-derived defaults, THEN socialize the WHOLE merged set in
|
|
334
|
+
// social mode — so restricted gambling vocabulary is rewritten in BOTH the built-in copy AND any
|
|
335
|
+
// author-supplied text (title + custom HTML). A game can no longer surface a forbidden word in
|
|
336
|
+
// social mode just because the author wrote it in their own info section. (Custom sections built
|
|
337
|
+
// from a raw DOM `node` can't be rewritten automatically — author owns the node and can call the
|
|
338
|
+
// exported `socialize` from '@energy8platform/game-engine/host' on their own strings.)
|
|
339
|
+
// Author gameInfo may be a plain object or a `(t) => content` factory. `t` socializes when in
|
|
340
|
+
// social mode (identity otherwise) so authors can wrap copy explicitly; the full merged set is
|
|
341
|
+
// still socialized below as a safety net.
|
|
342
|
+
const t = isSocial ? socialize : (text: string) => text;
|
|
343
|
+
const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
|
|
344
|
+
let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime), authored);
|
|
345
|
+
// The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
|
|
346
|
+
// wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
|
|
347
|
+
if (isSocial) {
|
|
348
|
+
gameInfo = {
|
|
349
|
+
sections: (gameInfo.sections ?? []).map((s) => (isDisclaimerSection(s) ? s : socializeSection(s))),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
// The legal DISCLAIMER always renders LAST — author-merged or extra sections never push below it.
|
|
353
|
+
gameInfo = { sections: orderDisclaimerLast(gameInfo.sections ?? []) };
|
|
354
|
+
// Buy-bonus cards: socialize the FINAL options (author override or spec-derived) in social mode.
|
|
355
|
+
const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model), isSocial);
|
|
356
|
+
// Features: defaults, then author overrides, THEN jurisdiction restrictions (a restriction wins).
|
|
357
|
+
const features: ShellFeatures = {
|
|
358
|
+
turbo: 0,
|
|
359
|
+
spacebar: true,
|
|
360
|
+
autoplay: {},
|
|
361
|
+
buyBonus,
|
|
362
|
+
...(opts.features ?? {}),
|
|
363
|
+
} as ShellFeatures;
|
|
364
|
+
applyJurisdiction(features, runtime.jurisdiction);
|
|
365
|
+
return {
|
|
366
|
+
mount: opts.mount ?? (typeof document !== 'undefined' ? document.body : (undefined as never)),
|
|
367
|
+
language: runtime.language ?? 'en',
|
|
368
|
+
isSocial,
|
|
369
|
+
currency,
|
|
370
|
+
gameInfo,
|
|
371
|
+
availableBets: [...betLevels],
|
|
372
|
+
defaultBet,
|
|
373
|
+
currentBet: defaultBet,
|
|
374
|
+
balance: runtime.balance,
|
|
375
|
+
win: 0,
|
|
376
|
+
mode: runtime.mode,
|
|
377
|
+
features,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { SlotSpinResultBase, SlotResultNormalizer } from '@energy8platform/platform-core/slot-result';
|
|
2
|
+
|
|
3
|
+
export interface SlotPlayDeps<T extends SlotSpinResultBase> {
|
|
4
|
+
play(params: { action: string; bet: number; roundId?: string }): Promise<unknown>;
|
|
5
|
+
normalize: SlotResultNormalizer<T>;
|
|
6
|
+
onWin?: (totalWin: number) => void;
|
|
7
|
+
/** Host hook to acknowledge a finished result (PlatformSession.playAck). Called by `ack()`
|
|
8
|
+
* with the raw host result of the most recent play. On Stake this is what settles the round
|
|
9
|
+
* (`/wallet/end-round`) AFTER the win animation — so the scene must call `ack()` once it has
|
|
10
|
+
* finished presenting each result. */
|
|
11
|
+
ack?: (raw: unknown) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A bound play/ack pair the host uses to drive the play loop (createSlotGame → runRound). */
|
|
15
|
+
export interface SlotPlay<T extends SlotSpinResultBase> {
|
|
16
|
+
/** play → normalize → onWin(totalWin) → return T. Pass `roundId` to advance an in-flight round
|
|
17
|
+
* (drain the next segment of a multi-segment bonus) instead of starting a new one. */
|
|
18
|
+
play(action: string, bet: number, roundId?: string): Promise<T>;
|
|
19
|
+
/** Acknowledge the most recent result (call AFTER its animation). Settles the round on Stake. */
|
|
20
|
+
ack(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Enrich a normalized result with round-continuation metadata (roundId / nextActions / complete)
|
|
25
|
+
* read from the raw play result, so a caller can drain the remaining segments of a multi-segment
|
|
26
|
+
* round by replaying the SAME roundId. The game's normalizer stays focused on render data. Shared by
|
|
27
|
+
* `createSlotPlay` (normal play) and the host's resume path (draining a recovered open round).
|
|
28
|
+
*/
|
|
29
|
+
export function enrichRoundMeta<T extends SlotSpinResultBase>(result: T, raw: unknown): T {
|
|
30
|
+
const meta = (raw ?? {}) as {
|
|
31
|
+
roundId?: string;
|
|
32
|
+
nextActions?: string[];
|
|
33
|
+
session?: { completed?: boolean } | null;
|
|
34
|
+
};
|
|
35
|
+
result.roundId = meta.roundId;
|
|
36
|
+
result.nextActions = meta.nextActions;
|
|
37
|
+
// A round is complete when there is no open session, or the session reports completed. The host
|
|
38
|
+
// sets a session on every segment, so this is `session.completed` in practice.
|
|
39
|
+
result.complete = !meta.session || meta.session.completed === true;
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build the host play/ack pair. Host-agnostic wiring; unit-testable. The returned `play` stashes
|
|
44
|
+
* the raw host result so the matching `ack()` can forward it to `deps.ack` (PlatformSession.playAck)
|
|
45
|
+
* once the scene has finished animating. Plays are sequential (awaited), so a single stash is safe. */
|
|
46
|
+
export function createSlotPlay<T extends SlotSpinResultBase>(
|
|
47
|
+
deps: SlotPlayDeps<T>,
|
|
48
|
+
): SlotPlay<T> {
|
|
49
|
+
let lastRaw: unknown = null;
|
|
50
|
+
return {
|
|
51
|
+
play: async (action, bet, roundId) => {
|
|
52
|
+
const raw = await deps.play({ action, bet, roundId });
|
|
53
|
+
lastRaw = raw;
|
|
54
|
+
const result = enrichRoundMeta(deps.normalize(raw), raw);
|
|
55
|
+
deps.onWin?.(result.totalWin);
|
|
56
|
+
return result;
|
|
57
|
+
},
|
|
58
|
+
ack: () => {
|
|
59
|
+
if (lastRaw != null) deps.ack?.(lastRaw);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|