import { Container, Filter, Texture, Application } from 'pixi.js'; import { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result'; type Orientation = 'landscape' | 'portrait'; interface SceneDoc { version: 1; /** Stable document id (usually the game id). */ id: string; /** * Another scene this one builds on. The resolved doc is base ⊕ this, merged by node id, * so a per-mode scene carries only its delta instead of a copy of the whole board. * Resolved by `resolveExtends` before the doc reaches the engine. */ extends?: string; /** Design space the layout rules are authored in (viewport arrives in these units). */ design: { width: number; height: number; }; root: SceneNode; } interface SceneNode { /** Stable id — flow, code and patches address the node by it. Unique per doc. */ id: string; /** Node kind resolved through the registry (built-in or plugin-contributed). */ type: string; /** Human label for the outliner; never used for addressing. */ name?: string; /** Kind-specific properties (validated by the contribution's schema). */ props?: Record; layout?: LayoutRule; /** Per-orientation override merged over the base node before layout. */ responsive?: Partial>; /** * Named variants toggled at runtime (flow `setState`, editor, code) — e.g. a reel * frame's `anticipation` / `bonus` looks. A state is an override, not a new node. */ states?: Record; /** Id of another node in this doc used as this node's mask. */ mask?: string; /** Filters applied to the node's view; kinds resolve through the registry (core: blur). */ filters?: FilterSpec[]; /** * Tiny condition over runtime vars, e.g. `"mode === 'free_spins'"`. Supported form: * ` ` with `=== !== >= <= > <`. Anything richer belongs in flow/code. */ visibleWhen?: string; /** Entry in the shared anchor vocabulary flow/animation use to address the node. */ anchorName?: string; /** Order = z-order. */ children?: SceneNode[]; } /** A filter instance: kind + kind-specific params (validated by the contribution). */ interface FilterSpec { kind: string; [param: string]: unknown; } /** Partial node applied on top of the base for an orientation or a named state. */ interface NodeOverride { layout?: LayoutRule; props?: Record; visible?: boolean; } interface LayoutCommon { /** Content anchor [ax, ay] in 0..1 (applied via pivot on the node's natural size). */ anchor?: [number, number]; /** Final hand-tuned nudge in design px, applied after the rule resolves. */ pxNudge?: { x?: number; y?: number; }; /** Static rotation in radians (choreographed rotation belongs to flow/animation). */ rotation?: number; /** * `'viewport'` resolves this rule against the LIVE viewport even when the node lives * inside a nested coordinate space — the engine converts the result into the space's * coordinates. The paper-duel logo idiom: screen-pinned, but rendered at a diorama * z-depth. */ space?: 'viewport'; } /** Fixed design-space coordinates. */ interface AbsoluteLayout extends LayoutCommon { mode: 'absolute'; x: number; y: number; scale?: number; rotation?: number; } /** * Position as a fraction of the live viewport. Sizing, strongest first: * `widthFrac`/`heightFrac` size the node to that fraction of the viewport (contain when * both are given) — the "frame is 92% of the screen in portrait" idiom; otherwise * `scaleWith` multiplies `scale` by the viewport/design ratio; otherwise plain `scale`. */ interface ViewportFractionLayout extends LayoutCommon { mode: 'viewport-fraction'; xFrac: number; yFrac: number; scale?: number; /** Multiply `scale` by viewport/design ratio: width | height | min | max. */ scaleWith?: 'width' | 'height' | 'min' | 'max'; /** Size the node to this fraction of the viewport width (scale derived from natural size). */ widthFrac?: number; /** Size the node to this fraction of the viewport height. */ heightFrac?: number; /** With both fracs: contain (default) keeps aspect, stretch distorts to fill exactly. */ fit?: 'contain' | 'stretch'; } /** * Center-crop fill of the current space (backgrounds, diorama layers). `scale` multiplies * the computed cover scale (the paper-duel `e` idiom); `pxNudge` offsets from center. */ interface CoverLayout extends LayoutCommon { mode: 'cover'; scale?: number; } /** * Place inside another node's laid-out rect using fractions of that rect. * `use: 'inner'` reads the target's declared `props.inner` fractions (reel frames * declare where their hollow is) instead of spelling the fractions twice. * When a box (`wFrac`/`hFrac` or `use`) is given the node is contain-fitted into it * (`fit: 'stretch'` distorts instead). */ interface FrameFractionLayout extends LayoutCommon { mode: 'frame-fraction'; frame: string; xFrac?: number; yFrac?: number; wFrac?: number; hFrac?: number; use?: 'inner'; fit?: 'contain' | 'stretch'; } /** * Center of a reel-grid cell (badges, per-cell VFX slots). `grid` defaults to the sole * reelGrid. By default the node scales with the grid (sizes authored in grid-design px); * `scaleWithGrid: false` keeps it in absolute design units. */ interface GridCellLayout extends LayoutCommon { mode: 'grid-cell'; grid?: string; col: number; row: number; scale?: number; scaleWithGrid?: boolean; } /** Pin to an edge/center of another node's laid-out rect. */ interface PinLayout extends LayoutCommon { mode: 'pin'; to: string; edge: PinEdge; offset?: [number, number]; scale?: number; } type PinEdge = 'top-left' | 'top-center' | 'top-right' | 'center-left' | 'center' | 'center-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'; type LayoutRule = AbsoluteLayout | ViewportFractionLayout | CoverLayout | FrameFractionLayout | GridCellLayout | PinLayout; type ScenePatch = { op: 'set-props'; id: string; props: Record; } /** * Targets `orientation` (responsive) when given, else the base rule. `layout: null` clears * that slot — which is how undo removes an override the edit created. */ | { op: 'set-layout'; id: string; layout: LayoutRule | null; orientation?: Orientation; } /** Rename the outliner label. `name` is presentation only — addressing is always by id. */ | { op: 'set-name'; id: string; name: string | undefined; } | { op: 'set-state'; id: string; state: string | null; } | { op: 'set-var'; name: string; value: unknown; } /** Insert `node` under `parent` at `index` (default: append). Ids must be unused. */ | { op: 'add-node'; parent: string; node: SceneNode; index?: number; } | { op: 'remove-node'; id: string; } /** Reparent/reorder `id` under `parent` at `index` (same parent = pure reorder). */ | { op: 'move-node'; id: string; parent: string; index?: number; } /** Deep-copy `id` (all descendant ids suffixed to stay unique) as its next sibling. */ | { op: 'duplicate-node'; id: string; }; /** * Outcome of applying a patch. Rejections used to be console-only warnings inside the * game, so an editor (or agent) saw "ok" for a patch that never applied — the reason now * travels back to the caller. */ type PatchResult = { ok: true; /** * The patch that undoes this one. Applying it restores the pre-patch state exactly, * which is what the editor's undo stack replays. Absent only when the op had no effect. */ inverse?: ScenePatch; } | { ok: false; error: string; }; /** Outliner row — the tree the inspector and the agent both read. */ interface OutlineNode { id: string; type: string; name?: string; anchorName?: string; visible: boolean; state: string | null; children: OutlineNode[]; } /** A node in an extending doc may delete an inherited node instead of overriding it. */ declare const REMOVE_MARKER = "$remove"; interface ExtendOptions { /** Resolve a parent reference (a path, an id — whatever the host uses) to its doc. */ load: (ref: string) => SceneDoc | undefined; /** Guards against a cycle; also the practical depth limit for a chain of modes. */ maxDepth?: number; } /** * Resolve a doc's `extends` chain into a single self-contained doc. Docs without `extends` * are returned as-is (cloned), so this is safe to call on everything. */ declare function resolveExtends(doc: SceneDoc, opts: ExtendOptions): SceneDoc; /** * The patches that turn `from` into `to`. Used to switch game modes without rebuilding: a * node both docs share and agree on is never touched, so the reel system, its symbols and * any running animation survive the transition. * * Order matters — removals first (ids can be reused), then additions parent-before-child, * then property updates. */ declare function diffDocs(from: SceneDoc, to: SceneDoc): ScenePatch[]; type FieldKind = 'number' | 'text' | 'boolean' | 'color' /** One of `options`. */ | 'enum' /** A texture/audio alias from the game's asset manifest — the editor offers a picker. */ | 'asset' /** The id of another node in this doc — the editor offers the node list. */ | 'nodeRef' /** Nested record described by `fields`. */ | 'object' /** Anything else: edited as raw JSON. */ | 'json'; interface FieldSchema { kind: FieldKind; /** Human label (defaults to the key). */ label?: string; /** What the field does — inspector tooltip AND the agent's documentation. */ doc?: string; /** * Value the runtime uses when the field is absent. The inspector shows it as the * effective value so "not set" never looks like "zero". */ default?: unknown; /** `enum`: the allowed values. */ options?: readonly string[]; /** `number`: bounds and input step. */ min?: number; max?: number; step?: number; /** `asset`: which kind of media this field accepts (filters the picker). */ accept?: 'image' | 'audio' | 'spritesheet'; /** `nodeRef`: restrict the picker to nodes of this type (e.g. `reelFrame`). */ ofType?: string; /** `object`: the nested fields. */ fields?: PropsSchema; /** Lower sorts first in the inspector; unordered fields follow, alphabetically. */ order?: number; } /** A node type's (or prefab's) authorable props, keyed by prop name. */ type PropsSchema = Record; /** Shared field definitions, so every kind describes `alpha` the same way. */ declare const ALPHA_FIELD: FieldSchema; /** Fractions of a texture (0..1), used for both `region` (crop) and `inner` (frame hollow). */ declare const fracBoxFields: (doc: string) => FieldSchema; /** * Flatten a schema into dot-path rows, pairing each with the value currently in `props` * (`undefined` when unset). Nested `object` fields expand to `parent.child`, matching how * layout rules are already flattened for the inspector. */ interface SchemaFieldRow { /** Dot-path into props ('src', 'sheet.alias'). */ path: string; label: string; schema: FieldSchema; /** Current value, or `undefined` when the prop is not set. */ value: unknown; /** True when the value comes from the schema default rather than the doc. */ unset: boolean; } declare function schemaFieldRows(schema: PropsSchema | undefined, props: Record): SchemaFieldRow[]; /** Props present on the node that the schema doesn't describe (plugin extras, hand-written). */ declare function extraPropKeys(schema: PropsSchema | undefined, props: Record): string[]; interface Rect { x: number; y: number; width: number; height: number; } interface Size { width: number; height: number; } /** Resolved transform for a node, in parent (scene) coordinates. */ interface Placement { x: number; y: number; scaleX: number; scaleY: number; rotation: number; /** Content anchor in 0..1 — the engine applies it via pivot over the natural size. */ anchor: [number, number]; /** Present when the rule targeted a box (frame-fraction with a width/height). */ box?: Size; } /** Layout context the engine provides while resolving a pass. */ interface LayoutContext { viewport: Size; design: Size; /** Laid-out AABB of a node (undefined until that node resolved this pass). */ bounds(id: string): Rect | undefined; /** Declared `props.inner` fractions of a node mapped onto its laid-out bounds. */ innerRect(id: string): Rect | undefined; /** Scene-space center of a grid cell; `id` undefined targets the doc's sole reelGrid. */ gridCell(id: string | undefined, col: number, row: number): { x: number; y: number; } | undefined; /** World scale of a grid node (for grid-cell nodes that scale with the grid). */ gridScale(id: string | undefined): { x: number; y: number; } | undefined; } type LayoutResolution = { placement: Placement; } | { waitingFor: string; }; declare function orientationOf(width: number, height: number, portraitFactor?: number): Orientation; /** Base node + orientation override + active state override (state wins), for one frame. */ declare function effectiveNode(node: SceneNode, orientation: Orientation, state: string | null): { layout?: LayoutRule; props: Record; visible: boolean; }; /** Which node id (if any) must be laid out before this rule can resolve. */ declare function dependencyOf(rule: LayoutRule | undefined): string | undefined; declare function edgePoint(rect: Rect, edge: PinEdge): { x: number; y: number; }; /** AABB of a placement over a natural content size (rotation ignored — pin targets should not rotate). */ declare function placementBounds(p: Placement, natural: Size): Rect; /** * Resolve one rule. Returns `waitingFor` when a referenced node has no bounds yet — the * engine re-queues the node for the next pass (bounded; see engine). */ declare function resolveLayoutRule(rule: LayoutRule, natural: Size, ctx: LayoutContext): LayoutResolution; /** * Evaluate a `visibleWhen` micro-expression: ` ` with * `=== !== >= <= > <`. Unknown shapes evaluate to visible (with the engine warning once) — * richer conditions belong in flow or code, not in the scene doc. */ declare function evalVisibleWhen(expr: string, vars: Record): boolean | undefined; /** A symbol's visual. Any Container; optionally implements the lifecycle the cell drives. */ interface SymbolView extends Container { playIdle?(): void; playWin?(): Promise; showStatic?(): void; /** Resize the symbol to the given cell size in pixels (square scalar or rectangular). */ resize?(size: number | { width: number; height: number; }): void; } /** Game-supplied factory: build the view for a symbol id (sprite / layered sprites / Spine / composite). */ type SymbolResolver = (symbolId: string) => SymbolView | null; 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; } interface NodeCreateContext { registry: SceneRegistry; /** Resolve a texture alias (defaults to Pixi `Texture.from`; tests/labs inject). */ texture(alias: string): Texture; /** Game-supplied symbol factory — required by the `reelGrid` kind. */ resolveSymbol?: SymbolResolver; log?: (msg: string) => void; } /** Live counterpart of one scene node, produced by its contribution. */ interface NodeInstance { view: Container; /** Natural (unscaled) content size — drives anchor, cover and box fitting. */ measure(): Size; /** Apply a props patch in place; absent → the engine rebuilds the node instead. */ applyProps?(props: Record): void; /** Scene-local center of a grid cell (reel-grid kinds only; enables `grid-cell` layout). */ gridCell?(col: number, row: number): { x: number; y: number; }; destroy?(): void; } /** Skeleton a palette/agent uses to spawn a fresh node of this kind (id is assigned by the caller). */ interface NodeDefaults { /** Human label for the palette button (defaults to the kind). */ label?: string; name?: string; props?: Record; layout?: SceneNode['layout']; } interface NodeTypeContribution { kind: string; /** Authorable `props` — drives the inspector's controls, validation and the agent's docs. */ schema?: PropsSchema; /** 3–10 lines for the agent: when to use this kind, patterns, anti-patterns. */ agentDoc?: string; /** Palette skeleton — omit to hide the kind from the add-menu (e.g. `prefab` dispatch). */ defaults?: NodeDefaults; create(node: SceneNode, ctx: NodeCreateContext): NodeInstance; } /** Reusable game component (meters, HUDs) addressed as `{type:'prefab', props:{prefab:'name'}}`. */ interface PrefabContribution { name: string; schema?: PropsSchema; agentDoc?: string; /** Palette skeleton (props merged onto `{prefab:name}`, plus a starter layout). */ defaults?: NodeDefaults; create(props: Record, ctx: NodeCreateContext): NodeInstance; } /** A filter kind: `{kind:'blur', strength: 3}` in a node's `filters` resolves through this. */ interface FilterKindContribution { kind: string; schema?: PropsSchema; agentDoc?: string; create(spec: FilterSpec): Filter; } interface ScenePlugin { id: string; nodeTypes?: NodeTypeContribution[]; prefabs?: PrefabContribution[]; filterKinds?: FilterKindContribution[]; } /** One palette entry — a spawnable node kind or prefab, with its skeleton. */ interface PaletteEntry { kind: string; label: string; /** 'prefab' entries spawn `{type:'prefab', props:{prefab:kind}}`; others spawn `{type:kind}`. */ isPrefab: boolean; agentDoc?: string; defaults: NodeDefaults; } interface SceneRegistry { nodeType(kind: string): NodeTypeContribution | undefined; prefab(name: string): PrefabContribution | undefined; filterKind(kind: string): FilterKindContribution | undefined; kinds(): string[]; prefabNames(): string[]; filterKindNames(): string[]; /** Spawnable palette: node kinds with `defaults` + all registered prefabs. */ palette(): PaletteEntry[]; } /** Merge built-ins with plugin contributions; later plugins may override earlier kinds. */ declare function createSceneRegistry(plugins?: ScenePlugin[], builtins?: NodeTypeContribution[], builtinFilters?: FilterKindContribution[]): SceneRegistry; /** Core filter kinds; heavier looks (bloom etc.) arrive as plugins carrying their deps. */ declare const BUILTIN_FILTER_KINDS: FilterKindContribution[]; declare const BUILTIN_NODE_TYPES: NodeTypeContribution[]; interface SceneValidationError { nodeId?: string; message: string; } declare function validateSceneDoc(doc: SceneDoc, registry: SceneRegistry): SceneValidationError[]; interface CreateSceneOptions { /** Extra contributions (official + game-local plugins) merged over the built-ins. */ plugins?: ScenePlugin[]; /** Texture alias resolver; defaults to Pixi's `Texture.from`. */ texture?: (alias: string) => Texture; /** Symbol factory for `reelGrid` nodes. */ resolveSymbol?: SymbolResolver; /** Initial runtime vars driving `visibleWhen` (e.g. { mode: 'base' }). */ vars?: Record; /** Treat near-square viewports as portrait below this width/height factor (default 1). */ portraitFactor?: number; log?: (msg: string) => void; } interface SceneHandle { view: Container; node(id: string): Container | undefined; instance(id: string): NodeInstance | undefined; anchor(name: string): Container | undefined; tree(): OutlineNode; layout(width: number, height: number): void; setVar(name: string, value: unknown): void; setState(id: string, state: string | null): void; /** Apply an edit. Returns `{ok:false, error}` when the patch was rejected. */ patch(patch: ScenePatch): PatchResult; /** The live doc (mutated by patches) — serialize this to persist the scene. */ doc(): SceneDoc; /** Spawnable palette (node kinds with defaults + prefabs) — feeds the editor add-menu. */ palette(): PaletteEntry[]; /** * Props schemas by node kind (prefabs keyed `prefab:`) — what the inspector renders * its controls from, and what an agent reads as the authoring contract. */ schemas(): Record; /** * Re-apply every node's doc-effective props, wiping transient presentation writes * (flow tweens/count-ups). "Settle = the doc" — scrub replays call this first. */ resetProps(): void; destroy(): void; } declare function createSceneFromDoc(doc: SceneDoc, opts?: CreateSceneOptions): SceneHandle; interface InnerFracs { left: number; top: number; width: number; height: number; } type InnerEdge = 'left' | 'right' | 'top' | 'bottom'; /** Pure edge-drag math: `frac` is the pointer position normalized to the frame rect axis. */ declare function dragInnerEdge(inner: InnerFracs, edge: InnerEdge, frac: number): InnerFracs; interface InnerCalibratorOptions { handle: SceneHandle; /** The node whose props.inner is being calibrated (reelFrame or any inner-declaring node). */ nodeId: string; /** Patch route (host may add logging/saving); defaults to handle.patch. */ applyPatch?: (patch: ScenePatch) => void; onChange?: (inner: InnerFracs) => void; } interface InnerCalibrator { /** Add to the stage ABOVE the scene view (canvas coordinates). */ view: Container; /** Redraw from current bounds/props (call after resize or external patches). */ refresh(): void; destroy(): void; } declare function createInnerCalibrator(opts: InnerCalibratorOptions): InnerCalibrator; /** Which size lever a rule exposes — decides how a resize drag is interpreted. */ declare function sizeLever(rule: LayoutRule): 'wh-frac' | 'wh-box' | 'scale' | 'none'; /** * Multiply a rule's size lever by (factorX, factorY). Corners pass equal factors (uniform); * edges pass 1 on the untouched axis. Uniform levers (`scale`) use factorX. */ declare function resizeRule(rule: LayoutRule, factorX: number, factorY: number): LayoutRule; declare function setRuleRotation(rule: LayoutRule, radians: number): LayoutRule; /** Nudge a rule's final pixel offset by a screen delta already converted to design units. */ declare function nudgeRule(rule: LayoutRule, dxDesign: number, dyDesign: number): LayoutRule; type HandleId = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'rotate' | 'body'; interface Box { x: number; y: number; width: number; height: number; } /** Screen position of each handle for a bounding box. */ declare function handlePoint(box: Box, id: Exclude): { x: number; y: number; }; /** * Resize factors from dragging a handle, using the OPPOSITE edge/corner as the fixed * reference — the intuitive "grab a corner, drag out, it grows" feel. */ declare function resizeFactors(startBox: Box, handle: HandleId, pointer: { x: number; y: number; }): { factorX: number; factorY: number; }; interface TransformGizmoOptions { handle: SceneHandle; app: Application; applyPatch?: (patch: ScenePatch) => void; getOrientation?: () => Orientation; /** Override screen→design conversion for move drags. Default: the node's parent world scale. */ designPerPixel?: (nodeId: string) => number; /** * Gesture boundaries. A drag emits a patch per pointer frame, so undo needs to know that * the whole gesture is ONE edit — otherwise ⌘Z rewinds a drag one mouse-move at a time. */ onGestureStart?: () => void; onGestureEnd?: () => void; /** * A press inside the selection box that never moved. The body covers the whole selected * node so it can be dragged, which also made it swallow every click there — overlapping * siblings and deeper children became unreachable once anything was selected. The host * re-picks at this point instead. */ onBodyClick?: (globalX: number, globalY: number, altKey: boolean) => void; } interface TransformGizmo { attach(nodeId: string | null): void; refresh(): void; /** Run a full handle drag programmatically (agent/tests). Screen coords. */ drag(handle: HandleId, from: { x: number; y: number; }, to: { x: number; y: number; }): void; /** * Move the selection by a discrete amount (arrow keys). Deliberately NOT a synthetic drag: * a drag ignores travel below the click/drag threshold, which would swallow a 1px nudge. */ nudge(dxScreen: number, dyScreen: number): void; destroy(): void; } declare function createTransformGizmo(opts: TransformGizmoOptions): TransformGizmo; /** * Base class for all scenes. * Provides a root PixiJS Container and lifecycle hooks. * * @example * ```ts * class MenuScene extends Scene { * async onEnter() { * const bg = Sprite.from('menu-bg'); * this.container.addChild(bg); * } * * onUpdate(dt: number) { * // per-frame logic * } * * onResize(width: number, height: number) { * // reposition UI * } * } * ``` */ declare abstract class Scene implements IScene { readonly container: Container; constructor(); /** Called when this scene becomes active. Override in subclass. */ onEnter?(data?: unknown): Promise | void; /** Called when this scene is deactivated. Override in subclass. */ onExit?(): Promise | void; /** Called every frame with delta time (in seconds). Override in subclass. */ onUpdate?(dt: number): void; /** Called when the viewport resizes. Override in subclass. */ onResize?(width: number, height: number): void; /** Cleanup — called when the scene is permanently removed. */ onDestroy?(): void; } /** * 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; } /** 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; /** 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; } interface FlowDoc { version: 1; id: string; /** Sound cue registry: alias → definition. Steps refer to cues, never to files. */ cues?: Record; /** Event name (host lifecycle: spinStart/reelStop/result/enterMode/…) → steps. */ on: Record; } interface CueDef { /** One source alias, or several — with `rotate` the runner cycles them (anti-repetition). */ src: string | string[]; channel?: 'music' | 'sfx'; loop?: boolean; rotate?: boolean; /** ± random playback-rate jitter (0.06 = ±6%). */ jitter?: number; volume?: number; } /** * Fields every step carries regardless of kind. * * `id` exists because steps used to be addressed purely by position — three different * positional schemes across the codebase — so any insert or delete invalidated every * sibling's path, and a trace entry could not say WHICH step it came from. It lives on the * step rather than in a document-level map on purpose: the editor→game doc push copies only * `on` and `cues`, so a new top-level key would be silently dropped and never saved. */ interface StepCommon { /** Stable identity. Assigned by `ensureStepIds` when a hand-written doc omits it. */ id?: string; /** Editor-only: where the node sits on the flow canvas. Absent → auto-laid-out. */ ui?: { x: number; y: number; }; } /** Core step vocabulary; plugins contribute additional `do` kinds through the registry. */ type Step = TweenStep | SoundStep | SetStateStep | SetVarStep | SetPropsStep | CountUpStep | WaitStep | IfStep | ParallelStep | SeqStep | ForEachStep | CodeStep | PluginStep; interface TweenStep extends StepCommon { do: 'tween'; node: string; /** Target numeric view properties (alpha, x, y, scale, rotation, …). */ to: Record; ms: number; ease?: string; } interface SoundStep extends StepCommon { do: 'sound'; cue: string; action?: 'play' | 'stop'; } interface SetStateStep extends StepCommon { do: 'setState'; node: string; state: string | null; } interface SetVarStep extends StepCommon { do: 'setVar'; name: string; value: unknown; } /** Transient prop write on the live instance (presentation) — does NOT touch the doc. */ interface SetPropsStep extends StepCommon { do: 'setProps'; node: string; props: Record; } interface CountUpStep extends StepCommon { do: 'countUp'; node: string; /** Instance prop receiving the formatted value (default 'value' — badge prefabs). */ prop?: string; from?: number | string; /** Number or a '$path' reference into the fire() ctx (e.g. '$win'). */ to: number | string; ms?: number; format?: 'int' | 'space'; } interface WaitStep extends StepCommon { do: 'wait'; ms?: number; until?: 'tap'; } interface IfStep extends StepCommon { do: 'if'; /** Micro-expression over the fire() ctx: `win >= 100`, `mode === 'fs'`, or a bare truthy name. */ when: string; then: Step[]; else?: Step[]; } interface ParallelStep extends StepCommon { do: 'parallel'; /** Independent tracks, awaited together. */ steps: Step[][]; } interface SeqStep extends StepCommon { do: 'seq'; steps: Step[]; } /** * Iterate a ctx collection — THE dynamic fan-out every studied game needs (staggered jar * flights, per-cell transmutes, cascade histories). Nested steps see the current element * as `$` (default `$item`) and its index as `$Index`. */ interface ForEachStep extends StepCommon { do: 'forEach'; /** '$path' into the ctx (or an inline array). */ items: string | unknown[]; as?: string; mode?: 'sequential' | 'parallel'; /** Per-item delay: parallel start stagger / sequential inter-item gap (turbo-scaled). */ staggerMs?: number; steps: Step[]; } /** Escape hatch: a named code choreography registered on the runner. */ interface CodeStep extends StepCommon { do: 'code'; ref: string; args?: Record; } /** A plugin-contributed kind: open shape, but it still carries the common fields. */ interface PluginStep extends StepCommon { do: string; [key: string]: unknown; } interface TraceEntry { /** ms since fire() (real time, unscaled). */ t: number; do: string; /** * Which step produced this entry. Without it the trace only says a `tween` ran — two * identical tweens are indistinguishable — so a canvas cannot highlight what is running * and a scrub cannot point at a step. */ stepId?: string; node?: string; info?: string; } interface FlowTrace { event: string; turbo: number; skipped: boolean; /** Set on scrub replays: the run executed instantly and halted after this entry index. */ haltedAtEntry?: number; durationMs: number; entries: TraceEntry[]; } /** What the audio adapter receives — cue already resolved (rotation/jitter applied). */ interface ResolvedCue { cue: string; src: string; channel: 'music' | 'sfx'; loop: boolean; rate: number; volume: number; } interface FlowAudioAdapter { play(cue: ResolvedCue): void; stop(cueName: string): void; } interface CreateFlowRunnerOptions { scene: SceneHandle; audio?: FlowAudioAdapter; plugins?: FlowPlugin[]; /** Named code choreographies for `{do:'code', ref}` escape hatches. */ code?: Record) => Promise | void>; /** Live trace feed (the log/timeline view); the full trace is also returned by fire(). */ onTrace?: (event: string, entry: TraceEntry) => void; log?: (msg: string) => void; } interface FireOptions { /** Scrub replay: run instantly (no waits, anims jump, audio muted) and halt after entry N. */ replay?: { untilEntry?: number; }; } interface FlowRunner { fire(event: string, ctx?: Record, opts?: FireOptions): Promise; /** Scrub: re-fire `event` with its LAST ctx, instantly, halting after trace entry N. */ replay(event: string, untilEntry: number): Promise; /** Complete every active run in zero time (settle). */ skip(): void; /** 1 = normal; 2 = twice as fast. Applies to waits, tweens, count-ups. */ setTurbo(factor: number): void; /** Host reports a user tap (releases `wait until:'tap'`). */ tap(): void; events(): string[]; /** * The step kinds this runner accepts, with their authoring metadata. The editor lives in * another origin and cannot reach the registry, so the contract travels through here — * before this, field lists were hardcoded in the editor (twice, and already drifting). */ stepKinds(): Array<{ kind: string; schema?: PropsSchema; agentDoc?: string; }>; destroy(): void; } /** Per-run runtime handed to step executors (core and plugin alike). */ interface FlowRuntime { scene: SceneHandle; ctx: Record; audio: FlowAudioAdapter; /** Turbo-scaled, skip-aware sleep. */ wait(ms: number): Promise; waitTap(): Promise; /** Execute a nested step list (if/parallel/seq bodies). */ run(steps: Step[]): Promise; /** True once skip() hit this run — executors should jump straight to end states. */ readonly skipped: boolean; /** True for scrub replays — like skipped (jump to end states), plus audio stays muted. */ readonly instant: boolean; readonly turbo: number; /** Resolve '$path' references against ctx; other values pass through. */ resolve(value: unknown): unknown; /** Evaluate an if-condition against ctx. */ when(expr: string): boolean; /** A runtime whose ctx is overlaid with extra values (forEach exposes `$item` this way). */ child(ctxPatch: Record): FlowRuntime; trace(entry: Omit): void; /** The node's live view (tween targets). */ view(id: string): Container | undefined; log(msg: string): void; codeRef(ref: string): ((rt: FlowRuntime, args: Record) => Promise | void) | undefined; playCue(name: string, action: 'play' | 'stop'): void; } interface FlowStepContribution { kind: string; /** Authorable fields — drives the editor's controls and documents the step for an agent. */ schema?: PropsSchema; /** 3–10 lines for the agent: when to use the step, patterns, anti-patterns. */ agentDoc?: string; run(step: Step, rt: FlowRuntime): Promise | void; } interface FlowPlugin { id: string; steps?: FlowStepContribution[]; } /** The multi-scene graph as data: which docs exist and how the player moves between them. */ interface StageDoc { version: 1; scenes: Array<{ key: string; scene: SceneDoc; flow?: FlowDoc; /** * The game mode this scene is the view for (`free_spins`, `bonus`, …). Such a scene is * NOT a separate host scene: it is folded into the scene it extends and applied as a * diff when the mode starts, so the board is not rebuilt. Omit for real screens (intro, * an adventure map) — those stay their own host scene. */ mode?: string; /** Host skips this scene on replay launches (intro idiom). */ skipOnReplay?: boolean; /** A tap anywhere advances to this scene key (intro → game). */ advanceOnTap?: string; }>; } /** Build the createSlotGame `scenes` list from a StageDoc — every entry a DocScene. */ declare function buildDocScenes(stage: StageDoc, shared: Omit, 'scene' | 'flow' | 'advanceOnTap'>): Array<{ key: string; scene: DocScene; skipOnReplay?: boolean; }>; interface DocSceneOptions { scene: SceneDoc; /** * A scene doc per game mode (`free_spins`, `bonus`, …) — each authored as its own file, * usually `extends`-ing this one so it carries only its delta. Entering a mode applies * the DIFF to the live scene: shared nodes are never touched, so the reel system, its * symbols and any running animation survive the transition. (No game in the surveyed set * rebuilds its board on mode entry, and rebuilding mid-spin would be visible.) */ modeScenes?: Record; flow?: FlowDoc; /** A tap anywhere advances to this scene key (uses the host-injected goto). */ advanceOnTap?: string; plugins?: ScenePlugin[]; flowPlugins?: FlowPlugin[]; code?: CreateFlowRunnerOptions['code']; resolveSymbol?: SymbolResolver; texture?: (alias: string) => Texture; vars?: Record; /** * Build the flow ctx for result-bearing events. Default: the result object spread + * `{ win: totalWin, mode, action, bet }`. Games with richer normalized data override. */ resultCtx?: (result: T, ctx: RenderContext) => Record; /** Map the shell turbo level (0..3) to a flow time factor. Default 1 + level. */ turboFactor?: (level: number) => number; onTrace?: CreateFlowRunnerOptions['onTrace']; log?: (msg: string) => void; } declare class DocScene extends Scene implements SlotSceneController { private opts; private api; private handle_; private runner_; private chain; private lastSize; private log; constructor(opts: DocSceneOptions); /** The live scene handle (inspector/agent attachment). Null before onEnter. */ get scene(): SceneHandle | null; get flow(): FlowRunner | null; onEnter(data?: unknown): void; onResize(width: number, height: number): void; onDestroy(): void; private fire; private ctxOf; onCreate(api: SceneApi): void; onSpinStart(): void; /** The resolved doc currently on screen, so a mode switch diffs from the right baseline. */ private activeDoc_; private activeDocId_; onSpin(result: T, ctx: RenderContext): Promise; onEnterMode(result: T, ctx: RenderContext): Promise; onExitMode(result: T, ctx: RenderContext): Promise; /** * Move the live scene to the doc for `mode` (null = the base scene) by applying the diff. * A no-op when the mode has no scene of its own, which is the common case: most modes * differ only in what flow does. */ private applySceneFor; /** Fold a doc's `extends` chain, looking parents up among the scenes this game declared. */ private resolveDoc; onSpinEnd(result: T, ctx: RenderContext): void; onSkip(): void; onTurboChanged(level: number): void; } declare function createDocScene(opts: DocSceneOptions): DocScene; export { ALPHA_FIELD, BUILTIN_FILTER_KINDS, BUILTIN_NODE_TYPES, DocScene, REMOVE_MARKER, buildDocScenes, createDocScene, createInnerCalibrator, createSceneFromDoc, createSceneRegistry, createTransformGizmo, dependencyOf, diffDocs, dragInnerEdge, edgePoint, effectiveNode, evalVisibleWhen, extraPropKeys, fracBoxFields, handlePoint, nudgeRule, orientationOf, placementBounds, resizeFactors, resizeRule, resolveExtends, resolveLayoutRule, schemaFieldRows, setRuleRotation, sizeLever, validateSceneDoc }; export type { AbsoluteLayout, Box, CoverLayout, CreateSceneOptions, DocSceneOptions, ExtendOptions, FieldKind, FieldSchema, FilterKindContribution, FilterSpec, FrameFractionLayout, GridCellLayout, HandleId, InnerCalibrator, InnerCalibratorOptions, InnerEdge, InnerFracs, LayoutContext, LayoutResolution, LayoutRule, NodeCreateContext, NodeDefaults, NodeInstance, NodeOverride, NodeTypeContribution, Orientation, OutlineNode, PaletteEntry, PatchResult, PinEdge, PinLayout, Placement, PrefabContribution, PropsSchema, Rect, SceneDoc, SceneHandle, SceneNode, ScenePatch, ScenePlugin, SceneRegistry, SceneValidationError, SchemaFieldRow, Size, StageDoc, TransformGizmo, TransformGizmoOptions, ViewportFractionLayout };