import { P as Scene$1, Pt as LogEntry, b as Engine, jt as Node, n as BehaviorCtor } from "./behavior-B_245qRy.js"; import { a as SceneJson, c as JsonValue, s as JsonObject } from "./rng-BsXZg3D6.js"; import { t as LoadSceneOptions } from "./loader-CbkVdXL8.js"; import { l as auditScene, o as IncantoError, r as ReplayJson, u as instancedScenes } from "./replay-W5nCw_cU.js"; import { l as LocalGameServerOptions, n as SplitScreenPlayer } from "./split-screen-CiLJf2-C.js"; import { Matrix4, Vector3 } from "three"; //#region src/core/asset-urls.d.ts interface SceneAssetUrl { /** `/Root/Enemies/Slime` — where in the tree it was written. */ path: string; /** `texture`, `sheet`, `material.map`, … */ prop: string; /** The asset spec's `kind` — `texture`, `spritesheet`, `model`, `audio`, … */ kind: string; url: string; } /** * Walk the tree and collect every URL-valued asset prop. * * `$ref` values are somebody else's problem (`validateAssetRefs` resolves them * against `assets{}` at load). Unknown node types are skipped rather than * thrown on: this is a warning path, and the loader reports an unknown type far * better than a walker could. */ declare function sceneAssetUrls(json: unknown): SceneAssetUrl[]; //#endregion //#region src/test/facing.d.ts interface FacingOptions { behaviors?: Record; stubMissingBehaviors?: boolean; resolveScene?: LoadSceneOptions["resolveScene"]; seed?: number; moveAction?: string; /** Which local axis the ART faces. Default `+z`, the engine's convention. */ forwardAxis?: "+z" | "-z" | "+x" | "-x"; /** Where the visual skin is, relative to the player. Default: found by name. */ skinPath?: string; } interface FacingReport { player: string | null; /** The node whose rotation was measured, or null when the player has no skin. */ skin: string | null; /** Skin forward · travel direction. +1 faces forward, −1 runs backwards. */ dot: number | null; /** True only when the two genuinely oppose — the reportable failure. */ backwards: boolean; /** How far it actually moved, so a report on a stuck character is not trusted. */ travelled: number; /** * WHICH property was read to get a heading — `yaw` (3D, and 2D top-down), * `flipX` (a 2D side view), or null when the skin declares neither. * * It exists because the answer used to be "none of them, silently": in 2D * both values this read were `undefined`, so `dot` came out exactly 0, * `backwards` could never fire, and the line printed was * `faces its travel (dot 0.00)` for any 2D character whatever way its art * pointed. A check that cannot fail is worse than no check, so a report that * measured nothing now SAYS it measured nothing. */ basis: "yaw" | "flipX" | null; /** When there is no `dot`, which of the four reasons it is. */ noAnswer: "no-player" | "no-skin" | "did-not-move" | "no-heading" | "locked-on" | "paused" | null; } /** * Drive the player forward and compare where the skin points with where it went. * * The travel direction is measured, not assumed: whatever the input map means by * "up", the character ends up going somewhere, and that somewhere is the truth * the art has to agree with. */ declare function facingReport(json: unknown, opts?: FacingOptions): Promise; /** One line for a report, or the failure spelled out. */ declare function facingText(report: FacingReport): string; //#endregion //#region src/test/framing.d.ts /** Where a node's origin sits relative to the current camera. */ /** * `hidden` is not a position — it is the renderer declining to draw at all. * * It is its own state rather than a flavour of `offscreen` because the fix * differs: an offscreen node is in the wrong PLACE, a hidden one is switched * off, and a 2D screen drawing absolutely nothing used to report * `2 in view, 0 outside it` — the two numbers an author reads to conclude the * frame is fine. */ type Where = "onScreen" | "offscreen" | "behind" | "hidden" | "blank"; interface FramingEntry { path: string; type: string; /** World-space origin. */ at: [number, number, number]; where: Where; /** * Normalised coords, x/y in −1..1. Absent when `behind`. * * The Y AXIS DIFFERS BY REPORT, because the two spaces do: in the 3D report * this is NDC and +y is UP; in the 2D report it is the design-space window * and +y is DOWN, like every other 2D coordinate in this engine. The one * docstring here used to promise "(right/up)" for both, and the comment above * the 2D computation claimed it was "the same reading as the 3D report" — so * anything comparing a 2D `screen.y` against a 3D one was reading a sign * that was never there. `FramingReport.dimension` already tells you which * report you have; this follows it. */ screen?: [number, number]; /** Metres from the camera's eye. */ distance: number; } interface FramingReport { /** * Which report this is. The question is the same — can the camera see it — * but the machinery and the UNITS are not: 3D projects a perspective frustum * and measures metres, 2D clips an orthographic window and measures pixels. */ dimension: "2d" | "3d"; /** * 3D: null when the scene declares no camera — nothing frames anything. * 2D: null means the documented default view, `(0,0)`–`(design)`. */ camera: { path: string; at: [number, number, number]; /** 3D: the unit vector it points along. Absent in 2D. */ forward?: [number, number, number]; /** 3D only. */ fovDeg?: number; /** 2D only: world pixels visible, after `zoom`. */ view?: [number, number]; /** 2D only. */ zoom?: number; /** * 3D: which way is UP on screen — the camera's local +Y in world space. * * Roll changes nothing else: `at` and `forward` of a camera rolled 180° * are identical to an upright one's, so the whole world hanging upside * down printed a byte-identical header. */ up?: [number, number, number]; /** * 3D: how far the horizon is tilted, in degrees. 0 is level, 180 inverted. * * A number rather than a verdict: a dutch angle is a real choice. But the * engine's OWN suggested default — `position [7, 6, 9], rotation * [-26, 36, 0]` — measures 16° of roll, and nothing anywhere reported it, * because no two-angle Euler-XYZ recipe can produce a level camera in the * first place (`[pitch, yaw, 0]` is Rx·Ry; a level camera is Ry·Rx). */ rollDeg?: number; /** * The frame is upside down (`up.y < 0`), near-vertical shots excepted. * * A plain top-down camera (`rotation: [-90, 0, 0]`) measures * `up = (0, 0, -1)` — `up.y` is 0 EXACTLY and the frame is correct, so the * test is `< 0` and skips |forward.y| > 0.98 entirely. */ upsideDown?: boolean; /** * The projection matrix has a non-finite element, so NOTHING is drawn. * * `fov: 0` / `near: 0` / `near >= far`. Every entry is reported offscreen * rather than passed through with `screen [NaN, NaN]` under an `ok`. */ degenerate?: boolean; } | null; entries: FramingEntry[]; /** * Widgets a HudLayer puts on screen, by path — the part of the picture the * camera has nothing to do with. * * The walk stops at a `HudLayer` on purpose ("asking whether the camera can * see it is a category error"), and nothing counted what was inside. So a * shop screen made entirely of widgets and a scene file with a root node and * nothing else BOTH read `0/0 on screen`, and the check whose header says * "N scene(s) a person would call broken on sight" passed the second one: * its rule is "the camera sees none of the things there are", and there were * none. Hidden widgets are left out, like every other `visible: false` node. */ widgets: string[]; /** * What lights the scene: light nodes, plus the environment header when it * carries a sky/hdri/preset/ambient. Empty means the render is BLACK. */ lights: string[]; /** Pairs whose declared colliders intersect — "the gem is inside the platform". */ overlaps: Array<{ a: string; b: string; }>; } interface FramingOptions { /** Viewport aspect. Default 16/9 — only affects the horizontal edge test. */ aspect?: number; /** Report only these node types (default: everything that draws). */ types?: readonly string[]; /** * 2D only: the window in world pixels. Defaults to the scene's * `viewport.design`, then 960×540 — headless there is no canvas to ask. */ viewport?: readonly [number, number]; } declare function describeFraming(scene: Scene$1, opts?: FramingOptions): FramingReport; /** The report as something to read in a terminal. */ declare function framingText(report: FramingReport): string; //#endregion //#region src/test/picker.d.ts /** * "What node is under this point" — from GEOMETRY, with no renderer. * * `Clickable` reads `engine.picker`, which the renderer installs as a GPU * raycast. Headless there is no renderer, so `engine.picker` is null and every * mouse-driven genre — match-3, tower defense, cards, point-and-click, RTS, * idle, a flip puzzle — was inert in the one place it was supposed to be * proved. The recipe for it was published (write your own picker) and every * such game therefore hand-wrote the same box hit-test. * * The boxes come from the same machinery `describeFraming` uses, so a node's * pickable extent and its on-screen extent are the same fact rather than two * that drift. */ interface PickerOptions { /** * The pixel rect the coordinates live in. * * 2D: defaults to the scene's `viewport.design`, then 960×540 — the same * fallback the framing report makes, because it is the same question. * 3D: the size the ray is cast through; only the ASPECT changes an answer. */ viewport?: readonly [number, number]; } /** * A picker over `scene`, in the scene's own units. * * 2D takes WORLD pixels — headless there is no canvas to convert from, and the * design window is the coordinate space every 2D position in the file is * already written in. 3D takes pixels within `viewport` and casts a ray from * the scene's current camera. * * A hidden node is never picked, which matches what the player can click. */ declare function geometricPicker(scene: Scene$1 | (() => Scene$1), opts?: PickerOptions): (x: number, y: number) => Node | null; /** * `engine.toWorld` / `engine.toScreen` from GEOMETRY, with no renderer — the * aiming half of a mouse game, the way `geometricPicker` is the clicking half. * * A renderer installs these on the engine so a Behavior can ask where the * cursor IS; headless nothing did, so a drag-and-launch, a tower placed on the * map or a unit steered by the cursor could be clicked in a harness and never * aimed. 2D is the identity (the picker's coordinates are already world pixels * — there is no canvas to convert from); 3D unprojects onto the `groundY` * plane, which is where a 3D game puts the thing you pointed at. */ declare function geometricView(scene: Scene$1 | (() => Scene$1), opts?: PickerOptions & { groundY?: number; }): { toWorld: (x: number, y: number) => number[] | null; toScreen: (world: readonly number[]) => { x: number; y: number; behind: boolean; } | null; screenRay: (x: number, y: number) => { origin: number[]; dir: number[]; } | null; }; /** * Where a node is, in the coordinates `geometricPicker` answers in. * * The click half of a headless driver: `at %Mole` has to become a point before * a press can land on it, and the space differs by dimension — 2D pixels are * the world, 3D pixels are a projection through the scene's camera. Both * drivers that click headlessly (`runScript` and `incanto-play`) need exactly * this, and the first one had it inline. */ declare function pointAtNode(scene: Scene$1, path: string, opts?: PickerOptions): [number, number] | null; //#endregion //#region src/test/agreement.d.ts /** * Do two clients hold the same value for the same replicated thing? * * Nothing in the engine asked. The multiplayer harness measured room * MEMBERSHIP and child COUNTS: `sawAccounts` is the room's key list, and the * kernel inserts an empty entry at joinRoom, so "saw p2" meant "p2 is in the * room" — not "p2's data arrived". A correct scene and four whose replication * was completely dead produced byte-identical green reports. * * A desync is the purest silent failure this engine can have: both clients * run, neither errors, and they are playing different games. * * Pure over snapshots, like `multiplayProblems`, because the test for "the * clients disagreed" must not depend on being able to REPRODUCE a * disagreement. */ /** One account's truth, and what every other client believes about it. */ interface AgreementSnapshot { /** The account that OWNS these values. */ account: string; /** Read from the owner node the way the sender reads it: key → value. */ truth: Record; /** * Observer account → what its spawned copy holds, or `null` when it never * materialised this account at all. */ seen: Record | null>; /** * Declared sync keys the OWNER cannot read. * * These used to be dropped from `truth` entirely — "the sender's own problem, * already diagnosed there". It is diagnosed there, to `engine.log.error`, * which does not touch `stats().errors` and so reaches no verdict; and the * sender's report is gated on 500 ms of replication, which a short match may * not reach. Meanwhile dropping the key means every observer agrees perfectly * about a value that is never sent, and `agrees` grades a dead sync key * PASS. A key that cannot be read is the most complete failure of * replication there is, so it is a finding, not a silence. */ unreadable?: string[]; /** * How fast the owner was moving when the snapshot was taken, and how wide * its send window is. A body still in motion has copies that are honestly * one window behind — a car coasts at constant speed once the throttle is * released (measured 14.2 m/s for a full second), a puck on ice slides for * ever — so a `position`/`rotation` gap of up to speed × window is the * wire's declared latency, not a disagreement. A dead sync still fails: its * gap grows with the match, not with the window. */ motion?: { /** |linearVelocity|, units per second (0 when the node declares none). */speed: number; /** |angularVelocity|, radians per second (0 when the node declares none). */ spin: number; /** Send throttle plus the server's turn, seconds. */ windowSeconds: number; }; } /** * One `collection:` spawner on one client: what the server sent it, and what it * made of that. * * `NetworkSpawner` has two sources and only `users` was ever compared. A * `collection:` spawner — bullets, pickups, every entity a server owns — could * receive a hundred entities and materialise none, and the whole `agrees` rung * had nothing to say: the comparison is keyed by ACCOUNT, and a bullet has no * account. */ interface CollectionSnapshot { /** The client this spawner belongs to. */ account: string; /** Node path of the spawner. */ spawner: string; /** The collection id, from `source: "collection:"`. */ id: string; /** Entity ids this client's manager last received for that collection. */ received: string[]; /** Child instance names the spawner actually materialised. */ materialised: string[]; } type DisagreementKind = "missing" | "absent" | "mismatch" | "shape" | "erased" | "unreadable" | "unspawned"; interface Disagreement { kind: DisagreementKind; /** Whose value it is. */ account: string; /** Who is wrong about it. */ observer: string; /** The sync key, or `''` for a whole missing instance. */ key: string; truth?: JsonValue; seen?: JsonValue; /** One sentence a reader can act on. */ summary: string; } interface AgreementOptions { absTol?: number; relTol?: number; } /** * Every way the clients disagree, most structural first. * * @param live snapshots taken after the match has quiesced * @param late the same, for a client that joined AFTER everything settled */ declare function agreementProblems(live: AgreementSnapshot[], late?: AgreementSnapshot[], opts?: AgreementOptions): Disagreement[]; /** * Collections that arrived and became nothing, and collections two clients * disagree about. * * Pure over snapshots, like `agreementProblems`, and reported in the same * shape so one verdict covers both halves of what a spawner does. */ declare function collectionProblems(snapshots: CollectionSnapshot[]): Disagreement[]; //#endregion //#region src/test/agreement-snapshot.d.ts /** * Read what every client actually holds, so `agreementProblems` can compare it. * * Both sides are read through the ENGINE'S OWN accessors — `readSyncKey` for * the owner and the receive-side last-dot split for the copy — so the check * cannot drift away from the protocol it is checking. Reading the copy the * same raw way `setProp` writes it also respects the data-bag design: a * replicated `score` that is not a declared prop still round-trips and still * gets compared. */ interface AgreementClient { account: string; engine: Engine; } declare function takeAgreementSnapshots(clients: AgreementClient[], observers?: AgreementClient[]): AgreementSnapshot[]; /** * What every `collection:` spawner RECEIVED, and what it made of it. * * The account-keyed comparison above cannot see these: a bullet has no account. * A spawner can take a hundred entities off the wire and materialise none of * them — an unregistered `scene` key does exactly that — and until this * existed the `agrees` rung had nothing to say about it. */ declare function takeCollectionSnapshots(clients: AgreementClient[]): CollectionSnapshot[]; //#endregion //#region src/test/feel.d.ts interface FeelOptions { behaviors?: Record; stubMissingBehaviors?: boolean; resolveScene?: LoadSceneOptions["resolveScene"]; seed?: number; /** The vector action to move with. Default: the scene's first vector2. */ moveAction?: string; /** The button to jump with. Default `jump` if the scene declares it. */ jumpAction?: string; } interface FeelReport { /** null when the scene has no player this can drive. */ player: string | null; moveAction: string | null; /** * WHICH WAY the move action was pushed to get these numbers. * * Every probe used to push `(1, 0)` and only `(1, 0)`. A game whose movement * reads the other axis of the same action — the engine's own documented * forward is `-y` — measured nothing and printed the silence as `0 u/s` with * every derived row blank, exit 0. Null means nothing moved on any axis. */ moveAxis: [number, number] | null; /** Every vector2 action this run actually pushed, in the order it tried them. */ moveTried: string[]; /** * What the engine said while these numbers were being taken. * * A feel report is a page of confident numbers, and it printed them for a * game whose controller's sibling script threw on the first frame and was * quarantined. The controller still works, so the numbers are honest for what * they measure — and a script dying during the measurement is worth a line. * Deduped across probes: each probe is a fresh session. */ errorMessages: string[]; warnings: string[]; jumpAction: string | null; /** Top horizontal speed reached, world units per second. */ topSpeed: number; /** Standstill → 95% of top speed. */ timeToFullSpeedMs: number | null; /** Release → stopped. */ timeToStopMs: number | null; /** Full speed one way → full speed the other. */ turnaroundMs: number | null; /** Height gained above the standing position. */ jumpApex: number | null; /** The apex with the button HELD — see `jumpShape`. Null when it matches. */ heldJumpApex: number | null; /** Airtime with the button held. */ heldAirtimeMs: number | null; /** * The held apex while WALKING and while SPRINTING. `sprintJumpMultiplier` * scales with the keyboard's intensity (0.6 for any move key, 1 with sprint), * so a moving jump is higher than the standing one the other rows measure — * +26% walking, +44% sprinting at the defaults — and a ledge sized against * the standing apex is cleared by anyone holding a direction. Null when the * scene declares no move (or sprint) action. */ walkingJumpApex: number | null; sprintJumpApex: number | null; timeToApexMs: number | null; /** Launch → back on the ground. */ airtimeMs: number | null; /** * The fastest single frame, when it is well above the sustained `topSpeed`. * * Null when they agree. When they do not, something OTHER than the character's * own movement moved it during the measurement — a knockback from an enemy the * probe walked into, a launcher, a conveyor — and every number in the block is * about that instead. */ peakSpeed: number | null; /** MEASURED, not read: how long after leaving ground a jump still fires. */ coyoteMs: number | null; /** * Can this character get off the ground a full second after leaving it? * * The coyote probe reads a jump as "vertical velocity went up on the frame I * pressed", and TWO of the six shipped starters make that true forever, for * two different reasons: `platformer-2d` has a double jump, and * `beacon-isle-3d` has a glide that clamps the fall while `jump` is held. So * both reported `coyote window always (no window)` — which reads as "your * ground check is infinitely generous" — while one of them declares * `coyoteSeconds: 0.12` in the very same file. * * A window cannot be measured this way on a character that can jump in * mid-air, and saying that is worth more than a number that is wrong. */ jumpsInMidair: boolean; /** * `coyoteSeconds` as the scene DECLARES it, in ms — the only number in this * report that is read rather than measured, and it is here to be CONTRASTED * with `coyoteMs` rather than to stand in for it. */ declaredCoyoteMs: number | null; /** MEASURED: how long before landing a press still survives. */ jumpBufferMs: number | null; /** * `jumpBufferSeconds` as the scene DECLARES it, in ms — read, not measured, * and here for the same reason as `declaredCoyoteMs`. */ declaredJumpBufferMs: number | null; /** * NOTHING left the ground, in any of the four jump probes. * * `coyoteMs` and `jumpBufferMs` are searched by lifting the body and pressing * — and a character that cannot jump at all fails every step of that search, * which comes back as `0`. So a report that had just said * `jump apex — (nothing left the ground)` went on to print * `coyote window 0 ms` and advise `Try coyoteSeconds: 0.12`, at three shipped * examples, one of which declares exactly that. A window nobody could measure * is not a window of zero. */ neverJumped: boolean; /** * The player's controller was switched OFF for the whole measurement. * * The second way a game holds the player still, and it looks nothing like a * pause: `examples/heist-3d` opens on an eight-second cutscene that sets * `controller.enabled = false` while the clock runs and a camera flies * around. The probe sat out fifteen seconds waiting for control and this says * it never came — so `the player did not move` is about the SCENE's state and * not about the character. */ controllerOff: boolean; /** * The clock never ran: this game was PAUSED for the whole measurement. * * Every game with a title screen starts this way — `GameFlow.pause()` from * the shell's `onReady` is what holds the world behind the menu — and three * of this repo's own examples do it. This probe pushed the stick into a * stopped world and reported: * * ``` * jump apex — (nothing left the ground) * coyote window 0 ms * note: coyote window is 0 … Try coyoteSeconds: 0.12. * ``` * * about `shell-3d`, whose controller declares `coyoteSeconds: 0.12` in the * same file — advising an author to set a prop they had already set, on the * strength of a zero that was a measurement FAILURE wearing a number's * clothes. The probe now presses the game's own start button first (see * `startTheGame`); this flag is what is left when even that could not get the * clock running, and it turns every number below into "not measured". */ timeStood: boolean; /** Frames between the press and the first movement. */ inputToMotionFrames: number | null; /** * `maxJumps` as the scene declares it (1 when it says nothing) — read, like * `declaredCoyoteMs`, and for the same reason: the probes below have to know * how many presses a character has before they can tell a coyote jump from * an air jump. */ declaredMaxJumps: number; /** * MEASURED: the apex and airtime with EVERY declared jump spent — press, * rise, press again. Null when the controller declares one jump. * * The moment a scene wrote `maxJumps: 2`, this report gave up on two of its * numbers ("not measurable — this character jumps in MID-AIR") and called a * third suspect, while the scene said exactly why in the line above. A * double jump is the 3D character skill's own recommendation; the instrument * has to survive it. */ doubleJump: { apex: number; airtimeMs: number | null; } | null; } declare function feelReport(json: unknown, opts?: FeelOptions): Promise; /** Numbers, not grades. What "good" means here is a genre decision. */ declare function feelText(report: FeelReport): string; //#endregion //#region src/test/multiplay.d.ts interface MultiplayOptions { /** The scene every client loads (the same JSON, cloned per client). */ scene: unknown; /** Your `Server` class. Omitted: the built-in room protocol only. */ server?: LocalGameServerOptions["server"]; /** Scenes a `NetworkSpawner` names (`remote-player`, `coin`, …). */ scenes?: Record; /** How many clients. Default 2. */ players?: number; /** Simulated seconds. Default 5. */ seconds?: number; /** Fixed frame length in ms. Default 16.67 (60fps). */ stepMs?: number; /** Per-frame hook: drive input, call server functions, assert mid-match. */ drive?: (player: SplitScreenPlayer, frame: number, seconds: number) => void | Promise; /** Called once after every client has joined, before the match runs. */ start?: (players: SplitScreenPlayer[]) => void | Promise; /** Seed every client's RNG (client i gets `seed + i`). Without it, Math.random. */ seed?: number; /** Loader options — `stubMissingBehaviors` when the checker has no TypeScript. */ load?: LoadSceneOptions; /** * Bring one more client in AFTER the match has settled, and check it sees * what everyone else does. Default true. * * This half is not optional in spirit: a value that stops changing is erased * from the authoritative state by a partial patch, and the clients already in * the room never notice — they applied it once and kept it on their node. The * late joiner is the only observer that reads the snapshot fresh. */ lateJoin?: boolean; } interface MultiplayPlayer { account: string; room: string; /** Nodes each `NetworkSpawner` in this client's scene ended up holding. */ spawned: Record; /** * The same, with each spawner's `source`. * * "materialised nothing" used to be asked of the CLIENT — every spawner it * has, all at zero — so a client with a working spawner and a dead one * beside it was silent, and the whole check was additionally gated on nobody * being blind. A spawner is the unit that fails, so a spawner is the unit * that is asked. */ spawners: Array<{ name: string; source: string; children: number; }>; /** Other accounts this client's room state actually carried. */ sawAccounts: string[]; /** Frame errors on this client's engine. */ errors: number; } interface MultiplayReport { ok: boolean; seconds: number; frames: number; /** The rooms the clients joined — more than one is the silent killer. */ rooms: string[]; players: MultiplayPlayer[]; /** The last room state, from the first client. */ roomState: JsonObject; /** What is wrong, in the order it matters. Empty when `ok`. */ problems: string[]; /** Every way the clients ended up holding DIFFERENT values. */ disagreements: Disagreement[]; /** How many replicated keys were compared — 0 means agreement was not tested. */ keysCompared: number; /** Whether a late-joining client was checked too. */ lateJoinChecked: boolean; /** * Accounts whose owner node was still MOVING when the clients were compared. * Their copies are allowed one send window of lag (see `AgreementSnapshot.motion`); * the report says so, because a comparison that tolerated motion silently * would be a comparison nobody could read the strictness of. */ moving: string[]; } /** Run a whole match headlessly and report what the clients ended up sharing. */ declare function playMultiplayer(opts: MultiplayOptions): Promise; /** * What is wrong with a finished match, in the order it matters. * * Pure, and exported, because every one of these is a SILENT failure — the test * for "the clients ended up in different rooms" must not depend on being able * to reproduce different rooms. */ declare function multiplayProblems(detail: MultiplayPlayer[], rooms: string[]): string[]; /** The report as the one paragraph a reader needs. */ declare function multiplayText(report: MultiplayReport): string; //#endregion //#region src/test/playtest.d.ts type Outcome = "won" | "lost" | "fell" | "error" | "stuck" | "unfinished" | "swapped"; interface PlaytestOptions { /** How many independent runs. Each gets its own seed. Default 20. */ runs?: number; /** Simulated seconds per run before calling it stuck. Default 60. */ seconds?: number; /** First seed; run `i` uses `seed + i`. Default 1. */ seed?: number; behaviors?: Record; stubMissingBehaviors?: boolean; resolveScene?: LoadSceneOptions["resolveScene"]; /** * The y past which the player has left the world, in the SCENE's own * direction: 3D counts down (−y), 2D counts down the screen (+y). * Default: 50 m under the start in 3D, 1000 px under it in 2D. */ fallBelow?: number; /** * How close counts as "reached", in the scene's own units. Default 2 (metres) * in 3D, 32 (pixels — one tile) in 2D. */ reachRadius?: number; } interface PlaytestRun { seed: number; outcome: Outcome; /** Simulated ms elapsed when the run ended. */ timeMs: number; /** Paths of nodes the player came within `reachRadius` of. */ reached: string[]; /** `Path.signal` for everything that fired at least once. */ fired: string[]; /** How many times the player's Health went down. */ damageTaken: number; errors: number; /** * What the engine WARNED about — one line per distinct warning. * * The warnings are the things that leave a game running and wrong: a camera * whose `follow` resolves to a node with no position, a `DayNight` with no * sky, a body with no collider. `incanto-verify`'s `says` rung reads them off * the browser; a headless run had them in `engine.log` and printed none. */ warnings: string[]; /** * What threw, in the engine's own words — one line per distinct error. * * `errors` is a COUNT, and the count was all a run carried. The message that * names the behavior, the node and the exception went to the console only, * so `playtestText` — what the bin prints and what an agent reads back — * said `✗ error in 2/2` and nothing an author could act on. */ errorMessages: string[]; /** * What this run MADE and was still holding when it ended, by template. * * A shipped tower defense kept every projectile that MISSED alive forever — * its `Lifetime` had been left asleep on the prefab shelf — and no instrument * said so: the playtest reported the game as fine, the audit passed, the * harness passed, every rung was green. Total node count is too blunt to be * the answer, because a tower defense legitimately ENDS with four towers * standing and a survivor with a swarm. Grouped by the AUTHORED template * (`Node.wiringPath`), the two read differently at a glance: `4 × Tower` is * furniture and `5 × Bolt` is a leak, and only the author can tell them * apart — so this is reported and never judged. */ aliveClones: { path: string; count: number; half: number; }[]; /** Only for a run worth watching — replay it with `incanto-play`. */ replay: ReplayJson | null; /** Where the player ended up, for the fall report. */ endedAt: [number, number, number]; /** Node paths that made a sound during the run. */ heard: string[]; /** Node paths that fired a visible effect during the run. */ shown: string[]; /** * Mouse clicks this run landed on a `Clickable`. * * The bot drives the mouse; nothing counted it, so a game played entirely by * clicking looked identical to a game nobody touched. */ clicks: number; /** * Mouse DRAGS this run — press, move, let go. * * A game can be entirely mouse-driven with no `Clickable` in it: a slingshot, * a drawing game, a box-select, a swipe. The bot could only click a listed * node, so it reported the whole class as `nothing here is drivable`. */ drags: number; /** * HUD widgets this run pressed — a menu game's only verb. * * Counted separately from `clicks` because they are different gestures on * different surfaces: a click lands on a world node through the picker, a * press lands on a DOM overlay widget through the path a controller takes. */ presses: number; /** * Declared BUTTON ACTIONS this run pressed — the keyboard, which is the only * verb a rhythm game, a typing game, a QTE or a keyboard puzzle has. * * The bot has always pressed them (`steer` flips a coin per button per * frame); nothing counted them. So a game with no character and four declared * lanes was reported as one where "there was nobody to move and nothing to * press", in the same run that pressed its buttons three hundred times. */ actionPresses: number; /** * HUD DRAGS this run — an ingredient into a pot, an item between slots. * * The bot drove the world mouse and the widgets' activation, and never the * one gesture the HUD skill says a click cannot express. So a game whose * every verb is a drag reported `nothing here is drivable` and `stuck in * 6/6`, about a game that plays. */ widgetDrags: number; } interface PlaytestReport { runs: PlaytestRun[]; /** Every node the driver could have reached, by path. */ targets: string[]; /** Signals worth reporting on: wired by a connection, or on a destination. */ signals: string[]; /** Declared actions the driver had available. */ actions: string[]; /** * Declared actions NOTHING read, in any run — a control the scene promises * and no code implements. * * This field shipped hard-coded to `[]`, with a comment claiming it held * "actions that never changed the world". Nothing ever measured that, so the * report answered "no dead controls" for every scene ever passed to it — * including `examples/village-quest-3d`, which declares * `"restart": { "keys": ["KeyR"] }` and never reads it, so its own control * list says R restarts and R does nothing. * * What is measured now is exact rather than heuristic: `engine.input` records * every action something ASKED about, and an action nobody asked about cannot * have changed anything. Pressing each button and diffing the world — the * original wording — is unmeasurable in a scene where NPCs walk and physics * settles; this is free and it cannot be fooled by noise. * * Advisory, not a failure: an action polled only in a state no run reached * (a pause menu the bot never opened) lands here honestly. */ inertActions: string[]; /** * Every distinct thing that threw, across every run, in the engine's own * words. Empty when nothing did. */ errorMessages: string[]; /** * Every distinct WARNING the engine made, across every run — the things * that leave a game running and wrong (a camera following a node with no * position, a body with no collider). Empty when there were none. */ warnings: string[]; /** * Does anything in this scene declare an END? * * "No run reached a win" has two causes that need opposite fixes: a goal * that cannot be reached, and a scene with no goal in it. A walkabout * template is not a broken game, and reporting one as a failure sends its * author looking for a bug that was never there. */ declaresWin: boolean; /** How many nodes are wired to hurt the player — see the `danger:` line. */ hazards: number; /** Is there a player-side `Health` at all — something to LOSE? */ playerHasHealth: boolean; /** * Actions the bot stopped pressing because they STOPPED THE CLOCK. * * It drove every declared button, and `incanto-your-first-game.md` tells you * to declare `pause`. Measured on one losable scene: 0/10 losses with `pause` * declared, 10/10 without, everything else identical — a frozen game cannot * hurt you, so the same run also reported the game as having no way to lose. * Named here rather than guessed from the word "pause": a game may call it * `menu` or `escape`, and the bot notices the way a player would. */ froze: string[]; /** * Actions the bot stopped pressing because they STARTED THE LEVEL OVER. * * The sibling of `froze`, and it hid behind it: a run inside a pause menu * measures nothing, and a run that reloads the scene measures nothing EITHER * — except that one is over, because the bot is bound to the tree it began * in. Measured on one losable scene, ten runs: `lost 10/10` without a * mid-play restart, `lost 0/10 · swapped 10/10` with one, and the rung read * `? 10 runs played without reaching a win (10 changed scene)`. * * Mid-run retry is ordinary — puzzle, racing, tower defense — and the * engine's own `GameFlow` restarts only from a terminal state, so the games * this ruined are the ones that wired `restartScene` themselves, exactly as * the skills tell them to. Observed, never read off the word "restart": the * scene being played was replaced by that same scene. */ restarted: string[]; /** * Did `findPlayer` find anyone to drive? * * `findPlayer`'s own comment says a scene it cannot drive "says so rather * than guessing" — and nothing said so. With no player the driver moves * nothing, `wentAsFarAs` stays 0, and every run is reported `✗ stuck in * 8/8`: the word for a character wedged in geometry, used for a scene that * has no character. Measured on the shipped `examples/2d-phaser-basic`, * whose whole tree is a ground, a sprite and a camera: * * ``` * ✗ stuck in 4/4 * danger: the player took damage 0 times in 4 runs — nothing here can hurt you * ``` * * Both sentences are about a player that does not exist. The `plays` rung * needs to tell the two apart before it can call `stuck` a defect. */ hasPlayer: boolean; /** * The per-run clock the caller asked for, in simulated seconds. * * The header used to read this off the FIRST run's length, which is the one * number guaranteed to be wrong when something ends runs early: a 60-second * playtest whose every run died at a second announced itself as "20 runs × * up to 1s", hiding the very fact it was reporting. */ seconds: number; /** * Sound and effects: what the scene declares, and what actually fired. * * A game whose feedback is wired but never triggered plays perfectly and * feels dead — invisible in every other number in this report. */ feedback: { declaredAudio: string[]; declaredEffects: string[]; heard: string[]; shown: string[]; }; } /** * Who the driver is playing as. * * In order: the body carrying a character controller, then group `player`, then * a node literally called Player. A scene that satisfies none of them is not * one this can drive, and says so rather than guessing. */ declare function findPlayer(root: Node): Node | null; /** Play the scene `runs` times and report what happened. */ declare function playtest(json: unknown, opts?: PlaytestOptions): Promise; /** * The report, as prose an agent reads and a person skims. * * Deliberately says what is WRONG and stays quiet about what is fine — a wall * of green is a wall nobody reads. */ declare function playtestText(report: PlaytestReport): string; /** * Did this playtest find something WRONG? — the exit code, as a function. * * `incanto-playtest` exited 1 whenever no run reached a win, and its own report * says when that is not a failure. Measured on a walkabout scene: * * ``` * · no win declared in this scene — nothing emits won/lost, so there is * nothing to reach * · 2/2 ran out of time still playing * $ echo $? * 1 * ``` * * The `·` is this file's own marker for "not applicable", printed above an exit * code that says failure — so a CI step on a walkabout, a title screen, or any * scene whose ending is a quest a random walker cannot finish is red forever, * and its author learns to stop reading it. `incanto-verify` already reads the * JSON and calls those `unmeasured`; only the standalone bin disagreed. * * What stays a failure regardless of a declared win: a run that THREW, and a * run that left the world. Neither is a matter of what the scene set out to be. */ declare function playtestFailed(report: PlaytestReport): boolean; /** The failing runs worth saving, newest problem first. */ declare function failingReplays(report: PlaytestReport): Array<{ seed: number; outcome: Outcome; replay: JsonValue; }>; //#endregion //#region src/test/verify-ladder.d.ts /** * What a spawned tool actually SAID, given its stderr. * * Used by every rung in `incanto-verify`; it lives here rather than in the bin * so it can be tested without a built `dist` — the bin loads the library at * runtime, so a regex only the bin knows is a regex nothing checks. */ declare function firstLine(text: string | null | undefined): string | null; type RungStatus = "pass" | "fail" | "unmeasured" | "skipped"; interface RungResult { name: string; status: RungStatus; /** One line: what this rung found. */ summary: string; /** What to do about it, when there is something to do. */ fix?: string; /** * Things the rung's own tool said that are not failures — printed under it. * * The `loads` rung runs `incanto-check`, which returns * `{ok, results:[{file, ok, warnings}]}`, and the rung read `ok` and threw * the warnings away. So a scene `incanto-check` describes as *"nothing lights * this 3D scene … It will render black"* came back from the headline command * as: * * ``` * ✓ loads — the scene is legal and its assets resolve * ``` * * The ladder's own header table names `incanto-check` as the tool for "the * scene will not load, OR RENDERS BLACK". It ran it, was told, and did not * pass it on. */ notes?: string[]; } interface LadderVerdict { ok: boolean; rungs: RungResult[]; /** Rungs that could not be measured — questions unasked, not answers. */ unmeasured: string[]; /** The single next action, or null when there is nothing to do. */ next: string | null; /** Scenes found when none was named (empty once one is being verified). */ candidates: string[]; /** * Did anything get asked at all? * * `ok` answers "is anything WRONG", and with no rungs the honest answer to * that is yes-nothing-is-wrong — which is how `incanto-verify` printed * *"2 scenes here, and guessing between them would verify the wrong one"* * and exited 0. A CI step whose whole job is this command then goes green * having looked at nothing, which is the exact shape the gate exists to * prevent. Callers that need a verdict rather than an absence read this. */ measured: boolean; } interface LadderContext { /** Scenes found when none was named — none, or more than one to choose from. */ candidates?: string[]; } /** * The `says` rung, from `incanto-logs`' report — the one rung whose whole job is * "what is the running game SAYING". * * It read `ok` and printed "no errors, no failed assets", which threw away * every WARNING the page had reported. The engine warns about exactly the * things that leave a game running and wrong — a camera whose `follow` resolves * to a node with no position, a `DayNight` with no sky to move, a `Buoyancy` * with no water, a body with no collider — and the headline command showed a * tick over all of it. * * Warnings are NOT a failure (an author may have chosen to live with one), so * this keeps `pass` and carries them the way `loads` has always carried * `incanto-check`'s: as notes under the rung, printed with a `!`. * * Pure, so it can be tested without a browser — the bin owns fetching the * report, this owns what it means. */ declare function saysRung(report: { ok?: boolean; counts?: { warn?: number; }; grouped?: readonly { level: string; message: string; count: number; }[]; assetErrors?: readonly { ref?: string; error?: string; }[]; worst?: { message?: string; } | null; stats?: { errors?: number; }; } | null): RungResult; /** * What to say when the bot could not WALK — which is not the same as could not * play. * * This branch was "nobody to move", said about games the bot had played for * eight runs. The MOUSE half was fixed when a flip puzzle whose every tile the * bot clicked was told to add a character controller; the KEYBOARD half was * found the same way, one genre along: a sokoban declares `move`, `undo` and * `restart`, a behaviour reads them every frame and moves plain nodes, and the * bot pressed them 989 times across eight runs — while the rung said * * ? plays — nothing here is drivable — 8 runs had nobody to move and * nothing to click * * Both halves of that sentence were false. A whole class — sokoban, snake, * tetris, roguelikes, typing games, anything on a grid — has no character to * add, and the advice was to add one anyway. * * PRESSED and READ, not merely declared: `inertActions` is the report's own * list of actions nothing asks about, and a scene where every one of them is * inert really does have nothing driving it. * * Pure, so the wording can be tested without running a game. */ declare function unmeasuredPlays(report: { runs?: readonly { clicks?: number; actionPresses?: number; }[]; actions?: readonly string[]; inertActions?: readonly string[]; hasPlayer?: boolean; declaresWin?: boolean; behaviorsLoaded?: boolean; scene?: string; }): { summary: string; fix: string; }; /** * What the run HEARD and SHOWED, as one rung. * * Pure, like `saysRung` and `unmeasuredPlays`: the bin owns running the * playtest, this owns what its feedback log means. Extracted when the * all-silent branch was found printing a COUNT with nothing beside it — * `racing-3d` reports 20 emitters and its scene declares none of them (they are * a pool of `new Particles3D()` built in `onReady`), so an author counted * twenty in a message and found zero in their JSON. */ declare function feelsRung(fb: { declaredAudio?: readonly string[]; declaredEffects?: readonly string[]; heard?: readonly string[]; shown?: readonly string[]; } | null | undefined, opts?: { /** * Did the bot get the game MOVING at all? * * Zero emitters firing is a broken wire only if something was played. On * `racing-3d` — 20 pooled `Particles3D`, a car that needs a SUSTAINED * throttle — a random presser never gets it rolling, every run comes back * `stuck`, and this rung printed `✗ 20 … and not one fired in any run` * about a game whose emitters all fire the moment anyone drives it * (measured: hold throttle + steer for 12s, all 20 fire). * * The partial case beside it has always said the two are indistinguishable * — "either a broken wire or a moment random play never reached" — and the * zero case has exactly the same ambiguity when nothing was driven. Same * flag the `plays` rung uses, so the two never disagree about whether the * bot played. */ played?: boolean; }): RungResult; declare function ladderVerdict(rungs: RungResult[], ctx?: LadderContext): LadderVerdict; /** The ladder as a person or an agent reads it: every rung, then the one action. */ declare function ladderText(v: LadderVerdict): string; //#endregion //#region src/test/index.d.ts /** One thing that is not standing where it should be. */ interface GroundingIssue { /** Scene path, e.g. `/World/Footbridge/PostL0`. */ path: string; /** Metres between the node's underside and the ground: + floats, − buried. */ gap: number; } interface GroundingOptions { /** How far a foot may sit off the ground before it is a bug (default 0.1 m). */ tolerance?: number; /** Node types to check (default: the ones that read as props on a map). */ types?: readonly string[]; } /** * Every prop that is floating or buried. * * THE most repeated mistake in scene authoring, by humans and agents alike, is * a hand-computed Y: correct when it is written and wrong the moment anything * reshapes the ground under it — a river carving its bed, a new seed, a * generator that mirrors a carve slightly differently. This is the check that * catches it, and `snapToGround` is the fix that stops it happening. * * Anything explicitly marked `snapToGround: false` is skipped: that is the * author saying "yes, this hangs in the air", which a bridge deck, a lantern * and a cloud all legitimately do. * * ```ts * const off = findFloatingProps(gameJson); * ok(`nothing floats (${off.length})`, off.length === 0); * ``` */ declare function findFloatingProps(json: unknown, opts?: GroundingOptions): GroundingIssue[]; declare function registerAllNodes(): void; interface NodeCapture { /** Absolute path ('/Root/Player') — stable, grep-able. */ path: string; type: string; uid?: string; groups?: string[]; tags?: Record; /** The attached behavior's name, if any. */ script?: string; /** CURRENT value of every schema prop (not delta — captures runtime state). */ props: Record; /** * What this node actually PAINTS, when that differs from its props. * * A capture printed `text="@t:menu.start"` — byte-identical in every * language — so it proved nothing about localization. Absent unless the * painted words differ from the authored ones. */ text?: string; } interface SceneCapture { name: string; dimension?: "2d" | "3d"; nodes: NodeCapture[]; } /** Snapshot the live tree: every node, every schema prop's current value. */ declare function captureScene(scene: Scene$1): SceneCapture; /** * Render a capture as one grep-able line per node, showing only NON-DEFAULT * props — the same delta discipline as scene JSON, so the interesting state * stands out. */ declare function describeCapture(capture: SceneCapture): string; interface ValidateSceneOptions { /** Assets the rest of the PROJECT declares — see `LoadSceneOptions`. */ declaredAssets?: Record; /** Real behavior classes to register (hot-replacing) before loading. */ behaviors?: Record; /** Hard-fail on unregistered behaviors instead of stubbing them. */ strictBehaviors?: boolean; resolveScene?: LoadSceneOptions["resolveScene"]; } interface ValidationResult { ok: boolean; /** The hard load error, when ok is false — `details` is machine-readable. */ error?: IncantoError; } declare function validateScene(json: unknown, opts?: ValidateSceneOptions): ValidationResult; interface RunContext { engine: Engine; scene: Scene$1; /** Simulated time of the current step, in ms. */ timeMs: number; /** Resolve a node path relative to the scene root. */ getNode(path: string): Node; /** The same lookup, null for a node that is not there — a gem already taken, a foe already dead. */ getNodeOrNull(path: string): Node | null; /** Snapshot the scene right now. */ capture(): SceneCapture; } interface ScriptStep { /** Simulated time at which this step applies (before that frame's tick). */ atMs: number; /** Press a button action (held until `release`). */ press?: string; /** Release a previously pressed button action. */ release?: string; /** Set a vector2 action: [action, x, y]. (0,0) clears. */ vector?: [string, number, number]; /** Raw key-code fallback: [code, isDown]. */ key?: [string, boolean]; /** * Click a node by PATH, or a point `[x, y]` — the mouse, as one step. * * `Clickable` reads the mouse BUTTON, not an action, and needs * `engine.picker`, which headless is null because there is no renderer to * raycast with. So every mouse-driven genre — match-3, tower defense, cards, * point-and-click, RTS, idle, a flip puzzle — could not be played by its own * harness: `press`/`release`/`vector`/`key` are all ACTIONS. * * The press and the release land on two different frames, which is what * `Clickable` requires and the part a hand-rolled version forgets. A * `geometricPicker` is installed for the run unless the game already has one * of its own — the game's answer always wins. * * A FUNCTION for the case a mouse game is usually in: click whatever is there * NOW. A whack-a-mole cannot name its target when the script is written, and * `null` means "nothing to click this time", which is not a failure. */ click?: string | [number, number] | ((ctx: RunContext) => string | [number, number] | null); /** * Move the cursor, without pressing anything. * * A node path or a point, resolved exactly as `click` resolves one. On its * own this is a HOVER — which is a state a game shows (a highlighted tile, a * tooltip, a cursor that changes) and nothing could reach. * * With `mouse` it is the other half of a DRAG, which `click` cannot express * at all: `click` presses and releases at one point, and a slingshot, a * drawn selection box, a swipe and an inventory item carried between slots * are all "press HERE, move THERE, let go". Measured on a drag-and-launch * game: the launch fired on every shot and the drag vector was always zero, * so the ball dropped where it stood and the harness said the run was fine. */ at?: string | [number, number] | ((ctx: RunContext) => string | [number, number] | null); /** * Press or release the mouse where the cursor is, as separate steps. * * `'down'` holds the button until a later `'up'` — across as many frames and * moves as the script likes. `Clickable` still fires on the release over the * node the press started on, so a drag that wanders off is not a click, which * is what the player already expects. */ mouse?: "down" | "up"; /** Which mouse button `mouse` (and `click`) uses: 0 left · 1 middle · 2 right. */ button?: number; /** Arbitrary imperative hook. */ do?: (ctx: RunContext) => void; /** * Inline check. A failure is `false`, a non-empty STRING (used as the * message), or a throw; the run continues either way. * * The string form exists because it is what people write. A harness that * says WHY it failed reads `(ctx) => problem ?? null` — and this API took * only `false`, so every one of those checks passed by not being `false`, * and a tower-defense harness printed `VERIFY OK` over a keep that had * fallen. Anything else — a number, an object — is recorded as a failure * naming the contract, because a check nobody can read is not a check. */ assert?: (ctx: RunContext) => boolean | string | null | undefined; /** Names this step in failure reports. */ label?: string; } interface RunFailure { atMs: number; label?: string; message: string; } /** * A `do:`/`assert:` step that ran with the game clock STOPPED. * * `GameFlow` sets `timeScale = 0` on a win or a loss (and a pause menu does the * same), after which `engine.step()` advances nothing. A harness that lost its * player three times inside one step then measured `0.00` for everything * after — the position it had just teleported to, no travel, no apex — and * the run was `ok` with nothing said beside the zeros. `before` is whether the * clock was already stopped when the step began (every read in it was stale) * or stopped by the time it finished (the step itself ended the game). */ interface RunStalled { atMs: number; label?: string; before: boolean; } interface RunScriptOptions { /** Total simulated duration in ms. */ durationMs: number; steps?: ScriptStep[]; /** Real behavior classes (hot-replacing). Unregistered ones still hard-fail. */ behaviors?: Record; /** Fixed-update rate (default 60). */ fixedHz?: number; /** Seed for engine.rng — same seed, same run. */ seed?: number; /** Physics: 'auto' (default — by scene dimension), '2d', '3d', or false. */ physics?: "2d" | "3d" | "auto" | false; /** Collect a snapshot every N simulated ms. */ snapshotEveryMs?: number; /** * Called after EVERY simulated frame, with the live context. * * `steps` fire at named times, which is the wrong shape for anything that has * to be watched continuously — the apex of a jump, the closest a chaser ever * got, whether a shell cleared the wall between two named moments. Every * harness in this repo wrote the same workaround: one `do:` that steps the * engine by hand, which moves the clock out from under the script's own * steps. A throw in here is a reported failure, like a throw in a step. */ onFrame?: (ctx: RunContext) => void; /** * Run the whole script in this language. * * Without it every headless check ran in English, so a translation could * only ever be verified by opening a browser and reading — which is exactly * the loop the ladder exists to replace. */ locale?: string; resolveScene?: LoadSceneOptions["resolveScene"]; } interface RunResult { /** True when no step assertion failed. */ ok: boolean; failures: RunFailure[]; /** Steps that ran with the clock stopped — see `RunStalled`. Not failures. */ stalled: RunStalled[]; snapshots: Array<{ atMs: number; capture: SceneCapture; }>; /** Everything the game logged via engine.log / this.log. */ logs: readonly LogEntry[]; finalCapture: SceneCapture; /** Human/agent-readable run report. */ describe(): string; } /** * Load a scene and play it headlessly at a fixed timestep, driving input by * ACTION intent. This is the e2e loop without a browser: script → simulate → * snapshot → assert. */ declare function runScript(json: unknown, opts: RunScriptOptions): Promise; interface PlaySessionOptions { behaviors?: Record; /** Stub unregistered behaviors (structure-only play — the CLI default without --behaviors). */ stubMissingBehaviors?: boolean; seed?: number; fixedHz?: number; physics?: "2d" | "3d" | "auto" | false; resolveScene?: LoadSceneOptions["resolveScene"]; } interface PlaySession { engine: Engine; scene: Scene$1; /** Simulated time so far, ms. */ readonly timeMs: number; /** Advance the simulation by ~ms (whole fixed steps). */ step(ms: number): void; /** The CURRENT runtime state in scene-JSON format — loadable, diffable. */ capture(): SceneJson; /** Grep-able one-line-per-node text view of the current state. */ describe(): string; /** * What the camera can SEE right now: on screen / off screen / behind, screen * position, what lights the scene, and colliders that intersect. No GPU. * * `describe()` tells you a gem is at [4, 1, -2]. This tells you whether you * can see it — which is the question you actually had. */ framing(): string; /** Engine log entries since the last drain. */ drainLogs(): LogEntry[]; dispose(): void; } /** * The headless play loop behind `incanto-play`: load → feed inputs by intent * (`session.engine.input.pressAction(...)`) → `step(ms)` → `capture()` the * state AS A SCENE FILE. What a screenshot is to humans, the capture is to * agents: complete, structured, and in the exact format they already read. */ declare function createPlaySession(json: unknown, opts?: PlaySessionOptions): Promise; //#endregion export { type AgreementSnapshot, type CollectionSnapshot, type Disagreement, type DisagreementKind, type FacingOptions, type FacingReport, type FeelOptions, type FeelReport, type FramingEntry, type FramingOptions, type FramingReport, GroundingIssue, GroundingOptions, type LadderVerdict, type MultiplayOptions, type MultiplayPlayer, type MultiplayReport, NodeCapture, type Outcome, type PickerOptions, PlaySession, PlaySessionOptions, type PlaytestOptions, type PlaytestReport, type PlaytestRun, RunContext, RunFailure, RunResult, RunScriptOptions, RunStalled, type RungResult, type RungStatus, type SceneAssetUrl, SceneCapture, ScriptStep, ValidateSceneOptions, ValidationResult, type Where, agreementProblems, auditScene, captureScene, collectionProblems, createPlaySession, describeCapture, describeFraming, facingReport, facingText, failingReplays, feelReport, feelText, feelsRung, findFloatingProps, findPlayer, firstLine, framingText, geometricPicker, geometricView, instancedScenes, ladderText, ladderVerdict, multiplayProblems, multiplayText, playMultiplayer, playtest, playtestFailed, playtestText, pointAtNode, registerAllNodes, runScript, saysRung, sceneAssetUrls, takeAgreementSnapshots, takeCollectionSnapshots, unmeasuredPlays, validateScene };