import type { GameDefinition, GameLoop } from "../game/defineGame.js"; import type { PointerAxisState } from "../input/pointerAxis.js"; import type { ModelAssetRef } from "../scene/assetCatalog.js"; import { type GameContext, type GameContextContent, type GameContextModels } from "./gameContext.js"; import type { InputFrame, InputSnapshot } from "./inputSnapshot.js"; import { type SimSnapshot } from "./simSnapshot.js"; /** One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame. */ export interface HeadlessInput { /** Discrete presses on this recorded step, including taps released before its held-state sample. */ presses?: InputFrame["presses"]; /** Replaces the held-action set for this step; edge detection (`justPressed`/`justReleased`) rolls the previous set forward. Omit to keep the last published set. */ held?: readonly string[]; /** Replaces the normalized pointer state; omit to leave the last pointer unchanged (pass `null` to clear it). */ pointer?: PointerAxisState | null; } export interface HeadlessRunnerOptions { definition: GameDefinition; /** Catalog resolvers (`entityById`/`objectById`) the context uses; defaults to an empty catalog. */ content?: GameContextContent; /** Lifecycle hooks — `onInit`/`onNewPlayer` fire at construction, `onTick` on every {@link HeadlessRunner.step}. The shell keeps this separate from `definition` (presentation wraps it); a headless caller passes it directly. */ loop?: GameLoop; /** The single local player; defaults to `{ userId: "player", isNew: true }`. */ player?: { userId: string; isNew: boolean; }; /** Wall-clock source for the sim clock — inject a deterministic counter for reproducible runs. */ now?: () => number; /** Per-step real-dt clamp in seconds, matching the shell's frame cap so one long stall can't fast-forward the sim. Default `0.05`. */ maxStepSeconds?: number; /** * Drive the built-in keyboard player-movement controller from the held-action set each step, exactly as the shell's * FrameDriver does for pose-driven games. Off by default — tick-driven games move their own entities inside * `onTick` and don't need it. */ playerMovement?: boolean; /** Movement tuning — when `playerMovement` is on, the shell resolves from `definition.world`/`physics`; pass a direct config to test flight or other movement overrides headlessly. */ movement?: import("../game/playableGame.js").PlayerMovementConfig; /** Yaw heading (radians) fed to the movement controller when `playerMovement` is on; the shell supplies its camera yaw. */ heading?: number; /** Pitch (radians) fed alongside yaw for 6DOF spectator flight when `alignWithLook` is set. */ pitch?: number; /** Render-model lookup for collider auto-fit — pass what the shell would derive from `entityModels`/`objectModels` so headless raycasts hit the same boxes as play. */ models?: GameContextModels; } /** Canvas-free command/intent surface for {@link HeadlessRunner} — invoke a game's registered UI commands and read the resulting reactive state off `ctx`. */ export interface HeadlessCommandInterface { /** Invoke a registered command/intent by name against the live context. Throws on `unknown-command` or a validation rejection so a headless UI-flow test fails loudly instead of silently no-op'ing. */ invoke(name: string, input?: unknown): void; /** Non-throwing {@link invoke} returning the raw command result (inspect `status`). */ tryInvoke(name: string, input?: unknown): ReturnType; /** Registered command names. */ names(): string[]; /** Is a command registered? */ has(name: string): boolean; } /** * A renderer-free driver for a game loop: builds a {@link GameContext} from a {@link GameDefinition}, runs the init * hooks, then advances the simulation one step at a time from injected input. No React, R3F, or three.js — the whole * play path (time, input, `onTick`, behaviour nav, optional player movement) runs from `core` primitives alone, so a * non-React host (a server tick, a test, a CLI replay) can play a real game and read its world snapshot. The shell's * FrameDriver is one such driver bolted to `useFrame`; this is the same step distilled out of the render tree. * * @capability headless-runner play a real game loop with no renderer — tick, feed input, read the world snapshot */ export interface HeadlessRunner { /** The live game context — read `ctx.scene.entity.list()`, `ctx.game.store`, `ctx.subscribe`/`ctx.version()` for the world snapshot. */ readonly ctx: GameContext; /** The per-frame input the loop reads; the same object exposed as `ctx.input`. */ readonly input: InputSnapshot; readonly userId: string; /** * Advance the simulation one step. Publishes `input` (if given), advances the sim clock by the clamped real dt, * runs optional player movement, then `loop.onTick` and behaviour nav. Returns the scaled game dt `onTick` saw. */ step(dtSeconds: number, input?: HeadlessInput): number; /** Publish input without advancing — for pre-seeding the held set before the first {@link step}. */ publishInput(input: HeadlessInput): void; /** Drive the game's own UI commands / intents headlessly — the same registry `ctx.game.commands` and the shell's command sink dispatch into. No renderer, no pointer simulation: dispatch an intent, then read reactive state off `ctx`. */ readonly ui: HeadlessCommandInterface; /** Capture the whole simulation (world, clock, loop, rng cursor) for {@link restore}; with a fixed `simulation.hz` and recorded input, replaying from it reproduces the run exactly. */ snapshot(): SimSnapshot; restore(snapshot: SimSnapshot): void; /** Current simulation tick (`ctx.sim.tick()`). */ tick(): number; } export declare function createHeadlessRunner(options: HeadlessRunnerOptions): HeadlessRunner;