@energy8platform/game-engine 0.17.0 → 0.19.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/audio.cjs.js +15 -5
- package/dist/audio.cjs.js.map +1 -1
- package/dist/audio.d.ts +5 -0
- package/dist/audio.esm.js +15 -5
- package/dist/audio.esm.js.map +1 -1
- package/dist/core.cjs.js +108 -19
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +46 -3
- package/dist/core.esm.js +109 -21
- 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 +3612 -0
- package/dist/host.cjs.js.map +1 -0
- package/dist/host.d.ts +1000 -0
- package/dist/host.esm.js +3603 -0
- package/dist/host.esm.js.map +1 -0
- package/dist/index.cjs.js +59 -19
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +19 -2
- package/dist/index.esm.js +59 -19
- package/dist/index.esm.js.map +1 -1
- package/dist/react.cjs.js.map +1 -1
- package/dist/react.d.ts +19 -2
- 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 +28 -2
- package/src/audio/AudioManager.ts +17 -5
- package/src/core/GameApplication.ts +38 -6
- 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 +543 -0
- package/src/host/fatalError.ts +104 -0
- package/src/host/freeSpinsCounter.ts +44 -0
- package/src/host/index.ts +21 -0
- package/src/host/overlayController.ts +81 -0
- package/src/host/pauseController.ts +21 -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 +55 -0
- package/src/host/sceneAudio.ts +14 -0
- package/src/host/sceneController.ts +96 -0
- package/src/host/sceneStart.ts +25 -0
- package/src/host/shellConfig.ts +384 -0
- package/src/host/skipGesture.ts +24 -0
- package/src/host/slotPlay.ts +62 -0
- package/src/host/types.ts +74 -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
- package/src/viewport/ViewportManager.ts +19 -9
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// packages/game-engine/src/host/shellConfig.ts
|
|
2
|
+
// `socialize` is a runtime helper that pixi-shell does NOT re-export (its index only re-exports
|
|
3
|
+
// types), so it stays sourced from platform-core/shell; the shapes are structurally identical.
|
|
4
|
+
import { socialize } from '@energy8platform/platform-core/shell';
|
|
5
|
+
import type {
|
|
6
|
+
PixiShellConfig, ShellMode, CurrencyConfig, GameInfoContent, GameInfoSection, PaytableRow,
|
|
7
|
+
BonusOption, ShellFeatures, GameMode,
|
|
8
|
+
} from '@energy8platform/pixi-shell';
|
|
9
|
+
import type { GameModel } from '@energy8platform/platform-core/game-spec';
|
|
10
|
+
import type { WinTier } from '../slot';
|
|
11
|
+
|
|
12
|
+
export interface SlotShellOptions {
|
|
13
|
+
/** Override the derived currency (normally taken from initData). */
|
|
14
|
+
currency?: CurrencyConfig;
|
|
15
|
+
/** Author-supplied info sections, MERGED over the host-derived set by section identity (an author
|
|
16
|
+
* section REPLACES the derived one of the same `type`/`kind`; a new `type` is appended; derived
|
|
17
|
+
* sections without an override are kept).
|
|
18
|
+
*
|
|
19
|
+
* Pass a plain `GameInfoContent`, OR a function `(t) => GameInfoContent` where `t` is the
|
|
20
|
+
* social-aware translator (it rewrites restricted gambling words when in social mode, and is the
|
|
21
|
+
* identity otherwise) — wrap player-facing copy in `t(...)` so it socializes. Either way the
|
|
22
|
+
* merged result is also run through `socialize` in social mode as a safety net, so forbidden
|
|
23
|
+
* words never leak even if `t()` was forgotten. */
|
|
24
|
+
gameInfo?: GameInfoContent | ((t: (text: string) => string) => GameInfoContent);
|
|
25
|
+
/** Override the derived buy/ante options. In social mode the card copy is socialized too. */
|
|
26
|
+
buyBonus?: BonusOption[];
|
|
27
|
+
tiers?: WinTier[];
|
|
28
|
+
features?: Partial<ShellFeatures>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The currency metadata surfaced on `initData.config.currency` (game-sdk `CurrencyMetaData`).
|
|
32
|
+
* Built by the Stake bridge via `lookupCurrency(code)` from the authoritative `CURRENCY_META`
|
|
33
|
+
* table — the single source of truth for symbol/decimals/placement. */
|
|
34
|
+
export interface CurrencyMeta {
|
|
35
|
+
code: string;
|
|
36
|
+
symbol: string;
|
|
37
|
+
decimals: number;
|
|
38
|
+
symbolAfter?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Runtime context from the SDK handshake (initData) + the resolved mode. */
|
|
42
|
+
export interface ShellRuntime {
|
|
43
|
+
balance: number;
|
|
44
|
+
/** Resolved shell currency, derived from `initData.config.currency` (the SAME meta the Stake
|
|
45
|
+
* bridge builds). Pass a full `CurrencyConfig` — see `resolveCurrency`. */
|
|
46
|
+
currency?: CurrencyConfig;
|
|
47
|
+
language?: string;
|
|
48
|
+
mode: ShellMode;
|
|
49
|
+
/** Social-casino mode from initData (`config.socialMode`); swaps shell vocabulary. */
|
|
50
|
+
social?: boolean;
|
|
51
|
+
/** Stake-required disclaimer lines from initData (`config.disclaimerLines`); when
|
|
52
|
+
* absent (non-stake/dev) no disclaimer section is rendered. */
|
|
53
|
+
disclaimerLines?: string[];
|
|
54
|
+
/** Jurisdiction flags from initData (`config.jurisdiction`). Restrict shell features — applied
|
|
55
|
+
* OVER the author's features so a jurisdiction restriction always wins. */
|
|
56
|
+
jurisdiction?: JurisdictionRestrictions;
|
|
57
|
+
/** Bet ladder from `/wallet/authenticate` (`initData.config.betLevels`, major units). Stake ladders
|
|
58
|
+
* are CURRENCY-SPECIFIC (us_/non_us_/social_), so this overrides the spec's static `betLevels` on a
|
|
59
|
+
* Stake launch; falls back to the spec on dev/devBridge. */
|
|
60
|
+
betLevels?: number[];
|
|
61
|
+
/** Per-currency default bet from `/wallet/authenticate` (the bridge surfaces it as
|
|
62
|
+
* `config.stake.defaultBetLevel`). Stake requires the selector to start here on every entry. */
|
|
63
|
+
defaultBet?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The subset of Stake's jurisdiction flags the shell can enforce via `ShellFeatures`. */
|
|
67
|
+
export interface JurisdictionRestrictions {
|
|
68
|
+
/** No turbo at all → `features.turbo = 0`. */
|
|
69
|
+
disabledTurbo?: boolean;
|
|
70
|
+
/** Basic turbo allowed, but no super-turbo → cap `features.turbo` at 1. */
|
|
71
|
+
disabledSuperTurbo?: boolean;
|
|
72
|
+
/** No spacebar quick-spin → `features.spacebar = false`. */
|
|
73
|
+
disabledSpacebar?: boolean;
|
|
74
|
+
/** No autoplay → `features.autoplay = null`. */
|
|
75
|
+
disabledAutoplay?: boolean;
|
|
76
|
+
/** No buy-feature → `features.buyBonus = false`. */
|
|
77
|
+
disabledBuyFeature?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Apply jurisdiction restrictions over the resolved shell features, in place. A restriction ALWAYS
|
|
82
|
+
* wins over the author's intent (a forbidden control must stay off even if the game enabled it).
|
|
83
|
+
*/
|
|
84
|
+
export function applyJurisdiction(features: ShellFeatures, j?: JurisdictionRestrictions): void {
|
|
85
|
+
if (!j) return;
|
|
86
|
+
if (j.disabledTurbo) features.turbo = 0;
|
|
87
|
+
else if (j.disabledSuperTurbo && features.turbo > 1) features.turbo = 1;
|
|
88
|
+
if (j.disabledSpacebar) features.spacebar = false;
|
|
89
|
+
if (j.disabledAutoplay) features.autoplay = null;
|
|
90
|
+
if (j.disabledBuyFeature) features.buyBonus = false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the shell `CurrencyConfig` from the SAME data the Stake bridge uses — the
|
|
95
|
+
* `CurrencyMetaData` it puts on `initData.config.currency` (symbol + placement from
|
|
96
|
+
* `symbolAfter`). No second symbol table lives here.
|
|
97
|
+
*
|
|
98
|
+
* Fallback chain (dev/devBridge with no Stake meta): `initData.config.currency`
|
|
99
|
+
* → the spec's currency `code` (neutral `{ symbol: code, position: 'left' }`)
|
|
100
|
+
* → `{ symbol: '€', position: 'left' }`.
|
|
101
|
+
*/
|
|
102
|
+
/** Extra precision for WIN / TOTAL-WIN readouts so small-bet wins (e.g. 0.0041 on a 0.01 bet) are
|
|
103
|
+
* not rounded away to 0.00. Balance / bet stay at the currency's own decimals (`minDecimals`). */
|
|
104
|
+
const WIN_MAX_DECIMALS = 4;
|
|
105
|
+
|
|
106
|
+
/** Attach decimals: `minDecimals` (balance/bet/prices, fixed) = the currency's decimals; `maxDecimals`
|
|
107
|
+
* (win/total-win, variable, trailing zeros trimmed) = up to WIN_MAX_DECIMALS — but only when the
|
|
108
|
+
* currency actually has fraction digits (a 0-decimal currency like JPY keeps wins integer). */
|
|
109
|
+
function withDecimals(base: { symbol: string; position: 'left' | 'right' }, decimals: number): CurrencyConfig {
|
|
110
|
+
return {
|
|
111
|
+
...base,
|
|
112
|
+
minDecimals: decimals,
|
|
113
|
+
maxDecimals: decimals > 0 ? Math.max(decimals, WIN_MAX_DECIMALS) : 0,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function resolveCurrency(meta?: CurrencyMeta | null, specCurrency?: string): CurrencyConfig {
|
|
118
|
+
const hasMeta = !!(meta && meta.symbol);
|
|
119
|
+
// Single expression, no early-return branches (the bundler was treeshaking the meta branch away).
|
|
120
|
+
const symbol = hasMeta ? meta!.symbol : (specCurrency || '€');
|
|
121
|
+
const position: 'left' | 'right' = hasMeta && meta!.symbolAfter ? 'right' : 'left';
|
|
122
|
+
const decimals = hasMeta && typeof meta!.decimals === 'number' ? meta!.decimals : 2;
|
|
123
|
+
return withDecimals({ symbol, position }, decimals);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Total stake for an action = bet × the action's cost multiplier (1 for a base spin; e.g. 100 for
|
|
127
|
+
* a buy bonus). The host uses this to block a play the balance can't cover. */
|
|
128
|
+
export function stakeForAction(model: GameModel, action: string, bet: number): number {
|
|
129
|
+
const cost = (model.spec.actions?.[action]?.cost ?? 1) as number;
|
|
130
|
+
return cost * bet;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT). */
|
|
134
|
+
export function toBonusOptions(model: GameModel): BonusOption[] {
|
|
135
|
+
const out: BonusOption[] = [];
|
|
136
|
+
for (const [key, action] of Object.entries(model.spec.actions)) {
|
|
137
|
+
const role = action.role ?? 'base';
|
|
138
|
+
if (role !== 'buy' && role !== 'feature') continue;
|
|
139
|
+
out.push({
|
|
140
|
+
id: key,
|
|
141
|
+
type: role === 'buy' ? 'bonus' : 'feature',
|
|
142
|
+
title: action.title ?? key.replace(/_/g, ' ').toUpperCase(),
|
|
143
|
+
description: action.description ?? '',
|
|
144
|
+
priceMultiplier: action.cost ?? (role === 'buy' ? 100 : 1),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Build a paytable section from the model's derived paytable view (multipliers per symbol count). */
|
|
151
|
+
function paytableSection(model: GameModel): GameInfoSection | null {
|
|
152
|
+
const symbols = model.paytable?.symbols ?? [];
|
|
153
|
+
const rows: PaytableRow[] = [];
|
|
154
|
+
for (const s of symbols) {
|
|
155
|
+
const wins = Object.entries(s.pay ?? {})
|
|
156
|
+
.map(([count, multiplier]) => ({ count: String(count), multiplier: Number(multiplier) }))
|
|
157
|
+
.filter((w) => Number.isFinite(w.multiplier) && w.multiplier > 0)
|
|
158
|
+
.sort((a, b) => Number(a.count) - Number(b.count));
|
|
159
|
+
if (!wins.length) continue;
|
|
160
|
+
rows.push({ symbol: { text: s.name ?? s.id }, wins });
|
|
161
|
+
}
|
|
162
|
+
if (!rows.length) return null;
|
|
163
|
+
return { type: 'paytable', title: 'PAYTABLE', rows };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint. */
|
|
167
|
+
function winsSection(model: GameModel): GameInfoSection {
|
|
168
|
+
const { cols, rows } = model.spec.grid;
|
|
169
|
+
const grid = { cols, rows };
|
|
170
|
+
switch (model.spec.mechanic) {
|
|
171
|
+
case 'cluster':
|
|
172
|
+
return { type: 'wins', kind: 'cluster', minCount: 5, grid } as GameInfoSection;
|
|
173
|
+
case 'ways':
|
|
174
|
+
return { type: 'wins', kind: 'ways', grid } as GameInfoSection;
|
|
175
|
+
default:
|
|
176
|
+
return { type: 'wins', kind: 'anywhere', minCount: 3, grid } as GameInfoSection;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Title of the legal disclaimer section — used to build it and to exempt it from socialization. */
|
|
181
|
+
const DISCLAIMER_TITLE = 'DISCLAIMER';
|
|
182
|
+
|
|
183
|
+
/** A disclaimer section from initData's disclaimer lines; null when none supplied. */
|
|
184
|
+
function disclaimerSection(lines?: string[]): GameInfoSection | null {
|
|
185
|
+
const clean = (lines ?? []).map((l) => l.trim()).filter(Boolean);
|
|
186
|
+
if (!clean.length) return null;
|
|
187
|
+
const html = clean.map((l) => `<p>${l}</p>`).join('');
|
|
188
|
+
return { type: 'custom', title: DISCLAIMER_TITLE, html };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The legal disclaimer must be shown verbatim — this identifies it so socialization skips it. */
|
|
192
|
+
function isDisclaimerSection(s: GameInfoSection): boolean {
|
|
193
|
+
return s.type === 'custom' && (s as { title?: string }).title === DISCLAIMER_TITLE;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Move the legal disclaimer to the very END of the section list — it must always render last,
|
|
197
|
+
* regardless of where an author merge or an extra section would otherwise place it. */
|
|
198
|
+
function orderDisclaimerLast(sections: GameInfoSection[]): GameInfoSection[] {
|
|
199
|
+
const disclaimer = sections.filter(isDisclaimerSection);
|
|
200
|
+
if (!disclaimer.length) return sections;
|
|
201
|
+
return [...sections.filter((s) => !isDisclaimerSection(s)), ...disclaimer];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Pure: derive a maximal default GameInfoContent from the model + runtime so every game
|
|
206
|
+
* gets a real info panel for free (paytable, win illustration, controls, and the Stake
|
|
207
|
+
* disclaimer when present). Author-supplied `opts.gameInfo` is MERGED over this set by
|
|
208
|
+
* section identity (see `mergeGameInfo`), not wholesale-replaced.
|
|
209
|
+
*/
|
|
210
|
+
export function defaultGameInfo(model: GameModel, runtime: ShellRuntime): GameInfoContent {
|
|
211
|
+
const sections: GameInfoSection[] = [];
|
|
212
|
+
sections.push(winsSection(model));
|
|
213
|
+
const pay = paytableSection(model);
|
|
214
|
+
if (pay) sections.push(pay);
|
|
215
|
+
const modes = modesSection(model);
|
|
216
|
+
if (modes) sections.push(modes);
|
|
217
|
+
sections.push({ type: 'controls' });
|
|
218
|
+
const disclaimer = disclaimerSection(runtime.disclaimerLines);
|
|
219
|
+
if (disclaimer) sections.push(disclaimer);
|
|
220
|
+
return { sections };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Per-mode info table (BASE / ANTE / each buy tier) derived from the spec's modes — the SAME SSOT
|
|
224
|
+
* (`model.mathModes` + `spec.actions`) that drives the buy cards and the math pipeline. Stake
|
|
225
|
+
* compliance requires Cost / RTP / Max Win per mode; deriving it here means the author declares a
|
|
226
|
+
* mode once (in game.spec) and the info table can't drift. `free` actions are excluded (mathModes
|
|
227
|
+
* already drops them — free spins are part of a bonus, not a purchasable mode). */
|
|
228
|
+
function modesSection(model: GameModel): GameInfoSection | null {
|
|
229
|
+
const modes = model.mathModes ?? [];
|
|
230
|
+
if (!modes.length) return null;
|
|
231
|
+
const rows: GameMode[] = modes.map((m) => {
|
|
232
|
+
const action = model.spec.actions[m.action];
|
|
233
|
+
const isBase = (action?.role ?? 'base') === 'base' || m.mode === 'BASE';
|
|
234
|
+
const row: GameMode = {
|
|
235
|
+
title: action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' ')),
|
|
236
|
+
maxWin: `${m.maxWin.toLocaleString('en-US')}×`,
|
|
237
|
+
};
|
|
238
|
+
// Cost is a bet-multiplier; a base spin (1×) reads as no premium, so only show it for buys/features.
|
|
239
|
+
if (m.costMultiplier && m.costMultiplier !== 1) row.price = `${m.costMultiplier}×`;
|
|
240
|
+
if (typeof m.rtp === 'number') row.rtp = Math.round(m.rtp * 1000) / 10; // 0.965 → 96.5 (%)
|
|
241
|
+
if (action?.description) row.description = action.description;
|
|
242
|
+
return row;
|
|
243
|
+
});
|
|
244
|
+
return { type: 'modes', title: 'MODES', modes: rows };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Identity key for merge. `wins` is keyed by `kind` (different mechanics coexist). `custom` has
|
|
248
|
+
* no structural discriminant and several can coexist (MAX WIN, DISCLAIMER, …) so it is keyed by
|
|
249
|
+
* its `title` (an author `custom` with a matching title replaces that derived block; a new title
|
|
250
|
+
* is added). Every other type is a singleton keyed by `type`. */
|
|
251
|
+
function sectionKey(s: GameInfoSection): string {
|
|
252
|
+
if (s.type === 'wins') return `wins:${s.kind}`;
|
|
253
|
+
if (s.type === 'custom') return `custom:${s.title ?? ''}`;
|
|
254
|
+
return s.type;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Merge author `gameInfo` over the host-derived set by section identity: an author section
|
|
259
|
+
* REPLACES the derived section of the same identity (same `type`, or same `wins` `kind`); a new
|
|
260
|
+
* identity is APPENDED (after the derived ones, in author order); derived sections without an
|
|
261
|
+
* author override are KEPT. `override` undefined → the pure derived set.
|
|
262
|
+
*/
|
|
263
|
+
export function mergeGameInfo(derived: GameInfoContent, override?: GameInfoContent): GameInfoContent {
|
|
264
|
+
if (!override) return derived;
|
|
265
|
+
const authorByKey = new Map<string, GameInfoSection>();
|
|
266
|
+
for (const s of override.sections ?? []) authorByKey.set(sectionKey(s), s);
|
|
267
|
+
|
|
268
|
+
const out: GameInfoSection[] = [];
|
|
269
|
+
const used = new Set<string>();
|
|
270
|
+
// Keep derived order; swap in the author's version where identities collide.
|
|
271
|
+
for (const s of derived.sections ?? []) {
|
|
272
|
+
const k = sectionKey(s);
|
|
273
|
+
const replacement = authorByKey.get(k);
|
|
274
|
+
if (replacement) { out.push(replacement); used.add(k); }
|
|
275
|
+
else out.push(s);
|
|
276
|
+
}
|
|
277
|
+
// Append author sections whose identity wasn't in the derived set, in author order.
|
|
278
|
+
for (const s of override.sections ?? []) {
|
|
279
|
+
const k = sectionKey(s);
|
|
280
|
+
if (!used.has(k)) { out.push(s); used.add(k); }
|
|
281
|
+
}
|
|
282
|
+
return { sections: out };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Run a section's player-facing text through `socialize`. Applied to the full MERGED set
|
|
286
|
+
* (host-derived + author) in social mode. Covers section titles, custom HTML, and PAYTABLE row
|
|
287
|
+
* symbol labels — the paytable's symbol text comes straight from the gameSpec's `symbols[].name`,
|
|
288
|
+
* so a forbidden word in a spec symbol name is rewritten here too. A `node`-based custom section is
|
|
289
|
+
* returned untouched — its DOM is author-owned and not introspected. */
|
|
290
|
+
function socializeSection(s: GameInfoSection): GameInfoSection {
|
|
291
|
+
const next = { ...s } as GameInfoSection;
|
|
292
|
+
if ('title' in next && typeof next.title === 'string') {
|
|
293
|
+
(next as { title?: string }).title = socialize(next.title);
|
|
294
|
+
}
|
|
295
|
+
if (next.type === 'custom' && typeof next.html === 'string') {
|
|
296
|
+
(next as { html?: string }).html = socialize(next.html);
|
|
297
|
+
}
|
|
298
|
+
if (next.type === 'paytable' && Array.isArray((next as { rows?: PaytableRow[] }).rows)) {
|
|
299
|
+
(next as { rows: PaytableRow[] }).rows = (next as { rows: PaytableRow[] }).rows.map((r) =>
|
|
300
|
+
typeof r.symbol?.text === 'string'
|
|
301
|
+
? { ...r, symbol: { ...r.symbol, text: socialize(r.symbol.text) } }
|
|
302
|
+
: r,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (next.type === 'modes' && Array.isArray((next as { modes?: GameMode[] }).modes)) {
|
|
306
|
+
(next as { modes: GameMode[] }).modes = (next as { modes: GameMode[] }).modes.map((m) => ({
|
|
307
|
+
...m,
|
|
308
|
+
title: socialize(m.title),
|
|
309
|
+
...(m.description ? { description: socialize(m.description) } : {}),
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
return next;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Socialize buy-bonus card copy (title/description) when in social mode; a no-op otherwise.
|
|
316
|
+
* Applied to the final option set (author override or spec-derived) so forbidden words in author
|
|
317
|
+
* card copy are rewritten too. */
|
|
318
|
+
function socializeBonusOptions(options: BonusOption[], isSocial: boolean): BonusOption[] {
|
|
319
|
+
if (!isSocial) return options;
|
|
320
|
+
return options.map((o) => ({ ...o, title: socialize(o.title), description: socialize(o.description) }));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Pure: assemble the shell config (sans mount target) from the model + runtime context
|
|
324
|
+
* (currency/balance/language/mode). The host adds `app` at the call site. */
|
|
325
|
+
export function buildShellConfig(
|
|
326
|
+
opts: SlotShellOptions,
|
|
327
|
+
model: GameModel,
|
|
328
|
+
runtime: ShellRuntime,
|
|
329
|
+
): Omit<PixiShellConfig, 'app' | 'parent'> {
|
|
330
|
+
// Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
|
|
331
|
+
const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
|
|
332
|
+
// Stake requires the default to come from authenticate on every entry; spec default is the dev fallback.
|
|
333
|
+
const defaultBet = runtime.defaultBet ?? model.spec.defaultBet ?? betLevels[0];
|
|
334
|
+
// runtime.currency is the resolved CurrencyConfig (derived from initData.config.currency by the
|
|
335
|
+
// host); opts.currency still wins. Fall back to the spec code, then a neutral euro.
|
|
336
|
+
const currency =
|
|
337
|
+
opts.currency ?? runtime.currency ?? resolveCurrency(null, model.spec.currency);
|
|
338
|
+
const isSocial = runtime.social ?? false;
|
|
339
|
+
// Merge author sections over the host-derived defaults, THEN socialize the WHOLE merged set in
|
|
340
|
+
// social mode — so restricted gambling vocabulary is rewritten in BOTH the built-in copy AND any
|
|
341
|
+
// author-supplied text (title + custom HTML). A game can no longer surface a forbidden word in
|
|
342
|
+
// social mode just because the author wrote it in their own info section. (Custom sections built
|
|
343
|
+
// from a raw DOM `node` can't be rewritten automatically — author owns the node and can call the
|
|
344
|
+
// exported `socialize` from '@energy8platform/game-engine/host' on their own strings.)
|
|
345
|
+
// Author gameInfo may be a plain object or a `(t) => content` factory. `t` socializes when in
|
|
346
|
+
// social mode (identity otherwise) so authors can wrap copy explicitly; the full merged set is
|
|
347
|
+
// still socialized below as a safety net.
|
|
348
|
+
const t = isSocial ? socialize : (text: string) => text;
|
|
349
|
+
const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
|
|
350
|
+
let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime), authored);
|
|
351
|
+
// The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
|
|
352
|
+
// wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
|
|
353
|
+
if (isSocial) {
|
|
354
|
+
gameInfo = {
|
|
355
|
+
sections: (gameInfo.sections ?? []).map((s) => (isDisclaimerSection(s) ? s : socializeSection(s))),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
// The legal DISCLAIMER always renders LAST — author-merged or extra sections never push below it.
|
|
359
|
+
gameInfo = { sections: orderDisclaimerLast(gameInfo.sections ?? []) };
|
|
360
|
+
// Buy-bonus cards: socialize the FINAL options (author override or spec-derived) in social mode.
|
|
361
|
+
const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model), isSocial);
|
|
362
|
+
// Features: defaults, then author overrides, THEN jurisdiction restrictions (a restriction wins).
|
|
363
|
+
const features: ShellFeatures = {
|
|
364
|
+
turbo: 0,
|
|
365
|
+
spacebar: true,
|
|
366
|
+
autoplay: {},
|
|
367
|
+
buyBonus,
|
|
368
|
+
...(opts.features ?? {}),
|
|
369
|
+
} as ShellFeatures;
|
|
370
|
+
applyJurisdiction(features, runtime.jurisdiction);
|
|
371
|
+
return {
|
|
372
|
+
language: runtime.language ?? 'en',
|
|
373
|
+
isSocial,
|
|
374
|
+
currency,
|
|
375
|
+
gameInfo,
|
|
376
|
+
availableBets: [...betLevels],
|
|
377
|
+
defaultBet,
|
|
378
|
+
currentBet: defaultBet,
|
|
379
|
+
balance: runtime.balance,
|
|
380
|
+
win: 0,
|
|
381
|
+
mode: runtime.mode,
|
|
382
|
+
features,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface SkipDeps {
|
|
2
|
+
/** The skipGesture setting is on. */
|
|
3
|
+
enabled(): boolean;
|
|
4
|
+
/** An onSpin is currently presenting (skippable window). */
|
|
5
|
+
active(): boolean;
|
|
6
|
+
onSkip(): void;
|
|
7
|
+
/** Max ms between the two taps. Default 300. */
|
|
8
|
+
thresholdMs?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Pure double-tap recognizer. The host feeds it pointer `tap(now)` (e.g. performance.now()) and
|
|
12
|
+
* supplies the enabled/active gates + the onSkip effect. */
|
|
13
|
+
export function createDoubleTapSkip(deps: SkipDeps): { tap(now: number): void; destroy(): void } {
|
|
14
|
+
const threshold = deps.thresholdMs ?? 300;
|
|
15
|
+
let last = -Infinity;
|
|
16
|
+
return {
|
|
17
|
+
tap(now: number): void {
|
|
18
|
+
const isDouble = now - last <= threshold;
|
|
19
|
+
last = isDouble ? -Infinity : now; // consume the pair so a 3rd tap starts fresh
|
|
20
|
+
if (isDouble && deps.enabled() && deps.active()) deps.onSkip();
|
|
21
|
+
},
|
|
22
|
+
destroy(): void { last = -Infinity; },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// packages/game-engine/src/host/types.ts
|
|
2
|
+
import type { ApplicationOptions } from 'pixi.js';
|
|
3
|
+
import type { GameModel } from '@energy8platform/platform-core/game-spec';
|
|
4
|
+
import type { AssetManifest, LoadingScreenConfig } from '@energy8platform/platform-core';
|
|
5
|
+
import type { PixiGameShell } from '@energy8platform/pixi-shell';
|
|
6
|
+
import type { AudioConfig, ScaleMode, Orientation, SceneConstructor } from '../types';
|
|
7
|
+
import type { BookAdapter, AdapterModule, StakeBridge } from '@energy8platform/stake-bridge';
|
|
8
|
+
import type { GameApplication } from '../core';
|
|
9
|
+
import type { SlotShellOptions } from './shellConfig';
|
|
10
|
+
import type { SlotSpinResultBase, SlotResultNormalizer } from '@energy8platform/platform-core/slot-result';
|
|
11
|
+
|
|
12
|
+
export interface StakeIntegration {
|
|
13
|
+
/** The game's BookAdapter (or its module). modeMap + gameId come from the model. */
|
|
14
|
+
adapter: BookAdapter | AdapterModule;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One scene registered with the host: a key + its constructor. The list order matters — the
|
|
18
|
+
* first scene that is eligible for the current launch mode is the start scene (unless an explicit
|
|
19
|
+
* `startScene` overrides it). */
|
|
20
|
+
export interface SceneRegistration {
|
|
21
|
+
key: string;
|
|
22
|
+
scene: SceneConstructor;
|
|
23
|
+
/** Skip this scene as a START scene on a replay launch (e.g. an intro). It is still registered
|
|
24
|
+
* (other scenes can `goto` it), it just isn't auto-started — the first non-skipped scene is. */
|
|
25
|
+
skipOnReplay?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @deprecated alias kept for one release — use {@link SceneRegistration}. */
|
|
29
|
+
export type SceneEntry = SceneRegistration;
|
|
30
|
+
|
|
31
|
+
/** Navigation injected into the start data of EVERY scene the host registers.
|
|
32
|
+
* Any scene (intro, game, …) reads it from its `onEnter(data)` to navigate. */
|
|
33
|
+
export interface SceneNavData {
|
|
34
|
+
/** Switch to another registered scene by key. */
|
|
35
|
+
goto: (key: string, data?: unknown) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinResultBase> {
|
|
39
|
+
model: GameModel;
|
|
40
|
+
/** REQUIRED: maps the raw play result into the game's typed result. The host calls it on every play. */
|
|
41
|
+
normalize: SlotResultNormalizer<T>;
|
|
42
|
+
/** ALL scenes the game uses, registered up front, in order. The first scene eligible for the
|
|
43
|
+
* launch mode is the start scene — so a replay launch skips any leading `skipOnReplay` scene
|
|
44
|
+
* (e.g. the intro) and starts directly on the game scene. */
|
|
45
|
+
scenes: SceneRegistration[];
|
|
46
|
+
/** Optional explicit start scene key. Defaults to the first scene eligible for the launch mode
|
|
47
|
+
* (honoured only when that scene is itself eligible; otherwise the first eligible one wins). */
|
|
48
|
+
startScene?: string;
|
|
49
|
+
/** Start data passed to the start scene's `onEnter` (merged with the injected `goto`). */
|
|
50
|
+
startData?: unknown;
|
|
51
|
+
manifest: AssetManifest;
|
|
52
|
+
container?: HTMLElement | string;
|
|
53
|
+
design?: { width: number; height: number };
|
|
54
|
+
scaleMode?: ScaleMode;
|
|
55
|
+
orientation?: Orientation;
|
|
56
|
+
loading?: LoadingScreenConfig;
|
|
57
|
+
audio?: AudioConfig;
|
|
58
|
+
pixi?: Partial<ApplicationOptions>;
|
|
59
|
+
fonts?: string[];
|
|
60
|
+
textureDefaults?: boolean;
|
|
61
|
+
dev?: boolean;
|
|
62
|
+
stake?: StakeIntegration;
|
|
63
|
+
shell?: SlotShellOptions;
|
|
64
|
+
/** Double-tap on the play area to skip the current spin animation. Default `true`. Set `false`
|
|
65
|
+
* to disable the gesture (e.g. games where a tap means something else). */
|
|
66
|
+
skipGesture?: boolean;
|
|
67
|
+
onFatalError?: (message: string) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SlotGameHandle {
|
|
71
|
+
game: GameApplication;
|
|
72
|
+
stakeBridge: StakeBridge | null;
|
|
73
|
+
shell: PixiGameShell | null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// packages/game-engine/src/scenes/IntroScene.ts
|
|
2
|
+
import { Container, Graphics, Text } from 'pixi.js';
|
|
3
|
+
import { Scene } from '../core/Scene';
|
|
4
|
+
|
|
5
|
+
export interface IntroSceneConfig {
|
|
6
|
+
title?: string;
|
|
7
|
+
logo?: string; // texture alias (optional; title text is the default)
|
|
8
|
+
tapToStart?: boolean; // default true
|
|
9
|
+
/** Where to navigate on tap. Defaults to the conventional 'game' key. */
|
|
10
|
+
next?: string;
|
|
11
|
+
/** Optional explicit start callback. Takes precedence over `goto(next)`. */
|
|
12
|
+
onStart?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reusable splash scene: shows a title (or logo) + "tap to start", then advances.
|
|
17
|
+
*
|
|
18
|
+
* The host no longer special-cases the intro. Navigation works like every other
|
|
19
|
+
* scene: the host injects `goto(key)` into this scene's start data. On tap this
|
|
20
|
+
* scene calls `onStart` if the game supplied one, otherwise `goto(next ?? 'game')`.
|
|
21
|
+
* (The built-in can't know the game's scene key generically, so it falls back to
|
|
22
|
+
* the conventional 'game' key — override via `next`. Scaffold-generated intros
|
|
23
|
+
* skip this primitive and call `goto('game')` directly.)
|
|
24
|
+
*/
|
|
25
|
+
export class IntroScene extends Scene {
|
|
26
|
+
private layer?: Container;
|
|
27
|
+
|
|
28
|
+
async onEnter(data?: unknown): Promise<void> {
|
|
29
|
+
const cfg = (data ?? {}) as IntroSceneConfig & { goto?: (key: string, data?: unknown) => void };
|
|
30
|
+
const start = () =>
|
|
31
|
+
cfg.onStart ? cfg.onStart() : cfg.goto?.(cfg.next ?? 'game');
|
|
32
|
+
const layer = new Container();
|
|
33
|
+
this.layer = layer;
|
|
34
|
+
this.container.addChild(layer);
|
|
35
|
+
|
|
36
|
+
const title = new Text({
|
|
37
|
+
text: cfg.title ?? 'PLAY',
|
|
38
|
+
style: { fill: 0xffffff, fontSize: 96, fontFamily: 'Inter', align: 'center' },
|
|
39
|
+
});
|
|
40
|
+
title.anchor.set(0.5);
|
|
41
|
+
title.position.set(960, 460);
|
|
42
|
+
layer.addChild(title);
|
|
43
|
+
|
|
44
|
+
if (cfg.tapToStart !== false) {
|
|
45
|
+
const hint = new Text({
|
|
46
|
+
text: 'Tap to start',
|
|
47
|
+
style: { fill: 0xffd24a, fontSize: 36, fontFamily: 'Inter' },
|
|
48
|
+
});
|
|
49
|
+
hint.anchor.set(0.5);
|
|
50
|
+
hint.position.set(960, 600);
|
|
51
|
+
layer.addChild(hint);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// full-screen tap target
|
|
55
|
+
const hit = new Graphics().rect(0, 0, 1920, 1080).fill({ color: 0x000000, alpha: 0.001 });
|
|
56
|
+
hit.eventMode = 'static';
|
|
57
|
+
hit.cursor = 'pointer';
|
|
58
|
+
hit.once('pointerdown', () => start());
|
|
59
|
+
layer.addChild(hit);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
onExit(): void {
|
|
63
|
+
this.layer?.destroy({ children: true });
|
|
64
|
+
this.layer = undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Re-export the renderer-agnostic branded game shell from platform-core so
|
|
2
|
+
// game-engine consumers can import it via @energy8platform/game-engine/shell.
|
|
3
|
+
export {
|
|
4
|
+
createGameShell,
|
|
5
|
+
removeGameShell,
|
|
6
|
+
GameShell,
|
|
7
|
+
} from '@energy8platform/platform-core/shell';
|
|
8
|
+
export type {
|
|
9
|
+
ShellConfig,
|
|
10
|
+
ShellMode,
|
|
11
|
+
ShellFeatures,
|
|
12
|
+
ShellState,
|
|
13
|
+
ShellEvents,
|
|
14
|
+
BonusOption,
|
|
15
|
+
CurrencyConfig,
|
|
16
|
+
ThemeConfig,
|
|
17
|
+
GameInfoContent,
|
|
18
|
+
AutoplayOptions,
|
|
19
|
+
FreeSpinsState,
|
|
20
|
+
} from '@energy8platform/platform-core/shell';
|