import { Container, ApplicationOptions, Application, Texture } from 'pixi.js'; import { GameModel } from '@energy8platform/platform-core/game-spec'; import { LoadingScreenConfig, AssetManifest, PlatformSession } from '@energy8platform/platform-core'; import { CurrencyConfig, GameInfoContent, BonusOption, ShellFeatures, MenuItem, BonusIconName, ShellMode, PixiShellConfig, Shell } from '@energy8platform/shell/pixi'; export { OverlayHandle, OverlayRequest, PixiRenderer, PixiShellConfig, ResolvedShellConfig, SafeArea, Shell, ShellActions, ShellHost, ShellLayoutMode, ShellRenderer, ShellSurface, ShellTokens, createPixiShell, createShell } from '@energy8platform/shell/pixi'; import * as _energy8platform_shell_html from '@energy8platform/shell/html'; import { BookAdapter, AdapterModule, StakeBridge } from '@energy8platform/stake-bridge'; import { CasinoGameSDK, InitData, GameConfigData, SessionData } from '@energy8platform/game-sdk'; import { Lang } from '@energy8platform/shell'; export { socialize } from '@energy8platform/shell'; import { SlotSpinResultBase, SlotResultNormalizer } from '@energy8platform/platform-core/slot-result'; declare enum ScaleMode { /** Fit inside container, maintain aspect ratio (letterbox/pillarbox) */ FIT = "FIT", /** Fill container, maintain aspect ratio (crop edges) */ FILL = "FILL", /** Stretch to fill (distorts) */ STRETCH = "STRETCH" } declare enum Orientation { LANDSCAPE = "landscape", PORTRAIT = "portrait", ANY = "any" } interface AudioConfig { /** Default volumes per category (0..1) */ music?: number; sfx?: number; ui?: number; ambient?: number; /** Persist mute state in localStorage */ persist?: boolean; /** LocalStorage key prefix */ storageKey?: string; } interface GameApplicationConfig { /** Container element or CSS selector to mount canvas into */ container?: HTMLElement | string; /** Reference design width (fallback: GameConfigData.viewport.width or 1920) */ designWidth?: number; /** Reference design height (fallback: GameConfigData.viewport.height or 1080) */ designHeight?: number; /** How to scale the game to fit the container */ scaleMode?: ScaleMode; /** Preferred orientation */ orientation?: Orientation; /** Loading screen configuration */ loading?: LoadingScreenConfig; /** Asset manifest — what to load */ manifest?: AssetManifest; /** Audio configuration */ audio?: AudioConfig; /** SDK options. Set to false to disable SDK (offline/development mode) */ sdk?: { parentOrigin?: string; timeout?: number; debug?: boolean; /** Use in-memory channel instead of postMessage (no iframe required) */ devMode?: boolean; } | false; /** PixiJS Application options (pass-through) */ pixi?: Partial; /** Enable debug overlay (FPS, draw calls) */ debug?: boolean; /** When set, GameApplication mounts the branded game shell after the SDK handshake. */ shell?: _energy8platform_shell_html.ShellConfig | false; } interface SceneConstructor { new (): IScene; [key: string]: any; } interface IScene { /** Root display container for this scene */ readonly container: Container; /** @internal GameApplication reference — set by SceneManager */ __engineApp?: any; /** Called when the scene is entered */ onEnter?(data?: unknown): Promise | void; /** Called when the scene is exited */ onExit?(): Promise | void; /** Called every frame */ onUpdate?(dt: number): void; /** Called when viewport resizes */ onResize?(width: number, height: number): void; /** Called when the scene is destroyed */ onDestroy?(): void; } declare enum TransitionType { NONE = "none", FADE = "fade", SLIDE_LEFT = "slide-left", SLIDE_RIGHT = "slide-right" } interface TransitionConfig { type: TransitionType; duration?: number; easing?: (t: number) => number; } interface GameEngineEvents { /** Fired when engine initialization is complete */ initialized: void; /** Fired when all assets are loaded */ loaded: void; /** Fired when the engine starts running */ started: void; /** Fired on viewport resize */ resize: { width: number; height: number; }; /** Fired on orientation change */ orientationChange: Orientation; /** Fired on scene change */ sceneChange: { from: string | null; to: string; }; /** Fired when player balance changes (forwarded from SDK) */ balanceUpdate: { balance: number; }; /** Fired on error */ error: Error; /** Fired when engine is destroyed */ destroyed: void; } /** * Minimal typed event emitter. * Used internally by GameApplication, SceneManager, AudioManager, etc. * * Supports `void` event types — events that carry no data can be emitted * without arguments: `emitter.emit('eventName')`. */ declare class EventEmitter { private listeners; on(event: K, handler: (data: TEvents[K]) => void): this; once(event: K, handler: (data: TEvents[K]) => void): this; off(event: K, handler: (data: TEvents[K]) => void): this; emit(...args: TEvents[K] extends void ? [event: K] : [event: K, data: TEvents[K]]): void; removeAllListeners(event?: keyof TEvents): this; } interface SceneEntry { scene: IScene; key: string; } interface SceneManagerEvents { change: { from: string | null; to: string; }; } /** * Manages the scene stack and transitions between scenes. * * @example * ```ts * const scenes = new SceneManager(app.stage); * scenes.register('loading', LoadingScene); * scenes.register('game', GameScene); * await scenes.goto('loading'); * ``` */ declare class SceneManager extends EventEmitter { private static MAX_TRANSITION_DEPTH; /** Root container that scenes are added to */ root: Container; private registry; private stack; private _transitionDepth; /** Current viewport dimensions — set by ViewportManager */ private _width; private _height; /** @internal GameApplication reference — passed to scenes */ private _app; constructor(root?: Container); /** @internal Set the root container (called by GameApplication after PixiJS init) */ setRoot(root: Container): void; /** @internal Set the app reference (called by GameApplication) */ setApp(app: any): void; /** Register a scene class by key */ register(key: string, ctor: SceneConstructor): this; /** Get the current (topmost) scene entry */ get current(): SceneEntry | null; /** Get the current scene key */ get currentKey(): string | null; /** Whether a scene transition is in progress */ get isTransitioning(): boolean; /** * Navigate to a scene, replacing the entire stack. */ goto(key: string, data?: unknown, transition?: TransitionConfig): Promise; /** * Push a scene onto the stack (the previous scene stays underneath). * Useful for overlays, modals, pause screens. */ push(key: string, data?: unknown, transition?: TransitionConfig): Promise; /** * Pop the top scene from the stack. */ pop(transition?: TransitionConfig): Promise; /** * Replace the top scene with a new one. */ replace(key: string, data?: unknown, transition?: TransitionConfig): Promise; /** * Called every frame by GameApplication. */ update(dt: number): void; /** * Called on viewport resize. */ resize(width: number, height: number): void; /** * Destroy all scenes and clear the manager. */ destroy(): void; private createScene; private pushInternal; private popInternal; private transitionIn; private transitionOut; } /** * Manages game asset loading with progress tracking, bundle support, and * automatic base path resolution from SDK's assetsUrl. * * Wraps PixiJS Assets API with a typed, game-oriented interface. * * @example * ```ts * const assets = new AssetManager('https://cdn.example.com/game/', manifest); * await assets.init(); * await assets.loadBundle('preload', (p) => console.log(p)); * const texture = assets.get('hero'); * ``` */ declare class AssetManager { private _initialized; private _basePath; private _manifest; private _loadedBundles; constructor(basePath?: string, manifest?: AssetManifest); /** Whether the asset system has been initialized */ get initialized(): boolean; /** Base path for all assets (usually from SDK's assetsUrl) */ get basePath(): string; /** Set of loaded bundle names */ get loadedBundles(): ReadonlySet; /** * Initialize the asset system. * Must be called before loading any assets. */ init(): Promise; /** * Load a single bundle by name. * * @param name - Bundle name (must exist in the manifest) * @param onProgress - Progress callback (0..1) * @returns Loaded assets map */ loadBundle(name: string, onProgress?: (progress: number) => void): Promise>; /** * Load multiple bundles simultaneously. * Progress is aggregated across all bundles. * * @param names - Bundle names * @param onProgress - Progress callback (0..1) */ loadBundles(names: string[], onProgress?: (progress: number) => void): Promise>; /** * Load individual assets by URL or alias. * * @param urls - Asset URLs or aliases * @param onProgress - Progress callback (0..1) */ load(urls: string | string[], onProgress?: (progress: number) => void): Promise; /** * Get a loaded asset synchronously from cache. * * @param alias - Asset alias * @throws if not loaded */ get(alias: string): T; /** * Unload a bundle to free memory. */ unloadBundle(name: string): Promise; /** * Start background loading a bundle (low-priority preload). * Useful for loading bonus round assets while player is in base game. */ backgroundLoad(name: string): Promise; /** * Get all bundle names from the manifest. */ getBundleNames(): string[]; /** * Check if a bundle is loaded. */ isBundleLoaded(name: string): boolean; private ensureInitialized; } type AudioCategoryName = 'music' | 'sfx' | 'ui' | 'ambient'; /** * Manages all game audio: music, SFX, UI sounds, ambient. * * Optional dependency on @pixi/sound — if not installed, AudioManager * operates as a silent no-op (graceful degradation). * * Features: * - Per-category volume control (music, sfx, ui, ambient) * - Music crossfade and looping * - Mobile audio unlock on first interaction * - Mute state persistence in localStorage * - Global mute/unmute * * @example * ```ts * const audio = new AudioManager({ music: 0.5, sfx: 0.8 }); * await audio.init(); * audio.playMusic('bg-music'); * audio.play('spin-click', 'sfx'); * ``` */ declare class AudioManager { private _soundModule; private _initialized; private _globalMuted; private _persist; private _storageKey; private _categories; private _masterGain; private _currentMusic; /** Duck factor (0..1) from duckMusic/unduckMusic. A presentation state, not a player setting. */ private _musicDuck; /** Crossfade ramp (0..1) for the track that is fading IN. 1 whenever no fade is running. */ private _musicFade; /** Generation counter so a superseded crossfade ramp stops writing over the new track's. */ private _musicFadeToken; private _unlocked; private _unlockHandler; constructor(config?: AudioConfig); /** Whether the audio system is initialized */ get initialized(): boolean; /** Whether audio is globally muted */ get muted(): boolean; /** * Initialize the audio system. * Dynamically imports @pixi/sound to keep it optional. */ init(): Promise; /** * Play a sound effect. * * @param alias - Sound alias (must be loaded via AssetManager) * @param category - Audio category (default: 'sfx') * @param options - Additional play options */ play(alias: string, category?: AudioCategoryName, options?: { volume?: number; loop?: boolean; speed?: number; }): void; /** * Play background music with optional crossfade. * * @param alias - Music alias * @param fadeDuration - Crossfade duration in ms (default: 500) */ playMusic(alias: string, fadeDuration?: number): void; /** * Stop current music. */ stopMusic(): void; /** * Stop all sounds. */ stopAll(): void; /** Global gain (0..1) folded into every category's effective volume. Driven by the shell's * 'master' settingChange. Does not affect the persisted per-category volumes. */ setMasterVolume(volume: number): void; getMasterVolume(): number; /** * Set volume for a category. */ setVolume(category: AudioCategoryName, volume: number): void; /** * Get volume for a category. */ getVolume(category: AudioCategoryName): number; /** * Mute a specific category. */ muteCategory(category: AudioCategoryName): void; /** * Unmute a specific category. */ unmuteCategory(category: AudioCategoryName): void; /** * Toggle mute for a category. */ toggleCategory(category: AudioCategoryName): boolean; /** * Mute all audio globally. */ muteAll(): void; /** * Unmute all audio globally. */ unmuteAll(): void; /** * Toggle global mute. */ toggleMute(): boolean; /** * Duck music volume (e.g., during big win presentation). * * @param factor - Volume multiplier (0..1), e.g. 0.3 = 30% of normal */ duckMusic(factor: number): void; /** * Restore music to normal volume after ducking. */ unduckMusic(): void; /** * Destroy the audio manager and free resources. */ destroy(): void; /** * Smoothly fade a sound's volume from `fromVol` to `toVol` over `durationMs`. */ private fadeVolume; /** * The SOUND-layer gain for the running music track. * * @pixi/sound resolves a playing instance as `instance × sound × global` (WebAudioInstance. * refresh). Each of those three has exactly ONE owner here, which is what keeps the mixer honest: * global — the master gain (`applyVolumes`) * sound — music: this function. sfx: untouched, left at 1. * instance — sfx: the per-call volume × the sfx category. music: always 1. * Everything that can move music volume — the player's slider, the category mute, a big-win duck, * a crossfade — is a term below, so they compose instead of overwriting each other. */ private musicGain; /** Push `musicGain()` at the current track. Safe before it starts playing and with none playing. */ private applyMusicGain; /** Current SOUND-layer volume of `alias`, or 0 when it cannot be read. */ private soundVolumeOf; /** Ramp the crossfade term 0 → 1 over `durationMs`, recomposing the gain each frame so a slider * drag or a duck landing mid-fade is honoured rather than overwritten when the fade ends. */ private rampMusicFade; private applyVolumes; private setupMobileUnlock; private removeMobileUnlock; private saveState; private restoreState; } interface InputEvents { tap: { x: number; y: number; }; press: { x: number; y: number; }; release: { x: number; y: number; }; move: { x: number; y: number; }; swipe: { direction: 'up' | 'down' | 'left' | 'right'; velocity: number; }; keydown: { key: string; code: string; }; keyup: { key: string; code: string; }; } /** * Unified input manager for touch, mouse, and keyboard. * * Features: * - Unified pointer events (works with touch + mouse) * - Swipe gesture detection * - Keyboard input with isKeyDown state * - Input locking (block input during animations) * * @example * ```ts * const input = new InputManager(app.canvas); * * input.on('tap', ({ x, y }) => console.log('Tapped at', x, y)); * input.on('swipe', ({ direction }) => console.log('Swiped', direction)); * input.on('keydown', ({ key }) => { * if (key === ' ') spin(); * }); * * // Block input during animations * input.lock(); * await playAnimation(); * input.unlock(); * ``` */ declare class InputManager extends EventEmitter { private _canvas; private _locked; private _keysDown; private _destroyed; private _viewportScale; private _viewportOffsetX; private _viewportOffsetY; private _pointerStart; private _swipeThreshold; private _swipeMaxTime; constructor(canvas: HTMLCanvasElement); /** Whether input is currently locked */ get locked(): boolean; /** Lock all input (e.g., during animations) */ lock(): void; /** Unlock input */ unlock(): void; /** Check if a key is currently pressed */ isKeyDown(key: string): boolean; /** * Update the viewport transform used for DOM→world coordinate mapping. * Called automatically by GameApplication when ViewportManager emits resize. */ setViewportTransform(scale: number, offsetX: number, offsetY: number): void; /** * Convert a DOM canvas position to game-world coordinates, * accounting for viewport scaling and offset. */ getWorldPosition(canvasX: number, canvasY: number): { x: number; y: number; }; /** Destroy the input manager */ destroy(): void; private setupPointerEvents; private onPointerDown; private onPointerUp; private onPointerMove; private getCanvasPosition; private setupKeyboardEvents; private onKeyDown; private onKeyUp; } interface ViewportConfig { designWidth: number; designHeight: number; scaleMode: ScaleMode; orientation: Orientation; } interface ViewportEvents { resize: { width: number; height: number; scale: number; }; orientationChange: Orientation; } /** * Manages responsive scaling of the game canvas to fit its container. * * Supports three scale modes: * - **FIT** — letterbox/pillarbox to maintain aspect ratio (industry standard) * - **FILL** — fill container, crop edges * - **STRETCH** — stretch to fill (distorts) * * Also handles: * - Orientation detection (landscape/portrait) * - Safe areas (mobile notch) * - ResizeObserver for smooth container resizing * * @example * ```ts * const viewport = new ViewportManager(app, container, { * designWidth: 1920, * designHeight: 1080, * scaleMode: ScaleMode.FIT, * orientation: Orientation.LANDSCAPE, * }); * * viewport.on('resize', ({ width, height, scale }) => { * console.log(`New size: ${width}x${height} @ ${scale}x`); * }); * ``` */ declare class ViewportManager extends EventEmitter { private _app; private _container; private _config; private _target; private _resizeObserver; private _currentOrientation; private _currentWidth; private _currentHeight; private _currentScale; private _destroyed; private _resizeTimeout; constructor(app: Application, container: HTMLElement, config: ViewportConfig, target?: Container); /** Current canvas width in game units */ get width(): number; /** Current canvas height in game units */ get height(): number; /** Current scale factor */ get scale(): number; /** Current orientation */ get orientation(): Orientation; /** Design reference width */ get designWidth(): number; /** Design reference height */ get designHeight(): number; /** * Force a resize calculation. Called automatically on container size change. */ refresh(): void; /** * Destroy the viewport manager. */ destroy(): void; private setupObserver; private onWindowResize; private debouncedRefresh; } /** * FPS overlay for debugging performance. * * Shows FPS, frame time, and draw call count in the corner of the screen. * * @example * ```ts * const fps = new FPSOverlay(app); * fps.show(); * ``` */ declare class FPSOverlay { private _app; private _container; private _fpsText; private _visible; private _samples; private _maxSamples; private _lastUpdate; private _tickFn; constructor(app: Application); /** Show the FPS overlay */ show(): void; /** Hide the FPS overlay */ hide(): void; /** Toggle visibility */ toggle(): void; /** Destroy the overlay */ destroy(): void; } /** * The main entry point for a game built on @energy8platform/game-engine. * * Orchestrates the full lifecycle: * 1. Create PixiJS Application * 2. Initialize SDK (or run offline) * 3. Show CSS preloader → Canvas loading screen with progress bar * 4. Load asset manifest * 5. Transition to the first game scene * * @example * ```ts * import { GameApplication, ScaleMode } from '@energy8platform/game-engine'; * import { GameScene } from './scenes/GameScene'; * * const game = new GameApplication({ * container: '#game', * designWidth: 1920, * designHeight: 1080, * scaleMode: ScaleMode.FIT, * manifest: { bundles: [ * { name: 'preload', assets: [{ alias: 'logo', src: 'logo.png' }] }, * { name: 'game', assets: [{ alias: 'bg', src: 'background.png' }] }, * ]}, * loading: { tapToStart: true }, * }); * * game.scenes.register('game', GameScene); * await game.start('game'); * ``` */ declare class GameApplication extends EventEmitter { /** PixiJS Application instance */ app: Application; /** Scene manager */ scenes: SceneManager; /** Asset manager */ assets: AssetManager; /** Audio manager */ audio: AudioManager; /** Input manager */ input: InputManager; /** Viewport manager */ viewport: ViewportManager; /** Scaled world root (holds scenes). Transformed by the ViewportManager to fit the design * resolution; lives below the UI layer on app.stage. */ worldRoot: Container; /** Unscaled, screen-space UI layer. Sits above {@link worldRoot} and is NOT touched by the * viewport transform — children fill the real screen (e.g. the host's shell + overlay). */ uiLayer: Container; /** SDK instance (null in offline mode) */ sdk: CasinoGameSDK | null; /** FPS overlay instance (only when debug: true) */ fpsOverlay: FPSOverlay | null; /** Data received from SDK initialization */ initData: InitData | null; /** Platform session (SDK + optional DevBridge). null until start() runs. */ platformSession: PlatformSession | null; /** Branded game shell (only when config.shell is set). */ shell?: _energy8platform_shell_html.GameShell; /** Configuration */ readonly config: GameApplicationConfig; private _running; private _destroyed; private _container; constructor(config?: GameApplicationConfig); /** Current game config from SDK (or null in offline mode) */ get gameConfig(): GameConfigData | null; /** Current session data */ get session(): SessionData | null; /** Current balance */ get balance(): number; /** Current currency */ get currency(): string; /** Whether the engine is running */ get isRunning(): boolean; /** * Start the game engine. This is the main entry point. * * @param firstScene - Key of the first scene to show after loading (must be registered) * @param sceneData - Optional data to pass to the first scene's onEnter */ start(firstScene: string, sceneData?: unknown): Promise; /** * Destroy the engine and free all resources. */ destroy(): Promise; private resolveContainer; private initPixi; private initSDK; private applySDKConfig; private initSubSystems; private loadAssets; } interface WinTier { id: string; minMultiplier: number; title: string; accentColor: number; bannerTexture?: Texture; } interface SlotShellOptions { /** Override the derived currency (normally taken from initData). */ currency?: CurrencyConfig; /** Author-supplied info sections, MERGED over the host-derived set by section identity (an author * section REPLACES the derived one of the same `type`/`kind`; a new `type` is appended; derived * sections without an override are kept). * * Pass a plain `GameInfoContent`, OR a function `(t) => GameInfoContent` where `t` is the * social-aware translator (it rewrites restricted gambling words when in social mode, and is the * identity otherwise) — wrap player-facing copy in `t(...)` so it socializes. Either way the * merged result is also run through `socialize` in social mode as a safety net, so forbidden * words never leak even if `t()` was forgotten. */ gameInfo?: GameInfoContent | ((t: (text: string) => string) => GameInfoContent); /** Override the derived buy/ante options. In social mode the card copy is socialized too. */ buyBonus?: BonusOption[]; tiers?: WinTier[]; features?: Partial; /** Bar-menu popover items, in order. Omit for the shell's default list (sound/music/sfx, * separator, gameInfo). See `@energy8platform/shell`'s `MenuItem` for preset ids and custom * toggle/range/button/separator row kinds. */ menu?: MenuItem[]; /** The provider whose BUY BONUS icon the button shows, e.g. `'energy8'` (see the shell's * `BONUS_ICONS`). Omit for the skin's own icon. */ bonusIcon?: BonusIconName; /** Quote the derived paytable in money (multiplier × the current bet, session currency) instead * of `x`. The shell computes it each time the page opens, so it follows the bet. */ paytableAmounts?: boolean; /** Per-game translation map. Keys are the English source strings (from the spec or copy); * values are the translated strings for each language. Merged with the shell's built-in * `LOCALES` catalog — game strings take precedence over the built-in entries for the same key. * When not supplied, English source strings pass through verbatim (socialised in social mode). */ i18n?: Partial>>; } /** Runtime context from the SDK handshake (initData) + the resolved mode. */ interface ShellRuntime { balance: number; /** Resolved shell currency, derived from `initData.config.currency` (the SAME meta the Stake * bridge builds). Pass a full `CurrencyConfig` — see `resolveCurrency`. */ currency?: CurrencyConfig; language?: string; mode: ShellMode; /** Social-casino mode from initData (`config.socialMode`); swaps shell vocabulary. */ social?: boolean; /** Disclaimer lines the PLATFORM supplied (`initData.config.disclaimerLines` — in practice the * Stake bridge, which appends its own "TM and © {year} Engine." to the mandated body). * They outrank the default. When absent — Artube, dev, any other host — the shell's canonical * `DISCLAIMER_LINES` are used: the same mandated wording, minus a mark that isn't theirs. The * disclaimer is required legal copy, so there is no case where the section is simply missing. */ disclaimerLines?: string[]; /** Jurisdiction flags from initData (`config.jurisdiction`). Restrict shell features — applied * OVER the author's features so a jurisdiction restriction always wins. */ jurisdiction?: JurisdictionRestrictions; /** The client the platform launched us on (`initData.device`; both bridges read it off the launch * URL). `'mobile'` turns the whole keyboard surface off — the shortcuts and the Hotkeys section * that documents them. Left unset the shell measures the client itself (shell's core/device.ts); * the host only speaks when the platform told it outright. */ device?: 'desktop' | 'mobile' | string; /** Bet ladder from `/wallet/authenticate` (`initData.config.betLevels`, major units). Stake ladders * are CURRENCY-SPECIFIC (us_/non_us_/social_), so this overrides the spec's static `betLevels` on a * Stake launch; falls back to the spec on dev/devBridge. */ betLevels?: number[]; /** Per-currency default bet from `/wallet/authenticate` (the bridge surfaces it as * `config.stake.defaultBetLevel`). Stake requires the selector to start here on every entry. */ defaultBet?: number; /** Hard per-currency stake bounds from `/wallet/authenticate` (`config.stake.minBet/maxBet`, * major units). The bridge REJECTS any bet outside them before `/bet/play`, so the resolved * default is clamped into this window — see `resolveDefaultBet`. */ minBet?: number; maxBet?: number; } /** The subset of Stake's jurisdiction flags the shell can enforce via `ShellFeatures`. */ interface JurisdictionRestrictions { /** No turbo at all → `features.turbo = 0`. */ disabledTurbo?: boolean; /** Basic turbo allowed, but no super-turbo → cap `features.turbo` at 1. */ disabledSuperTurbo?: boolean; /** No spacebar quick-spin → `features.spacebar = false`. */ disabledSpacebar?: boolean; /** No autoplay → `features.autoplay = null`. */ disabledAutoplay?: boolean; /** No buy-feature → `features.buyBonus = false`. */ disabledBuyFeature?: boolean; } /** Total stake for an action = bet × the action's cost multiplier (1 for a base spin; e.g. 100 for * a buy bonus). The host uses this to block a play the balance can't cover. */ declare function stakeForAction(model: GameModel, action: string, bet: number): number; /** Pure: assemble the shell config (sans mount target) from the model + runtime context * (currency/balance/language/mode). The host adds `app` at the call site. */ declare function buildShellConfig(opts: SlotShellOptions, model: GameModel, runtime: ShellRuntime): Omit & { gameInfo: GameInfoContent; }; /** * Per-game persistence of the player's shell preferences — turbo level and every menu value * (sound, music, sfx, spacebar, and any custom row) — in `localStorage`. * * Opt-in via `createSlotGame({ persistSettings })`. A game that doesn't ask for it behaves exactly * as before: nothing is read, nothing is written, no storage is touched. * * Scoped per game, because these are per-game preferences: a player who mutes one slot has not * asked to mute the next one. The key is `e8::settings`. */ interface PersistSettingsOptions { /** Key namespace. Defaults to the game id, so two games never share a preference set. */ key?: string; /** Backing store. Defaults to `window.localStorage`; pass one in tests, or `null` to disable. */ storage?: Storage | null; } /** What we keep. `menu` covers presets and custom rows alike — `setMenuValue` routes both. */ interface StoredSettings { turbo?: number; menu?: Record; } declare function settingsKey(gameId: string): string; /** * Read and VALIDATE. Whatever is in `localStorage` is player-writable — anyone can open devtools * and put a string, an object, or `turbo: 99` in there. It is parsed as untrusted input: a bad * field is dropped, not coerced, and a bad blob yields nothing at all. */ declare function readSettings(storage: Storage | null, key: string): StoredSettings; /** * Restore the stored preferences onto a freshly created shell, then keep them in step. * * Returns an unsubscribe function. * * Two things are deliberate here: * * - **`maxTurbo` clamps the restored level.** `features.turbo` is the CEILING the shell offers * (`applyJurisdiction` lowers it where super-turbo or turbo is forbidden), while `state.turbo` * is what the player currently has. Restoring a saved 3 into a jurisdiction capped at 1 would * hand back exactly what the restriction took away, so the stored value is clamped, never * trusted. * - **Subscription happens AFTER restoring.** `setMenuValue` emits `settingChange`, so subscribing * first would have the restore write back what it just read. (`setTurbo` is quiet — only the * bar tap emits `turboChange` — which is why restoring the level can't echo. Do not "fix" that * asymmetry without moving this subscription.) */ declare function attachSettingsStore(shell: Shell, opts: { key: string; storage?: Storage | null; maxTurbo?: number; }): () => void; /** * Free-spins counter for the shell readout (current / total / totalWin), with RETRIGGER support. * * A bonus awards an initial pool of spins; a retrigger mid-bonus awards MORE. The full-event book * already contains every segment (incl. retriggered spins), but the player-facing counter must grow * dynamically: start at the awarded total, and each spin that awards extra bumps the total. The * remaining spins the shell shows are `total - current`. * * Example (the canonical case): enter(10) → 0/10. After two spins → 2/10. The third spin retriggers * +5 → 3/15 (i.e. 12 remaining). `awarded` per spin is the spins granted by THAT spin (0 normally, * the retrigger amount on a retrigger). Pure + unit-testable; the host feeds it `result.freeSpins`. */ interface FreeSpinsView { /** Free spins played so far (1-based once spinning). */ current: number; /** Total free spins awarded so far (initial + every retrigger). */ total: number; /** Cumulative bonus win (the host passes the round's cumulative totalWin). */ totalWin: number; } /** Turns a bonus segment into the bar readout for games whose bonus ISN'T a plain free-spins * counter (adventure, hold-and-spin, respins). The shell shows a host-driven hero + Total Win in * ANY bonus; this only customises the label + counter VALUE. Omit `bonus` entirely and the host * falls back to the free-spins default (label 'Free spins', value current/total, retrigger-aware). */ interface BonusReadoutConfig { /** Bar label (localized by the shell, so a game i18n entry is honoured). A string, or a function * of the current mode (e.g. `m => m === 'ADVENTURE' ? 'Adventure' : 'Free spins'`). * Default: 'Free spins'. */ label?: string | ((mode: string) => string); /** Format the counter VALUE string from the settled segment. `view` is the host's default * free-spins counter (current/total, retrigger-aware) — use it for the common case, or ignore it * and read your own fields off `result` (respins left, coins collected, a multiplier). * Default: `view.current == null ? String(view.total) : `${view.current} / ${view.total}``. */ readout?: (result: T, ctx: { view: FreeSpinsView; mode: string; }) => string; } interface StakeIntegration { /** The game's BookAdapter (or its module). modeMap + gameId come from the model. */ adapter: BookAdapter | AdapterModule; } /** The live Artube bridge, structurally. Kept minimal so game-engine never has to import * `@energy8platform/artube-bridge` — see `ArtubeIntegration.load`. */ interface ArtubeBridgeLike { /** Resolves once the backend has sent its init. */ ready(): Promise; destroy(): void; } /** What `ArtubeIntegration.load()` must resolve to. `@energy8platform/artube-bridge`'s own entry * satisfies this structurally, so `load: () => import('@energy8platform/artube-bridge')` typechecks * with no adapter. The launch CLASSIFIER comes from the same module as the bridge: one import, and * no chicken-and-egg where the host would have to load something to decide whether to load. */ interface ArtubeModule { classifyArtubeLaunch: (url: string) => 'artube' | 'blocked' | 'offline'; ArtubeBridge: new (options: { devMode?: boolean; gameId?: string; url?: string; apiBase?: string; demoBalance?: number; }) => ArtubeBridgeLike; } /** Artube host integration. There is no per-game artifact to pass: the game's own BACKEND * (`@energy8platform/artube-server`) owns the round shape, so the bridge is a pure protocol * translator — `load` is the only required field. `gameId` comes from the model, the launch params * from the URL. */ interface ArtubeIntegration { /** REQUIRED. How the host reaches the bridge: * `load: () => import('@energy8platform/artube-bridge')`. * * Why the GAME supplies this instead of the host importing the package itself: a bare * `import('@energy8platform/artube-bridge')` inside this always-shipped `/host` entry is resolved * STATICALLY by every bundler, so it would have to resolve in games that never opted into Artube * and therefore never installed the package — esbuild/webpack fail the build outright, and Vite * silently substitutes an empty module (it stubs uninstalled optional peers), which is worse: * the game builds and then dies at runtime. With the loader, the specifier only ever appears in * the bundle of a game that took the dependency. * * The loaded chunk is fetched on every launch of such a game (the classifier lives in it), which * is the point of the split: only Artube-targeted builds pay for it. */ load: () => Promise; /** Starting virtual balance for a DEMO session (the platform doesn't keep one — the bridge does, * client-side). Default: the backend's own configured demo balance. Ignored for real sessions. */ demoBalance?: number; /** Base address of the game's backend; the bridge appends `/api/ws`. Default is the launch page's * own PATH RE-ROOTED UNDER `/api`: Artube serves a game's static bundle from a CDN bucket at * `//` and mounts its backend on a separate proxy route at `/api//**`, so the page at * `https://host/artube-xxx/` talks to `wss://host/api/artube-xxx/api/ws`. At the root (local dev, * or a root-mounted deployment) there is nothing to re-root and the default is just the origin. * Override for local dev against a backend on another port — prefer proxying `/api` from the dev * server (what `BUILD_TARGET=artube` does) so dev matches — or to state the address outright if a * deployment is ever mounted differently than the above. */ apiBase?: string; } /** One scene registered with the host: a key + its constructor. The list order matters — the * first scene that is eligible for the current launch mode is the start scene (unless an explicit * `startScene` overrides it). */ interface SceneRegistration { key: string; scene: SceneConstructor; /** Skip this scene as a START scene on a replay launch (e.g. an intro). It is still registered * (other scenes can `goto` it), it just isn't auto-started — the first non-skipped scene is. */ skipOnReplay?: boolean; } /** Navigation injected into the start data of EVERY scene the host registers. * Any scene (intro, game, …) reads it from its `onEnter(data)` to navigate. */ interface SceneNavData { /** Switch to another registered scene by key. */ goto: (key: string, data?: unknown) => void; } interface CreateSlotGameOptions { model: GameModel; /** REQUIRED: maps the raw play result into the game's typed result. The host calls it on every play. */ normalize: SlotResultNormalizer; /** ALL scenes the game uses, registered up front, in order. The first scene eligible for the * launch mode is the start scene — so a replay launch skips any leading `skipOnReplay` scene * (e.g. the intro) and starts directly on the game scene. */ scenes: SceneRegistration[]; /** Optional explicit start scene key. Defaults to the first scene eligible for the launch mode * (honoured only when that scene is itself eligible; otherwise the first eligible one wins). */ startScene?: string; /** Start data passed to the start scene's `onEnter` (merged with the injected `goto`). */ startData?: unknown; manifest: AssetManifest; container?: HTMLElement | string; design?: { width: number; height: number; }; scaleMode?: ScaleMode; orientation?: Orientation; loading?: LoadingScreenConfig; audio?: AudioConfig; pixi?: Partial; fonts?: string[]; textureDefaults?: boolean; dev?: boolean; stake?: StakeIntegration; /** Run on Artube when the launch URL says so (`?sessionId=…`): * `artube: { load: () => import('@energy8platform/artube-bridge') }`. See `ArtubeIntegration` * for why the game supplies the loader. Build with `BUILD_TARGET=artube`. */ artube?: ArtubeIntegration; shell?: SlotShellOptions; /** Customise the bonus bar readout for games whose bonus isn't plain free spins (adventure, * hold-and-spin, respins). Omit for the free-spins default. See `BonusReadoutConfig`. */ bonus?: BonusReadoutConfig; /** Override how the control-bar shell is built. The host resolves the full shell config (theme, * features, gameInfo, currency, balance) and the Pixi mount (`app`/`parent`) and hands it to this * factory; return any `Shell` — e.g. `createShell({ renderer: new MyRenderer(...), ...config })` * to plug a custom renderer while the shell core still drives bet/balance/overlays. A custom * renderer can ignore `app`/`parent` and mount elsewhere (a DOM overlay, another canvas). * Default: the built-in Pixi shell (`createPixiShell`). */ shellFactory?: ShellFactory; /** Remember the player's turbo level and menu values (sound, music, sfx, Spacebar-to-spin, custom * rows) in `localStorage`, scoped to this game. Off by default — a game that doesn't ask touches * no storage at all. `true` keys on the game id; pass an object to set the key or supply the * store. Restored values are still subject to jurisdiction limits, and unreadable or tampered * storage degrades to "don't persist" rather than failing the boot. */ persistSettings?: boolean | PersistSettingsOptions; /** Double-tap on the play area to skip the current spin animation. Default `true`. Set `false` * to disable the gesture (e.g. games where a tap means something else). */ skipGesture?: boolean; onFatalError?: (message: string) => void; } /** Builds the shell the host drives. Receives the fully-resolved Pixi shell config (a custom * renderer may ignore the `app`/`parent` mount fields). Must return a `Shell`. */ type ShellFactory = (config: PixiShellConfig) => Shell; interface SlotGameHandle { game: GameApplication; stakeBridge: StakeBridge | null; /** The live Artube bridge — non-null only on a real Artube launch (`opts.artube` + `?sessionId=…`). */ artubeBridge: ArtubeBridgeLike | null; shell: Shell | null; } /** * One-call slot bootstrap: preboot → (optional Stake / Artube bridge) → GameApplication * → register scene → start. Collapses the per-game main.ts boilerplate. * * Not unit-tested: GameApplication.init() drives Pixi, which hangs in headless * environments. The pure helpers it sequences are unit-tested individually. */ declare function createSlotGame(opts: CreateSlotGameOptions): Promise; /** Reverse the model's modeMap (Stake bet mode → SDK action key) for replay labelling/cost. */ declare function resolveReplayBonusId(model: GameModel, stakeMode: string): string; /** * Pick the scene to START with, given the registered scenes (in order) and the launch mode. * * Rules: * - On a replay launch, scenes flagged `skipOnReplay` are NOT eligible to start (they stay * registered for `goto`, they just aren't auto-started) — so a leading intro is skipped and * the game scene starts directly. * - An explicit `startScene` wins, but only if that scene is itself eligible; otherwise the first * eligible scene wins. * - Falls back to the first scene unconditionally if nothing is eligible (degenerate config). */ declare function resolveStartScene(scenes: SceneRegistration[], isReplay: boolean, explicitStart?: string): string; /** * Gate for the PROGRESSIVE (cascade / tumble) WIN readout. * * By default the WIN readout is entirely host-driven: cleared to 0 when a segment starts, set to * that segment's win once `onSpin` resolves. A cascade game pays in steps, so its scene wants the * number to climb WHILE the segment presents — `api.shell.reportWin(amountSoFar)`. * * The gate keeps the host in charge: * - reports are honoured ONLY between `open()` (segment start, WIN already cleared) and `close()` * (the host paints the segment's final value) — a scene still animating after an abort, or one * reporting from a mode transition, can't overwrite the host's number, * - `amountSoFar` is ABSOLUTE (the segment's win up to now, not the step delta), so a re-report or * a collapse-to-final on skip is idempotent. Reports should be non-decreasing within a segment; * a lower value counts the readout back DOWN, * - garbage (NaN / Infinity) is dropped and negatives clamp to 0, so a math bug can't paint "NaN" * into the bar. * * Pure + unit-testable: the shell paint is injected. */ interface WinReportOptions { /** `false` snaps instead of counting up (e.g. collapsing to the final value on skip). */ animate?: boolean; /** Count-up length in ms (shell default 450) — pass the cascade step's length. */ durationMs?: number; } interface WinReporter { /** Scene-facing (`api.shell.reportWin`): the win accumulated by the presenting segment so far. */ report(amountSoFar: number, opts?: WinReportOptions): void; /** A segment starts presenting — the scene may now grow WIN. */ open(): void; /** The host takes the readout back (final value, round end, or an error). */ close(): void; /** True while reports are honoured. */ readonly accepting: boolean; } declare function createWinReporter(paint: (amount: number, opts?: WinReportOptions) => void): WinReporter; /** Everything a scene needs to render one segment. The host builds it per segment. */ interface RenderContext { /** Bet for this round (major units). Stable for the whole round. */ bet: number; /** Trigger action in the game's own vocabulary ('spin' | 'ante' | 'buy_bonus' | …). */ action: string; /** Stake bet-mode of the round ('BASE' | 'ANTE' | 'BONUS' | …). */ mode: string; /** Currency-aware money formatter. */ formatAmount(value: number): string; /** LIVE turbo level (0 = off, 1..3 = escalating speed). Read at access. */ readonly turbo: number; /** Only meaningful in `onEnterMode`: true when RETURNING to a suspended parent bonus after a * nested sub-bonus finished (e.g. back to free spins after an adventure), false on a fresh * entry. Lets a scene restore vs rebuild. Undefined outside `onEnterMode`. */ resumed?: boolean; /** Aborted when the player skips this segment (double-tap). The scene's async pacing can race or * cancel on it; on abort the scene must collapse to the segment's final visual state. */ signal: AbortSignal; } /** Playback-only audio handle. Volume/mute are shell settings → host, never the scene. */ interface SceneAudio { play(alias: string, opts?: { volume?: number; loop?: boolean; speed?: number; }): void; playMusic(alias: string, fadeMs?: number): void; stopMusic(): void; duck(factor: number): void; unduck(): void; } interface OverlayShowOptions { /** Draw the overlay content into `container` (sized to the canvas). */ build(container: Container, size: { width: number; height: number; }): void; /** Re-layout the same `container` when the canvas resizes while the overlay is open. * Receives the content container from `build` and the new size. Optional — omit for * overlays that self-center or don't care about resize. */ onResize?(container: Container, size: { width: number; height: number; }): void; /** Auto-close after N ms (combine with closeOn — whichever fires first). */ autoCloseMs?: number; /** Dismiss on a single tap. Default 'tap'. Set false to require an explicit close(). */ closeOn?: 'tap' | false; /** Optional host-drawn backdrop alpha (0..1). Default: none (game draws its own). */ dim?: number; } /** Single host-owned layer above scene + shell. Eats pointer events so shell controls are * unreachable while open. */ interface SceneOverlay { /** Resolves when the overlay closes. Rejects if one is already open. */ show(opts: OverlayShowOptions): Promise; close(): void; } interface SceneShell { /** Live insets (px). `bottom` = the shell bar height; read inside onResize. */ readonly safeArea: { top: number; right: number; bottom: number; left: number; }; /** * Grow the shell's WIN readout WHILE a segment presents — for cascade/tumble games that pay in * steps instead of one lump at the end. * * `amountSoFar` is ABSOLUTE: the win accumulated by this segment up to now (not the step's * delta), so a re-report, a skip, or an aborted step can just restate the truth. The host owns * the readout around it — it clears WIN to 0 when the segment starts and sets the segment's * final value once `onSpin` resolves (counting up from your last report, so matching numbers * produce no jump). In a bonus this moves WIN only; the Total Win accumulator still lands once * per segment. * * Only honoured while a segment is presenting (inside `onSpin`); calls from anywhere else are * ignored, so a scene still ticking after an abort can't overwrite the host's final number. * * `durationMs` sets this count-up's length (default 450ms) — pass your step length (or a shorter * one under turbo) so each count-up finishes before the next step lands. `{ animate: false }` * snaps, e.g. when collapsing to the final value on skip. */ reportWin(amountSoFar: number, opts?: WinReportOptions): void; } interface AutoplaySceneState { running: boolean; remaining: number; } /** Stable capabilities injected once via onCreate. */ interface SceneApi { audio: SceneAudio; overlay: SceneOverlay; shell: SceneShell; formatAmount(value: number): string; readonly bet: number; readonly mode: string; readonly turbo: number; } /** The contract a slot scene implements. The HOST owns the play→present→ack→drain loop and the * shell; the scene only renders + reacts. The core spin-lifecycle hooks are REQUIRED (implement * them — empty bodies are fine where a game has nothing to do); the incidental reactions below * stay optional. */ interface SlotSceneController { /** Injected ONCE before the first round — capabilities, subscriptions, one-time setup. */ onCreate(api: SceneApi): void; /** Fires once per round when the player presses spin (before the network result). */ onSpinStart(): void; /** The round never got a result: its first play failed (a dropped connection, a refused bet) * after `onSpinStart`. Stop whatever `onSpinStart` set going — the reels spin forever otherwise. * `onSpinEnd` does NOT follow. Optional; the host shows the error itself. */ onSpinAborted?(err: unknown): void; /** Render ONE segment (a spin or one free spin). Await your own pacing. */ onSpin(result: T, ctx: RenderContext): Promise; /** Fires when a bonus LEVEL begins. With nested bonuses this fires once per level (free spins, * then adventure, …) — check `ctx.mode` for which. `ctx.resumed` is true when returning to a * suspended parent after a nested sub-bonus finished, so a scene can restore instead of rebuild. * A single-bonus round fires it exactly once (as before). */ onEnterMode(result: T, ctx: RenderContext): Promise; /** Fires when a bonus LEVEL ends — popping a nested sub-bonus back to its parent, or unwinding * the last level back to BASE. `ctx.mode` is the level being left. Fires once per level. */ onExitMode(result: T, ctx: RenderContext): Promise; /** Fires once per round after the full drain (controls unlocked). */ onSpinEnd(result: T, ctx: RenderContext): void; /** Shell events (may fire while idle) — optional. */ onBetChanged?(bet: number): void; onTurboChanged?(level: number): void; onAutoplayChanged?(state: AutoplaySceneState): void; /** Double-tap skip during an active onSpin (gated by the skipGesture setting). */ onSkip?(): void; /** Tab focus lost / regained. */ onPause?(): void; onResume?(): void; } export { attachSettingsStore, buildShellConfig, createSlotGame, createWinReporter, readSettings, resolveReplayBonusId, resolveStartScene, settingsKey, stakeForAction }; export type { ArtubeBridgeLike, ArtubeIntegration, ArtubeModule, AutoplaySceneState, CreateSlotGameOptions, OverlayShowOptions, PersistSettingsOptions, RenderContext, SceneApi, SceneAudio, SceneNavData, SceneOverlay, SceneRegistration, SceneShell, ShellFactory, SlotGameHandle, SlotSceneController, SlotShellOptions, StakeIntegration, StoredSettings, WinReportOptions, WinReporter };