@energy8platform/game-engine 0.18.0 → 0.20.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.
@@ -1,63 +1,55 @@
1
1
  import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
2
2
  import type { RenderContext, SlotSceneController } from './sceneController';
3
3
 
4
- /** Injected dependencies for one round. All host-agnostic + unit-testable. */
5
4
  export interface RunRoundDeps<T extends SlotSpinResultBase> {
6
- /** play → normalize → enrich (roundId/nextActions/complete). From createSlotPlay. */
7
5
  play(action: string, bet: number, roundId?: string): Promise<T>;
8
- /** Settle the most recent result (post-animation). From createSlotPlay. */
9
6
  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. */
7
+ scene: Pick<SlotSceneController<T>, 'onSpin'>;
8
+ /** Build the per-round render context (without signal — runRound injects it per segment). */
9
+ context(action: string): Omit<RenderContext, 'signal'> & { signal?: AbortSignal };
15
10
  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
11
  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>;
12
+ /** Once, before the first segment is played (player pressed spin). */
13
+ onSpinStart?(): void;
14
+ /** Once, after the full drain. */
15
+ onSpinEnd?(last: T, ctx: RenderContext): void;
16
+ /** Fires when entering a non-BASE mode (first free segment). */
17
+ onEnterMode?(trigger: T, ctx: RenderContext): Promise<void>;
18
+ /** Fires after the last segment of a mode. */
19
+ onExitMode?(last: T, ctx: RenderContext): Promise<void>;
20
+ /** Hands the host the AbortController for the segment about to present (for skip). */
21
+ beforeSegment?(ac: AbortController): void;
25
22
  }
26
23
 
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
24
  export async function runRound<T extends SlotSpinResultBase>(
37
25
  deps: RunRoundDeps<T>,
38
26
  action: string,
39
27
  ): 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
28
+ deps.onSpinStart?.();
45
29
 
46
- let inBonus = false;
30
+ const ctxBet = deps.context(action).bet;
31
+
32
+ const segment = async (a: string, roundId: string | undefined): Promise<{ r: T; ctx: RenderContext }> => {
33
+ const ac = new AbortController();
34
+ deps.beforeSegment?.(ac);
35
+ const r = await deps.play(a, ctxBet, roundId);
36
+ const ctx = { ...deps.context(action), signal: ac.signal } as RenderContext;
37
+ await deps.scene.onSpin(r, ctx);
38
+ deps.ack();
39
+ deps.afterPresent?.(r);
40
+ return { r, ctx };
41
+ };
42
+ let { r, ctx } = await segment(action, undefined);
43
+
44
+ let inMode = false;
47
45
  while (!r.complete && r.nextActions && r.nextActions.length > 0) {
48
46
  const next = r.nextActions[0];
49
- if (!inBonus && deps.roleOf(next) === 'free') {
50
- inBonus = true;
51
- await deps.onBonusEnter?.(r, ctx);
47
+ if (!inMode && deps.roleOf(next) === 'free') {
48
+ inMode = true;
49
+ await deps.onEnterMode?.(r, ctx);
52
50
  }
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);
51
+ ({ r, ctx } = await segment(next, r.roundId));
61
52
  }
62
- if (inBonus) await deps.onBonusExit?.(r, ctx);
53
+ if (inMode) await deps.onExitMode?.(r, ctx);
54
+ deps.onSpinEnd?.(r, ctx);
63
55
  }
@@ -0,0 +1,14 @@
1
+ import type { AudioManager } from '../audio/AudioManager';
2
+ import type { SceneAudio } from './sceneController';
3
+
4
+ /** Wrap the engine's AudioManager into the playback-only handle a scene receives. Volume/mute are
5
+ * deliberately omitted — those are driven by the shell's settingChange → host. */
6
+ export function createSceneAudio(audio: AudioManager): SceneAudio {
7
+ return {
8
+ play: (alias, opts) => audio.play(alias, 'sfx', opts),
9
+ playMusic: (alias, fadeMs) => audio.playMusic(alias, fadeMs),
10
+ stopMusic: () => audio.stopMusic(),
11
+ duck: (factor) => audio.duckMusic(factor),
12
+ unduck: () => audio.unduckMusic(),
13
+ };
14
+ }
@@ -1,31 +1,96 @@
1
+ import type { Container } from 'pixi.js';
1
2
  import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
2
3
 
3
- /** Everything a scene needs to render one result. The host builds it once per round. */
4
+ /** Everything a scene needs to render one segment. The host builds it per segment. */
4
5
  export interface RenderContext {
5
- /** Bet for this round (major units). Stable for the whole round (a bonus is one round). */
6
+ /** Bet for this round (major units). Stable for the whole round. */
6
7
  bet: number;
7
- /** Trigger action in the game's own vocabulary (gameSpec.actions keys): 'spin' | 'ante' |
8
- * 'buy_bonus' | … Stable for the whole round. */
8
+ /** Trigger action in the game's own vocabulary ('spin' | 'ante' | 'buy_bonus' | …). */
9
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. */
10
+ /** Stake bet-mode of the round ('BASE' | 'ANTE' | 'BONUS' | …). */
12
11
  mode: string;
13
- /** Currency-aware money formatter. win/totalWin get variable decimals (0.0041 stays 0.0041). */
12
+ /** Currency-aware money formatter. */
14
13
  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. */
14
+ /** LIVE turbo level (0 = off, 1..3 = escalating speed). Read at access. */
17
15
  readonly turbo: number;
16
+ /** Aborted when the player skips this segment (double-tap). The scene's async pacing can race or
17
+ * cancel on it; on abort the scene must collapse to the segment's final visual state. */
18
+ signal: AbortSignal;
18
19
  }
19
20
 
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. */
21
+ /** Playback-only audio handle. Volume/mute are shell settings host, never the scene. */
22
+ export interface SceneAudio {
23
+ play(alias: string, opts?: { volume?: number; loop?: boolean; speed?: number }): void;
24
+ playMusic(alias: string, fadeMs?: number): void;
25
+ stopMusic(): void;
26
+ duck(factor: number): void;
27
+ unduck(): void;
28
+ }
29
+
30
+ export interface OverlayShowOptions {
31
+ /** Draw the overlay content into `container` (sized to the canvas). */
32
+ build(container: Container, size: { width: number; height: number }): void;
33
+ /** Auto-close after N ms (combine with closeOn — whichever fires first). */
34
+ autoCloseMs?: number;
35
+ /** Dismiss on a single tap. Default 'tap'. Set false to require an explicit close(). */
36
+ closeOn?: 'tap' | false;
37
+ /** Optional host-drawn backdrop alpha (0..1). Default: none (game draws its own). */
38
+ dim?: number;
39
+ }
40
+
41
+ /** Single host-owned layer above scene + shell. Eats pointer events so shell controls are
42
+ * unreachable while open. */
43
+ export interface SceneOverlay {
44
+ /** Resolves when the overlay closes. Rejects if one is already open. */
45
+ show(opts: OverlayShowOptions): Promise<void>;
46
+ close(): void;
47
+ }
48
+
49
+ export interface SceneShell {
50
+ /** Live insets (px). `bottom` = the shell bar height; read inside onResize. */
51
+ readonly safeArea: { top: number; right: number; bottom: number; left: number };
52
+ }
53
+
54
+ export interface AutoplaySceneState {
55
+ running: boolean;
56
+ remaining: number;
57
+ }
58
+
59
+ /** Stable capabilities injected once via onCreate. */
60
+ export interface SceneApi {
61
+ audio: SceneAudio;
62
+ overlay: SceneOverlay;
63
+ shell: SceneShell;
64
+ formatAmount(value: number): string;
65
+ readonly bet: number;
66
+ readonly mode: string;
67
+ readonly turbo: number;
68
+ }
69
+
70
+ /** The contract a slot scene implements. The HOST owns the play→present→ack→drain loop and the
71
+ * shell; the scene only renders + reacts. The core spin-lifecycle hooks are REQUIRED (implement
72
+ * them — empty bodies are fine where a game has nothing to do); the incidental reactions below
73
+ * stay optional. */
22
74
  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>;
75
+ /** Injected ONCE before the first round — capabilities, subscriptions, one-time setup. */
76
+ onCreate(api: SceneApi): void;
77
+ /** Fires once per round when the player presses spin (before the network result). */
78
+ onSpinStart(): void;
79
+ /** Render ONE segment (a spin or one free spin). Await your own pacing. */
80
+ onSpin(result: T, ctx: RenderContext): Promise<void>;
81
+ /** Fires when ctx.mode changes between segments (entering a non-BASE mode/bonus). */
82
+ onEnterMode(result: T, ctx: RenderContext): Promise<void>;
83
+ /** Fires when leaving a mode (back toward BASE). */
84
+ onExitMode(result: T, ctx: RenderContext): Promise<void>;
85
+ /** Fires once per round after the full drain (controls unlocked). */
86
+ onSpinEnd(result: T, ctx: RenderContext): void;
87
+ /** Shell events (may fire while idle) — optional. */
88
+ onBetChanged?(bet: number): void;
89
+ onTurboChanged?(level: number): void;
90
+ onAutoplayChanged?(state: AutoplaySceneState): void;
91
+ /** Double-tap skip during an active onSpin (gated by the skipGesture setting). */
92
+ onSkip?(): void;
93
+ /** Tab focus lost / regained. */
94
+ onPause?(): void;
95
+ onResume?(): void;
31
96
  }
@@ -1,14 +1,16 @@
1
1
  // packages/game-engine/src/host/shellConfig.ts
2
- import { socialize } from '@energy8platform/platform-core/shell';
2
+ // `socialize` / `createI18n` are runtime helpers sourced from platform-core/shell;
3
+ // pixi-shell re-exports only types so we import directly from platform-core.
4
+ import { socialize, createI18n } from '@energy8platform/platform-core/shell';
5
+ import type { Lang } from '@energy8platform/platform-core/shell';
3
6
  import type {
4
- ShellConfig, ShellMode, CurrencyConfig, GameInfoContent, GameInfoSection, PaytableRow,
7
+ PixiShellConfig, ShellMode, CurrencyConfig, GameInfoContent, GameInfoSection, PaytableRow,
5
8
  BonusOption, ShellFeatures, GameMode,
6
- } from '@energy8platform/platform-core/shell';
9
+ } from '@energy8platform/pixi-shell';
7
10
  import type { GameModel } from '@energy8platform/platform-core/game-spec';
8
11
  import type { WinTier } from '../slot';
9
12
 
10
13
  export interface SlotShellOptions {
11
- mount?: HTMLElement;
12
14
  /** Override the derived currency (normally taken from initData). */
13
15
  currency?: CurrencyConfig;
14
16
  /** Author-supplied info sections, MERGED over the host-derived set by section identity (an author
@@ -25,6 +27,11 @@ export interface SlotShellOptions {
25
27
  buyBonus?: BonusOption[];
26
28
  tiers?: WinTier[];
27
29
  features?: Partial<ShellFeatures>;
30
+ /** Per-game translation map. Keys are the English source strings (from the spec or copy);
31
+ * values are the translated strings for each language. Merged with the shell's built-in
32
+ * `LOCALES` catalog — game strings take precedence over the built-in entries for the same key.
33
+ * When not supplied, English source strings pass through verbatim (socialised in social mode). */
34
+ i18n?: Partial<Record<Lang, Record<string, string>>>;
28
35
  }
29
36
 
30
37
  /** The currency metadata surfaced on `initData.config.currency` (game-sdk `CurrencyMetaData`).
@@ -129,8 +136,9 @@ export function stakeForAction(model: GameModel, action: string, bet: number): n
129
136
  return cost * bet;
130
137
  }
131
138
 
132
- /** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT). */
133
- export function toBonusOptions(model: GameModel): BonusOption[] {
139
+ /** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT).
140
+ * @param t Optional translator applied to `title` and `description` before they enter the shell. */
141
+ export function toBonusOptions(model: GameModel, t: (s: string) => string = (s) => s): BonusOption[] {
134
142
  const out: BonusOption[] = [];
135
143
  for (const [key, action] of Object.entries(model.spec.actions)) {
136
144
  const role = action.role ?? 'base';
@@ -138,16 +146,19 @@ export function toBonusOptions(model: GameModel): BonusOption[] {
138
146
  out.push({
139
147
  id: key,
140
148
  type: role === 'buy' ? 'bonus' : 'feature',
141
- title: action.title ?? key.replace(/_/g, ' ').toUpperCase(),
142
- description: action.description ?? '',
149
+ title: t(action.title ?? key.replace(/_/g, ' ').toUpperCase()),
150
+ description: t(action.description ?? ''),
143
151
  priceMultiplier: action.cost ?? (role === 'buy' ? 100 : 1),
152
+ // Volatility (1–5 bolts) is part of the spec action SSOT; forward it so the buy card shows it.
153
+ ...(action.volatility != null ? { volatility: action.volatility } : {}),
144
154
  });
145
155
  }
146
156
  return out;
147
157
  }
148
158
 
149
- /** Build a paytable section from the model's derived paytable view (multipliers per symbol count). */
150
- function paytableSection(model: GameModel): GameInfoSection | null {
159
+ /** Build a paytable section from the model's derived paytable view (multipliers per symbol count).
160
+ * @param t Optional translator applied to symbol names before they enter the shell. */
161
+ function paytableSection(model: GameModel, t: (s: string) => string = (s) => s): GameInfoSection | null {
151
162
  const symbols = model.paytable?.symbols ?? [];
152
163
  const rows: PaytableRow[] = [];
153
164
  for (const s of symbols) {
@@ -156,10 +167,12 @@ function paytableSection(model: GameModel): GameInfoSection | null {
156
167
  .filter((w) => Number.isFinite(w.multiplier) && w.multiplier > 0)
157
168
  .sort((a, b) => Number(a.count) - Number(b.count));
158
169
  if (!wins.length) continue;
159
- rows.push({ symbol: { text: s.name ?? s.id }, wins });
170
+ rows.push({ symbol: { text: t(s.name ?? s.id) }, wins });
160
171
  }
161
172
  if (!rows.length) return null;
162
- return { type: 'paytable', title: 'PAYTABLE', rows };
173
+ // No literal title — the shell renders `s.title ?? host.t('Paytable')`, so the heading is
174
+ // localized (matching how the modes/wins sections rely on the shell's translated fallback).
175
+ return { type: 'paytable', rows };
163
176
  }
164
177
 
165
178
  /** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint. */
@@ -205,13 +218,14 @@ function orderDisclaimerLast(sections: GameInfoSection[]): GameInfoSection[] {
205
218
  * gets a real info panel for free (paytable, win illustration, controls, and the Stake
206
219
  * disclaimer when present). Author-supplied `opts.gameInfo` is MERGED over this set by
207
220
  * section identity (see `mergeGameInfo`), not wholesale-replaced.
221
+ * @param t Optional translator applied to spec-derived player-facing strings (symbol names, mode titles, etc.).
208
222
  */
209
- export function defaultGameInfo(model: GameModel, runtime: ShellRuntime): GameInfoContent {
223
+ export function defaultGameInfo(model: GameModel, runtime: ShellRuntime, t: (s: string) => string = (s) => s): GameInfoContent {
210
224
  const sections: GameInfoSection[] = [];
211
225
  sections.push(winsSection(model));
212
- const pay = paytableSection(model);
226
+ const pay = paytableSection(model, t);
213
227
  if (pay) sections.push(pay);
214
- const modes = modesSection(model);
228
+ const modes = modesSection(model, t);
215
229
  if (modes) sections.push(modes);
216
230
  sections.push({ type: 'controls' });
217
231
  const disclaimer = disclaimerSection(runtime.disclaimerLines);
@@ -223,21 +237,22 @@ export function defaultGameInfo(model: GameModel, runtime: ShellRuntime): GameIn
223
237
  * (`model.mathModes` + `spec.actions`) that drives the buy cards and the math pipeline. Stake
224
238
  * compliance requires Cost / RTP / Max Win per mode; deriving it here means the author declares a
225
239
  * 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 {
240
+ * already drops them — free spins are part of a bonus, not a purchasable mode).
241
+ * @param t Optional translator applied to row `title` and `description` before they enter the shell. */
242
+ function modesSection(model: GameModel, t: (s: string) => string = (s) => s): GameInfoSection | null {
228
243
  const modes = model.mathModes ?? [];
229
244
  if (!modes.length) return null;
230
245
  const rows: GameMode[] = modes.map((m) => {
231
246
  const action = model.spec.actions[m.action];
232
247
  const isBase = (action?.role ?? 'base') === 'base' || m.mode === 'BASE';
233
248
  const row: GameMode = {
234
- title: action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' ')),
249
+ title: t(action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' '))),
235
250
  maxWin: `${m.maxWin.toLocaleString('en-US')}×`,
236
251
  };
237
252
  // Cost is a bet-multiplier; a base spin (1×) reads as no premium, so only show it for buys/features.
238
253
  if (m.costMultiplier && m.costMultiplier !== 1) row.price = `${m.costMultiplier}×`;
239
254
  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;
255
+ if (action?.description) row.description = t(action.description);
241
256
  return row;
242
257
  });
243
258
  return { type: 'modes', title: 'MODES', modes: rows };
@@ -319,8 +334,13 @@ function socializeBonusOptions(options: BonusOption[], isSocial: boolean): Bonus
319
334
  return options.map((o) => ({ ...o, title: socialize(o.title), description: socialize(o.description) }));
320
335
  }
321
336
 
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 {
337
+ /** Pure: assemble the shell config (sans mount target) from the model + runtime context
338
+ * (currency/balance/language/mode). The host adds `app` at the call site. */
339
+ export function buildShellConfig(
340
+ opts: SlotShellOptions,
341
+ model: GameModel,
342
+ runtime: ShellRuntime,
343
+ ): Omit<PixiShellConfig, 'app' | 'parent'> {
324
344
  // Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
325
345
  const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
326
346
  // Stake requires the default to come from authenticate on every entry; spec default is the dev fallback.
@@ -330,18 +350,17 @@ export function buildShellConfig(opts: SlotShellOptions, model: GameModel, runti
330
350
  const currency =
331
351
  opts.currency ?? runtime.currency ?? resolveCurrency(null, model.spec.currency);
332
352
  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;
353
+ // Build the merged resolver: game i18n map + shell LOCALES (via createI18n), then socialize on
354
+ // top for English social mode. For non-English languages, createI18n already handles the
355
+ // translation lookup; social rewriting is English-only and applied separately after.
356
+ // Author gameInfo may be a plain object or a `(t) => content` factory. `t` is the resolver so
357
+ // authors can wrap player-facing copy explicitly; the full merged set is still socialized below
358
+ // (section pass) as a safety net so restricted words can't slip through even if t() was missed.
359
+ const { t } = createI18n({ language: runtime.language ?? 'en', isSocial, messages: opts.i18n });
343
360
  const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
344
- let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime), authored);
361
+ // Pass t() through to spec-derived sections so symbol names, mode titles, and descriptions are
362
+ // pre-translated before they reach the shell renderer.
363
+ let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime, t), authored);
345
364
  // The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
346
365
  // wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
347
366
  if (isSocial) {
@@ -351,8 +370,8 @@ export function buildShellConfig(opts: SlotShellOptions, model: GameModel, runti
351
370
  }
352
371
  // The legal DISCLAIMER always renders LAST — author-merged or extra sections never push below it.
353
372
  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);
373
+ // Buy-bonus cards: apply t() to spec-derived options (pre-translate), then socialize for en+social.
374
+ const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model, t), isSocial);
356
375
  // Features: defaults, then author overrides, THEN jurisdiction restrictions (a restriction wins).
357
376
  const features: ShellFeatures = {
358
377
  turbo: 0,
@@ -363,7 +382,6 @@ export function buildShellConfig(opts: SlotShellOptions, model: GameModel, runti
363
382
  } as ShellFeatures;
364
383
  applyJurisdiction(features, runtime.jurisdiction);
365
384
  return {
366
- mount: opts.mount ?? (typeof document !== 'undefined' ? document.body : (undefined as never)),
367
385
  language: runtime.language ?? 'en',
368
386
  isSocial,
369
387
  currency,
@@ -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
+ }
package/src/host/types.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import type { ApplicationOptions } from 'pixi.js';
3
3
  import type { GameModel } from '@energy8platform/platform-core/game-spec';
4
4
  import type { AssetManifest, LoadingScreenConfig } from '@energy8platform/platform-core';
5
- import type { GameShell } from '@energy8platform/platform-core/shell';
5
+ import type { PixiGameShell } from '@energy8platform/pixi-shell';
6
6
  import type { AudioConfig, ScaleMode, Orientation, SceneConstructor } from '../types';
7
7
  import type { BookAdapter, AdapterModule, StakeBridge } from '@energy8platform/stake-bridge';
8
8
  import type { GameApplication } from '../core';
@@ -61,11 +61,14 @@ export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinRe
61
61
  dev?: boolean;
62
62
  stake?: StakeIntegration;
63
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;
64
67
  onFatalError?: (message: string) => void;
65
68
  }
66
69
 
67
70
  export interface SlotGameHandle {
68
71
  game: GameApplication;
69
72
  stakeBridge: StakeBridge | null;
70
- shell: GameShell | null;
73
+ shell: PixiGameShell | null;
71
74
  }