--- name: author-scenario description: > Author event-driven LunCoSim scenarios for missions, waypoints, reactions, or multi-entity coordination. Use when working with `task`, `mission`, `on_event`, `RunScenario`, `nav_to`, `emit`, or persistent `this` state. Scenarios own sequencing and policy; Modelica owns continuous control math, USD owns scene structure and wiring, and authoring-vessel-controllers owns vessel GNC. --- # Authoring scenarios A **scenario** is a rhai program attached to an entity. Production scenarios are task/event-driven policy. They must not define `on_tick`; that hook is reserved for authored tests under `assets/scenarios/tests/` to sample live telemetry and publish a bounded verdict. Continuous rover dynamics remain in fixed-step physics/Modelica. Runtime plugins install their own cycles from the selected application composition. A scenario may declare its peer-role execution target with `// @peer host|client|both` and its cadence with `// @timing simulation`; `host` includes standalone authoritative mode. These directives do not choose its Core/Application/Twin owner or install schedules. Rust assigns the owner route and retains schedule ownership. Unsupported metadata disables only that program and publishes a document diagnostic for its source revision. Keep runtime errors visible; do not catch and erase them as successful no-ops. Scenario actors submit, commit, and run in `GlobalEntityId` order from the source-owned component, not from ECS query order or the API lookup index. A local-only host without a global identity uses a world-local Bevy key and is not part of cross-session replay ordering. If one scenario's synchronous command must be visible to another in the same pass, test that handoff through a production Rhai scene with stable actor identities. For mission operations, read the generic [mission and engineering quality gates](../interactive-component-authoring/references/mission-engineering-quality.md) before authoring the scenario. Treat the ConOps, mode transitions, command and telemetry contract, timing/resources, nominal path, and contingency/recovery paths as explicit requirements. Exercise fault stimuli and safe/degraded modes through event-driven policy and tests; do not turn missing telemetry or an invalid command into a silent fallback. For live route edits, Rhai owns the route policy and calls the generic typed USD operation command. The reusable `waypoint_editor` tool authors ordinary USD route points, whether their route scope is inline or composed from a separate route-plan asset. Document authoring composes the base, runtime, and view layers at their actual strengths before validating selected-variant children; the `route_variant_lifecycle` production gate covers add, move, and delete on that route shape. It uses a local `active=false` opinion when a point comes from a reference arc, and updates the disposable ribbon in the document's `@view@` layer under the selected route scope from the committed USD route. An edit can use the selected route point to resolve its enclosing route and does not require rover possession; multiple routes still require an explicit route or subject selection. A selection-only delete resolves the selected point when no pointer target is present. Bind a route program to its composed USD document and build its ribbon from the matching `usd.document.projected` event; that event is the authoritative boundary for the route identity and does not wait for Modelica admission. Keep `on_visualization(me, ctx)` for presentation preparation that does not depend on a document attachment that may still be settling. This one-shot callback runs in `PreUpdate`, after the time spine and before the first fixed tick, top-level initialization, or `on_start`; use the host id, parameters, and read-only queries. Rhai blocks world writes and events there; `ApplyUsdTransientOps` is the only available command and always edits the disposable view layer. Keeping the ribbon under that scope preserves the route points' USD parent frame. The subject and route owner remain live: route edits do not rebuild or reset their physics, Modelica state, pose, possession, or Rhai `this` state. The authored `inputs:enabled` value is only an initial policy; F changes the scenario's runtime state and must not write it back to USD. Do not implement this policy with a polling `on_tick` loop or move USD identity resolution, terrain sampling, collision events, or fixed-step steering into Rhai. Document-backed route edits remain valid during the short projection interval: the generic resolver uses a locally authored target before its live ECS entity exists. If the canonical child list is not synchronized, the editor reports a retryable error rather than guessing a point name or writing to a different program. Composed-only paths still require the mounted canonical stage. When a source is hot-swapped, the program emits the generic typed `program.ready` event after `on_start`; one-shot UI or control actions wait for that lifecycle edge rather than relying on a timer. Sensor events may carry a nested collider; match the entrant through the generic `parent()` chain to the authored subject instead of adding a route-specific child relationship. If the mounted document is attached after `on_start`, defer document-backed route reads and task actions until the matching `usd.document.projected` event binds it; do not retry against an empty identity each task pass. Initial scenario admission waits for all world and entity readiness holds because a program may depend on entities outside its own hierarchy. After admission, an entity hold idles only scenarios attached within that subtree; release resumes them without a stop/start cycle. Fixed-step hooks run after `SimTickSet` and release only events stamped before the current tick. Paused simulations deliver discrete events on the next `Update` without advancing the tick. The first `on_start` does not replay events from before the program started. Query current state from the owning subsystem for startup decisions, then use `on_event` for later transitions. Connected co-simulation event outputs are edge-detected on admitted simulation ticks after `SimTickSet` and the full scripting pass. During startup holds, an edge may occur before scenario execution opens and is not replayed to a later `on_start`; read the current state from its owning subsystem during startup. Event time comes from `MissionClock` at the producer's `SimTick`; later edges are delivered on an eligible scenario pass. Use `simulation_dependencies(me, ctx)` to declare simulation-clock Modelica port/event dependencies, generic live entities accessed through direct `get`, `port`, `ReadPorts`, or direct mutation, and broad API query providers. The owner uses the plan for startup admission and runtime access checks. Return all five plan arrays even when empty. Omitting a field is a source error: ```rhai fn simulation_dependencies(me, ctx) { #{ modelica_entities: [find_path("/Rover/Controller")], entity_reads: [], entity_writes: [], query_reads: [], required_inputs: [ #{ owner: "sysml.twin-analysis", identity: ctx.twin_name }, ], } } ``` Modelica entities join the shared fixed-step causal barrier. Required input keys name an owner namespace and an exact identity; the generic runtime waits on owner-published Pending/Ready/Failed state before top-level initialization and `on_start`. A pending scenario keeps its existing activation hold and resumes after the owner publishes a new state revision, so scripts should not poll analysis from `on_tick`. A missing owner or failed input produces a diagnostic. Keep the selection policy in Rhai and use the key contract exposed by the domain owner. Every `modelica_entities` id must resolve to a live Modelica participant; a live but unrelated entity fails the source revision before initialization. Declare every Modelica participant this scenario reads or writes through a port or whose event it consumes. The aggregate solver barrier is not an access grant: membership through USD connections or another scenario's plan does not authorize this scenario to read that participant. `modelica_entities` grants both read and write access to those live Modelica participants and adds them to the shared simulation barrier. `entity_reads` and `entity_writes` grant directional access to any live entity; a Modelica entity in either set also joins the shared barrier. Direct reflected and port reads require read access, while direct reflected writes, port writes, and structural verbs require write access. Entity-targeted query providers report the ids they read. Declare those target ids in `entity_reads` too; this includes `QueryEntity`, `QueryPhysicsState`, `ReadPorts`, `GetPort`, `SolarPose`, and single-entity `ListPorts` calls. Mounted-scene USD queries without `doc_id` use the committed Twin generation. For broad providers such as `EntitiesInRadius`, `Raycast`, or `CosimStatus`, list the public provider name in `query_reads`. This declares the provider's whole snapshot as a coarse dependency. Broad queries cannot run while the plan is being resolved; after commit, the same declaration authorizes the scenario-owned top-level initialization and lifecycle hooks. Undeclared simulation-clock access through direct `get`, `port`, or `ReadPorts` requires the matching direction. Targeted Modelica commands and Modelica event delivery require the target or producer in this scenario's own plan; aggregate barrier membership is not authorization. Hierarchy and spatial identity queries remain available for discovery. If a scenario uses their returned ids in direct `get`, `port`, or mutation calls, it must include those ids in the corresponding access set. When a typed simulation command creates an entity whose id was not available while the plan was prepared, call `track_entity_read(id)` or `track_entity_write(id)` after the command has materialized the live entity and before accessing it. This commits access in serialized simulation order; if the entity is a Modelica participant, it also joins the scenario's barrier. The reusable route marker is a translucent, unlit, shadowless annotation. Its unvisited colour is bright green and its visited colour is gray in standard `primvars:displayColor`; `route_follow` applies the visited colour through `waypoint_editor`'s transient USD view operation. Route progress is keyed by USD point path and survives autopilot stop/start. At `on_start`, the program reads the current Avian contact for only the first unvisited route point through the generic `SensorOccupants` query. A simultaneous occupancy snapshot has no visit order, so it cannot mark later points. A later sensor enter marks a visit only when its zone is the first unvisited point; out-of-order arrivals are ignored until that point is entered after its predecessors. Active route progression uses the same order. This is presentation state, not a vessel component or a second route fact. The route context gesture opens its authored menu without selecting the point; selection and gizmo activation require the menu's explicit select action, and click-to-place movement requires the separate Move action. Pass the resolved document, route, and direct point in each menu action context so a later callback does not depend on viewport query scope. Resolve a route-bearing pointer target before a previously selected or controlled subject, especially when one subject has multiple route programs. User possession is a `ControlLink`/`SessionRegistry` lifecycle, while a route program is guidance policy. Releasing possession hides the vessel HUD and releases manual input holds; an enabled route then republishes its active guidance target without claiming the user's session. Route policy owns any stop setpoint and writes it explicitly. Repossession restores the HUD without restarting the route. Script source edits made by a user go through the `ScriptDocument` host, so undo, redo, and the Twin journal see the same typed `ScriptOp`. A file-backed or USD-embedded source refresh uses the shared external-baseline path instead; it advances the runtime generation without duplicating the source owner's journal entry. The disposable ribbon follows the same rule: its typed `@view@` projection is rebuilt from the committed route and is never authored as Twin content or placed in user history. Reusable authored programs are managed through the generic Rhai `program_editor` tool. It lists `LunCoProgramAPI` children, selects or opens a program in the existing editor, atomically switches its `info:sourceAsset` arm, and can attach a new source-backed program without knowing whether the owner is a rover, lander, route, or another model. Twin policy may enable the standard `program-browser` surface on any USD scope with `program_editor::enable_hud(doc, scope, visibility)`. The surface emits typed semantic actions; it does not contain vessel-specific Rust or an ad-hoc text input protocol. A subject may bind several programs through `rel programs`; the browser and generic scene selection keep one canonical program path active, so editing or F control never falls back to a hardcoded `/Route`. > **Host = mechanism, script = policy.** A scenario touches the world only > through the same command/query API the HTTP API, MCP, and UI use — so it > inherits every command for free and stays decoupled from physics. Scene construction is an authoring/preflight concern, not production scenario logic. Use the generic Rhai `model_authoring::scene_recipe` before launching a scenario to place authored assemblies/terrain, define cameras and initial state, and record route/program hand-offs. Use `model_context`, `readiness_report`, and `port_graph`/`wiring_plan` to validate the exact document, paths, and generation first. Apply returned USD ops through `assembly_edit`; pass route entries to `waypoint_editor` and program entries to `assembly_edit::attach_program`. Keep mission sequencing here, continuous math in Modelica, and do not turn the recipe into an `on_tick` loop. See the [model-authoring guide](../../docs/scripting-guide.md#model-and-assembly-authoring-human-and-ai). **Scope boundary — do not blur these:** - **Control MATH** (PID, mixing, force/torque) → Modelica, NOT rhai. If you're writing a per-tick control loop here, stop — see [`authoring-vessel-controllers`](../authoring-vessel-controllers/SKILL.md). - **Scene structure / spawning geometry / wiring** → USD. - **Vector and angle math is already NATIVE — never write it in a script.** `vadd` `vsub` `vscale` `vlen` `norm_squared` `vdot` `vcross` `vnorm` `qrot` `clamp` `angle_deg` `yaw_delta_deg` are Rust (`lunco_scripting_rhai_core::rhai_math`, on glam). Existing array operands remain supported; hot-loop code should use native `Vec3`/`Quat` from `world_pos3`, `world_forward3`, and `world_rotation_quat`, lowering with `vec3_array`/`quat_array` only at a command, telemetry, or USD-literal boundary. Reimplementing one in rhai is how four scripts ended up with four copies of the same broken `acos` guard. - A scenario **senses and decides**; it drives via high-level verbs (`nav_to`, `drive`, `cmd`), reacts to events, and sequences phases. **The two rules that make the math surface safe:** 1. **Array reads preserve the observer contract** and return `()` when there is nothing to measure — a `()` input, a wrong-length array, or a degenerate orientation. Native constructors/operations instead raise a script error for malformed values, so an authored program cannot silently continue with a poisoned native pose: ```rhai let d = yaw_delta_deg(this.fprev, world_forward3(me)); if d != () { this.yaw += d; } // skip the tick, don't poison the sum ``` 2. **Angles are PER-TICK DELTAS.** `yaw_delta_deg` saturates at 180°, so a total swept angle is accumulated from deltas — never measured start-to-end. Past half a revolution a direct measure folds back and reads as a turn the other way. Full reference: [`docs/scripting-guide.md`](../../docs/scripting-guide.md). The authoritative callable surface in one place: the `ScriptingCatalog` query. ## 1. Lifecycle hooks — the shape of every scenario Define any subset. First param (`me`) is the host entity id. Production progression is returned by `task(me, ctx)` and advanced by the native behavior kernel; `mission(me, ctx)` supplies durable objective tracking. Lifecycle/event hooks remain available for setup, reactions, and teardown. ```rhai fn task(me, ctx) { seq([wait_until(|m| arrived(m, GOAL, 2.0))]); } fn mission(me, ctx) { [objective("survey", #{})]; } // optional fn on_start(me, ctx) { this.i = 0; } // once, after declared inputs are ready fn on_event(me, evt, ctx) { if evt.name == "GO" { /* … */ } } // event-driven policy fn on_stop(me, ctx) { brake(me); } // hot-reload / detach / despawn // Bounded sampled observer (tests only): // fn on_tick(me, ctx) { this.samples.push(query("rover_status", #{id: me})); } ``` **The state rule that trips up everyone (get this right first):** - rhai `fn`s are **pure** — they CANNOT see top-level `let`s. Thread all persistent state through **`this`**. - `this` is the persistent scenario-state map. Direct lifecycle/mission drivers receive the host entity id as `me`; task leaves receive that same id as their one positional argument and are authored as anonymous closures (`|me| ...`). The native task driver binds `this` while invoking those closures, and owns the task cursor/dwell/event state. Named callbacks are also supported as `Fn("name")` when declared `fn name(me)`; the driver binds the same persistent state map as `this`. - Rhai map arguments are value/copy-on-write values. A helper that assigns a persistent field must return the updated map, and the lifecycle callback must assign it back to `this`; helper-side field assignments alone do not persist. - Hot-reload runs `on_stop` before installing the new program state; initialize all required `this` fields in the new run. - Scenario initialization and readiness already have distinct owners: `simulation_dependencies` declares admission, the module body initializes per-instance state after admission, and `on_start` is the ready callback. Do not add duplicate `init`/`ready` hooks. `on_event` runs in the scenario's eligible Simulation pass (or paused Lifecycle pass); a listener does not select the producer's clock. Any future UI, Interaction, or Presentation listener needs its own cycle owner, inbox, and state, with typed messages across owners. - For stateless Rhai decisions outside the scenario Simulation lane, use the existing owner-scheduled hook registry when that subsystem exposes a typed hook. The owner supplies its facts and runtime context, then validates and applies the result at its own boundary. Do not move one stateful scenario instance between cycles or create per-cycle scenario callbacks for policy that an owner-invoked hook can express. ### Owner scope and reload Rhai hooks inherit the owner scope chosen by their Rust host. A Twin-owned scenario carries that Twin's stable ID and scene generation in `execution_context()`; application and Core policy runtimes keep their own scope across Twin transitions. Do not infer ownership from the currently active Twin. A script projected from USD is scene-owned and is stopped when that scene is replaced; a file-backed tutorial launched for a Twin uses an isolated host and is stopped, with its source handles and document, at `TwinClosed`. For a persistent Twin-owned host using `reload_policy: "Retain"`, scene replacement preserves `this` and does not repeat initialization or `on_start`. The owner reruns `simulation_dependencies` against the new scene generation before admitting subsequent hooks. Resolve scene entity identities in that hook; retaining VM state does not retain outgoing entity access rights. The outgoing Twin ID remains available to `on_stop` even after workspace selection changes. Pending asset completions are accepted only while that specific Twin remains mounted. Application-owned scenarios on `WorldRoot` are not replaced by Twin tutorial launches and are not recompiled because another Twin's scene generation changed. ## 2. The verb surface (host bridge — everything else is prelude) | Verb | Purpose | |---|---| | `cmd(name, #{params})` | **WRITE** — fire any `#[Command]` by name; returns `#{id,ok,data,error}` (`data` carries e.g. a spawned gid) | | `query(name, #{params})` | **READ** — any read-only query provider (Raycast, Nearest, GroundHeight, `CausalTrace`, …) | | `get(id,"Comp.field")` / `set(id,"Comp.field",v)` | reflected component read / write | | `world_pos(id)` / `world_forward(id)` | float-origin-correct array pose (use these, never raw `Transform`) | | `world_pos3(id)` / `world_forward3(id)` / `world_rotation_quat(id)` | native glam `Vec3`/`Quat` pose for hot loops; lower explicitly at wire boundaries | | `find(name)` / `name(id)` / `usd_path(id)` / `parent`/`children` | entity lookup + hierarchy; `name` is presentation, and `usd_path` reads stable USD identity metadata and is available during dependency planning | | `owner_of(id)` / `controller(id)` / `is_controlled(id)` | who's driving (human vs AI vs unowned) | | `emit(name, value?)` | fire a `TelemetryEvent` stamped with the simulator `sim_secs` and `sim_tick` (fixed-step delivery waits for a later `SimTick`; a paused simulation uses the next `Update` pass); scalar, array, and map payloads keep their typed structure. During a world-level readiness hold, the shared scenario gate is closed and events are not queued. | | `sim_tick()` / `dt()` / `elapsed_seconds()` | available only in simulation-cycle calls; each returns a Rhai error in paused lifecycle and one-shot REPL/tool calls | | `execution_context()` | read-only owner scope, cycle, phase, clock sample, logical sequence, and event producer stamp | | `rand()` / `rand_range(lo,hi)` | **deterministic** RNG (seeded by entity, event producer or cycle sequence, and hook; discrete lifecycle hooks use a sequence-free seed) | | `despawn(id)` / `add`/`remove`(id,"Comp",…) | structural. **Spawn:** `cmd("SpawnEntity", #{entry_id, position, producer_id?})` — no generic spawn. Twin Rhai uses its actor identity; actorless Rhai supplies a stable producer id. | | `notify(msg)` / `notify_kind(msg,kind)` | HUD notification | JSON appears **only** at the `cmd`/`query` params seam. `get`/`set` are native reflect — no JSON round-trip. ## 3. Prelude helpers (hot-reloadable policy — no Rust rebuild) `assets/scripting/prelude/*.rhai`, one file per topic. Read them for the full list. Highlights: - **Nav:** `drive(rover,fwd,steer)`, `brake(rover)`, `nav_to(entity,target,speed,radius)` (returns true on arrival). New missions return task trees. **`goto` is a reserved word — use `nav_to`.** - **Sensing:** `distance`, `arrived`, `velocity3`/`velocity`/`speed`, `raycast`, `obstacle_ahead`, `ground_height`, `nearest`, `entities_in_radius`. - **Selection:** `all_of_type`, `nearest_where`, `count_where`, `min_by`/`max_by`. - **Task tree:** `seq`/`par_all`/`par_race`/`repeat`/`forever`, leaves `step`/`once`/`act_for`/`wait`/`wait_until`/`wait_for`/`wait_for_from`, and failure nodes `check`/`sel`/`retry`/`invert`/`force_ok`/`force_fail`/`reactive_seq`/`reactive_sel`. Return the tree from `task(me, ctx)`; the kernel owns event delivery and there is one task progression path. - **Testing** (`prelude/auto_tests.rhai`): `t_range` `t_max` `t_true` `t_rel` `t_present` `t_bounded` `t_moved` `report_verdict` `fail_fast` `expect_fault` `expect_runtime_fault` `expect_scene_load_failure` `seg` `find_or_none` `r2`/`r4`. Add helpers freely — edit the prelude, no rebuild. For a reusable helper rather than mission-local policy, use a named Rhai tool library under `assets/scripting/tools/` or the active Twin's `tools/` directory. Follow [`author-rhai-tool`](../author-rhai-tool/SKILL.md): registered tools are called as `name::function(...)`, do not use dynamic `import` for them, and a tool used to author USD must return typed operations to the document owner instead of writing USDA or mutating ECS. ## 3a. Writing a scene TEST A test scenario is an ordinary scenario whose last act is a verdict. Take the assertions from `prelude/auto_tests.rhai` — do not paste private copies of `r2`/`t_range`/`t_report` into a new test. **Where it goes is what makes it a test**, and there is no name convention to remember: | | | |---|---| | `assets/scenes/tests/.usda` | the rig | | `assets/scenarios/tests/.rhai` | its scenario | `scripts/run_scene_tests.sh` runs everything in `scenes/tests/`, the Scene menu hides it (`AssetVisibilitySettings`, one checkbox in Settings), and `every_test_scene_carries_a_scenario` fails on any of them that asserts nothing. A rig written into `scenes/luncosim/` instead gates nothing however carefully it asserts — `no_test_scene_hides_outside_the_tests_directory` is the check that says so. Do NOT suffix the file `_test`: the folder already said it. A check returns `""` on pass and a MESSAGE on failure; collect them so every check runs and the report names all of them: ```rhai fn verdict(s) { let f = []; f.push(t_bounded(s.hull_pos, 100.0, "hull")); // still a vehicle f.push(t_range(s.tilt, 0.0, 5.0, "tilt at rest (deg)")); f.push(t_moved(s.distance, 1.0, "rover travel")); // and it actually drove report_verdict(f, "LANDING LEGS", "LANDING_LEGS"); // prints, emits, toasts } ``` `report_verdict(fails, title, channel)` prints the greppable `: PASS|FAIL` line, emits the verdict on `channel` — which is what sets `luncosim test`'s exit code — and raises a toast. Call it once after assertions. For a test that intentionally loads a scene expected to fail, call `expect_scene_load_failure(path, detail_contains)` before the verdict and call `load_scene(path)` after it. The runner keeps the result open until it observes that exact typed transition outcome and verifies the failed mount released its load and admission state. Use `fail_fast` for setup failures (a `find` that returned -1, the wrong scene) so a broken run stops on tick one instead of ticking silently to the limit. For a tutorial, this scenario is an **observer**, not a second lesson. Attach it to the same production scene fixture as the tutorial and observe its public `cmd:*` events, mission verdict, and live state. Count the mechanism that matters (`cmd:AcquireControl` plus a real port write, for example), then verify the resulting movement or value. Never make the observer send the same control commands as the lesson, and never accept `MISSION_COMPLETE` by itself. This keeps tutorial regression tests in Rhai, where they can be edited and run without rebuilding the Rust core: ```bash "$LUNCOSIM_BIN" test \ --scene scenes/tests/tutorial_first_drive.usda --max-ticks 6000 ``` Scene loading and asynchronous Modelica participant readiness are bounded by wall time, not update count. Use `--readiness-timeout SECS` when a machine needs a different compile budget; the shell gate uses its separate `READINESS_TIMEOUT` startup budget. The shell's larger `SCENE_TIMEOUT` wall-clock backstop allows valid long-running missions to keep advancing, while `--max-ticks` remains the simulated-time liveness bound after readiness. A readiness timeout is a no-verdict failure and must be diagnosed at the worker/readiness owner, not hidden by increasing an update-count constant. The generic Rust contract may still compile every embedded script and exercise the shared hook seam. Keep it content-agnostic; a lesson's steps, required events, and expected command sequence belong in an authored Rhai observer. **A silent pass is not a pass.** A scenario fails silently in every direction that matters: a hook that never fires, a phase that never advances, a `find` that missed. So assert that something was MEASURED (`t_present`) and that something MOVED (`t_moved`, or `t_rel`'s both-near-zero rejection), and print a per-sample table — a run with no sample rows proves nothing. For a user-visible mission or scene review, run the production `luncosim` headfully with `--api PORT`, keep the window open, and capture/inspect the viewport at each material phase. Do not silently replace that session with `--no-ui` or `--offscreen`; the user needs to see whether the authored vehicle actually lands, deploys, connects, or falls. Run the deterministic numeric assertion headlessly only when visual acceptance is not part of the request: ``` "$LUNCOSIM_BIN" test \ --scene scenes/tests/landing_legs.usda --max-ticks 500 ``` When a source build is required, build the production binary in the current worktree and set `LUNCOSIM_BIN` to that executable. An installed production build can be used directly. Test commands consume the selected production binary; they do not use `cargo run`. For USD/Rhai-only iteration, reuse it without rebuilding: ```bash ./scripts/run_scene_tests.sh --no-build --exact <scene-name> ./scripts/run_scene_tests.sh --no-build -j 4 <scene-substring> ``` The runner defaults to four independent headless production processes. `-j/--jobs N` changes only that process bound; each gate process still uses `--threads 1 --jitter 0`, and graphics assertions run in their separate serial offscreen pass. Use `-j 1` when diagnosing ordering or resource interactions. For a standalone assertion against a running scene, use the live no-restart wrapper: ```bash ./scripts/api/run_rhai_test.sh 4101 assets/scripting/tests/test_usd_query.rhai /SandboxScene/Box ``` The helper assembles the prelude and delegates to the native `luncosim rhai --stdout` client, which sends the test through `RunRhai`; edit the `.rhai` file and run it again in the same API session. Keep generic command/lifecycle/cache tests in Rust, and move authored mission or vehicle outcomes into a discovered `scenes/tests` + `scenarios/tests` pair. ### Keep test hooks below Rhai's expression-complexity ceiling An authored test `on_tick` is a bounded fixed-step verdict hook, not a replacement for the task/event machinery: - one helper per phase; - one sampler and one accumulator; - a final verdict/report helper; - short structured log rows instead of long concatenation expressions. Hook-bound `this` is not available inside helpers. In the production host, ordinary map arguments are passed by value and script-defined map helpers do not resolve as mutable methods. Therefore test phase helpers should be reducers: accept an explicit state map, return the updated map, and let the test observer copy the returned keys into `this`. This both controls parser complexity and makes phase logic independently testable. ### Choose the test runner from Rhai The test observer owns its execution domain. Existing observers are headless by default; a test whose assertion is rendered pixels, UI state, or a graphics diagnostic declares the GPU-backed path with a top-level literal: ```rhai const TEST_KIND = "graphics"; ``` The scene still binds the observer through `LunCoProgramAPI` and `info:sourceAsset`. Native asset sources must follow the [typed native admission contract](../../docs/architecture/55-scene-addressing-and-roots.md#native-payload-and-source-admission) through the originating live Twin; keep asset labels separate from filesystem characters. The runner discovers that composed binding and reads the literal without executing the script, so USD does not carry a second test-mode field and the shell gate does not maintain an exception list. Valid values are `"headless"`, `"graphics"`, `"render-contract"`, and `"editor"`. Use `"graphics"` when the assertion consumes rendered pixels, `"render-contract"` when it consumes production GPU/render diagnostics without requiring a valid color-phase item (for example a negative shader contract), and `"editor"` when the assertion requires the windowed workbench. Omit the declaration for the deterministic headless default. Never weaken the ordinary graphics readiness gate to accommodate a diagnostic-only fixture. ## 3b. A rig test needs a CONTROL, and an anti-trivial guard A comparative assertion is only as good as its ability to fail. Two traps, both of which produce a confidently green test that measures nothing. **The anti-trivial guard.** "The two sides mirror" is satisfied perfectly by a rig that never moved: `0 ≈ -0` passes. So assert the driven side ACTUALLY MOVED before asserting anything about the other one. ```rhai f.push(t_true(s.peak_l > 0.02, "the driven rocker never moved — a rig at rest mirrors trivially, so " + "nothing below would mean anything")); ``` **The control case.** Ship a second scene with the mechanism DISABLED, and assert it fails the same check. Without it, "coupled mirrors" might be measuring gravity, symmetry, or nothing at all. `differential_rig{,_nodiff}` and `rocker_bogie{,_nodiff}` are the worked pair. **The control invariant must match the fixture.** A disabled or uncoupled control case must have an explicit expected response; do not infer it from a simplified stand or from a symmetric result. Derive the assertion from the mechanism's purpose and declare the case through a parameter. **Declare which case a stage is; never sniff it.** Both stages reference one rig and differ only in whether the drive is live, so one scenario serves both — but it must be TOLD which: ```usda def Scope "Test" (prepend apiSchemas = ["LunCoProgramAPI"]) { uniform asset info:sourceAsset = @lunco://scenarios/tests/rocker_bogie.rhai@ float lunco:param:coupled = 1.0 # read: param(me, "coupled", -1.0) } ``` Reading it off the coupling's own stiffness would make the expectation depend on the very authoring the test exists to check. ### Avoid weak test assertions - **`t_rel(a, b, tol_pct, what)` takes a PERCENTAGE.** `0.2` means 0.2%, not 20%. Write the numeric tolerance and its percent meaning together. - **Helper functions never see `this`.** `on_tick` has it; anything it calls does not. Pass every measurement through the verdict map — which is also what keeps the verdict a pure function of what was measured. - **A guard that cannot run is not a guard.** Wrapping a check in `if s.x != () { ... }` makes it disappear when the port is absent. Wait for the port to exist, then measure; count the assertions in the final verdict. - **Never do arithmetic on a possibly-absent reading.** `()` divided by 1000 THROWS, the scenario dies between its last print and `report_verdict`, and `luncosim test` reports NO-VERDICT — the failure looks like a hang, not like the assertion that was about to fail. Route every logged number through a formatter that answers `"(none)"`. - **A control must vary the quantity that actually gates.** Confirm the relevant live variable, peer set, or connection output changes before diagnosing the geometry or downstream behavior. - **Keep the producer in the connection path.** A battery's `soc_out` belongs to the Battery prim. When another solver consumes it, author `float inputs:engine_enable.connect = </Rover/Battery.outputs:soc_out>` on the consumer. The generated network projection resolves that member output to its solver wrapper; do not invent a vessel-level SOC port or use a fallback value. A script may read an intentionally published network boundary, but that boundary must be an authored connection to the battery, never a second state. - **A cut is not a camera loan.** `set_camera("RoverCam")` rebinds the viewport until another action rebinds it. Give every `wait_until` a bound and provide a return-to-avatar beat when the mission uses a cinematic camera. ## 4. Missions & sequencing (task policy, both pure rhai) - **Layer 1 — task tree** (`examples/sequence.rhai`): build a tree with `step`/`wait`/`wait_for` and return it from `task(me, ctx)`. Action and predicate leaves are anonymous `|me| ...` closures or named `Fn("name")` callbacks (`fn name(me)`); the native kernel owns progression, state binding, and event delivery. - **Layer 2 — declarative timeline** (`examples/timeline.rhai`): a mission as **pure data**. Each step has exactly one operation word (`move_to`, `move_to_entity`, `possess`, `brake`, `cmd`, `emit`, `wait`, or `wait_event`) and only that operation's fields; `compile_timeline` lowers it inside `task(me, ctx)`. It is serialisable and can also be run through `RunTimeline`/`RunStoredTimeline`. Progress is observable on the bus: `TASK_COMPLETE`/`TASK_FAILED` for the native task root and `OBJECTIVE_COMPLETE`/`PLAN_COMPLETE` when mission policy emits those application events. `requires_event` mission objectives consume the native runtime's bounded `name`/`source` identity projection. Full typed payloads belong only to a matching user `on_event` hook; do not recreate a persistent Rhai event buffer or retain a retired event-delivery API. For complex reactive policy, compose the task tree in the scenario with the prelude's reactive selectors, guards, waits, and event leaves. The generic task kernel is shared and fast; no vessel-specific Rust driver or autopilot command is required. Use `wait_for` when an owner can publish completion, `wait_until` only for state with no suitable event, and `wait(seconds)` for a deterministic simulation-time delay. A `reactive_seq` guard is the task-tree interruption point: when an event handler updates the guarded state, a failed `check` cancels the running child on the next task pass. Do not schedule Rhai callbacks from an app-global wall-clock timer; callback execution stays on the owning deterministic cycle. If a reusable timeout is needed, its clock, cancellation, and failure result must be explicit task semantics. ## 5. Events — the reactive spine `emit(name, value)` fires a `TelemetryEvent`; a running receiver handles it on a later fixed step because the event carries the authoritative `sim_tick` and scenario emissions occur after that tick boundary. A paused simulation delivers discrete events on its next `Update` pass. The deterministic actor model makes "A emits, B reacts" order-independent. Scripts interact ONLY through events + shared ECS state, never by calling each other's functions (isolated VMs). Producers also include physics (`COLLISION_START`), lifecycle (`SCENE_LOADED`), and Modelica condition outputs connected to `LunCoEvent.inputs:trigger`. The event prim adds the bus-facing name and severity; threshold and hysteresis equations remain in Modelica. Discrete vessel actions have a dedicated atomic edge surface: `intent_edge(target, intent, "pressed"|"released"|"pulse")` or the shorter `intent_pulse(target, intent)` helper. It emits `intent.edge` with `value.target_gid`, `value.correlation_id`, `value.intent`, and `value.edge`; `value.correlation_id` is the same unsigned command id returned by the helper. The consuming Twin decides what the edge means and whether to write a port. Use `SimulateIntent`/`SetPorts` for held or continuous values, and never build a pulse from two ordered writes. Held `SimulateIntent` state is keyed by target, intent, and producer identity: API and direct typed producers use a stable nonzero `producer_id`; actorless Rhai supplies one too. A Twin scenario uses its runtime route and stable actor identity, so omit `producer_id` there. A release clears only that producer's hold. External commands targeting fixed simulation state are admitted for the next tick and publish `intent.hold` with producer identity, correlation id, and input-order stamp; Simulation-clock Rhai behavior stays in its current pass, and local-embodiment input uses the interaction cadence. The live held state is not a durable session replay record. The helper result's `id` correlates the edge with the read-only `query("CausalTrace", #{target: target, correlation_id: edge.id})` snapshot. That snapshot exposes the authored mapping, selected port owner, connection and native-joint admission, current measured channels, and classified producer origin. An externally admitted edge also exposes its committed scene generation, effective simulation tick, and per-tick sequence; a deterministic simulation-hook edge has no external admission stamp. Missing or pending stages remain visible as incomplete; they are not inferred as successful actuation. ## 6. Running & debugging Prefer the HTTP API (curl-first; canonical port **4101** — launch per the [`test-via-api`](../test-via-api/SKILL.md) / [`run-modelica`](../run-modelica/SKILL.md) skills): ```jsonc // attach + run (idempotent hot-reload); source is inline rhai OR an asset path {"type":"ExecuteCommand","command":"RunScenario","params":{"target":<gid>,"source":"<rhai or path>","params":{"speed":1.5}}} {"type":"ExecuteCommand","command":"SetScenarioPaused","params":{"target":<gid>,"paused":true}} {"type":"ExecuteCommand","command":"StopScenario","params":{"target":<gid>}} ``` - `params` is a typed object; the script receives it as the explicit read-only `ctx` argument of its lifecycle and program hooks. Omitted parameters are `{}`. - **Debug:** `ScriptStatus {target}` → compile/runtime health + located errors; `ScriptInspect {target}` → live `this`, hooks, generation, running/paused. `print(...)` goes to the process log. - One-shot (no attach): `RunRhai {code}` — full world access, stdout in the original deferred response. ## 7. Persistence — bake into the scene (USD) A script is a PRIM — give the entity a `LunCoProgramAPI` child and it auto-runs on spawn. Delete the prim and the behaviour is gone: ```usda def Xform "Rover_01" { def Scope "Patrol" (prepend apiSchemas = ["LunCoProgramAPI"]) { uniform asset info:sourceAsset = @scenarios/route_follow.rhai@ # File-backed source is canonical for production programs. # per-instance config: one typed attribute per key, read by param(me, "speed", 1.0) custom float lunco:param:speed = 2.0 } } ``` Timelines persist via `RegisterTimeline` → `<twin>/timelines/*.json`; tool libraries → `<twin>/tools/*.rhai`. ## The recipe (checklist) 1. Decide the shape: sequenced (`task`), objective-tracked (`mission`), or reactive (`on_event`). Use a Behavior Tree for reactive AI. Use `on_tick` only in authored tests for bounded state sampling and verdicts; rover continuous control/dynamics belong to native fixed-step systems or Modelica. 2. Return a task tree; pass task configuration into anonymous `|me| ...` closures or named callbacks. Keep persistent lifecycle state on `this` only where a hook or task closure genuinely needs it. 3. Drive with prelude verbs (`nav_to`/`drive`/`cmd`) — never a control loop (that's Modelica). 4. Wire reactions through `emit`/`on_event` (the next scenario pass delivers the event; paused simulations use `Update` while fixed simulation time is stopped). 5. `RunScenario` on the target gid through the live API; verify with `ScriptInspect`; iterate by re-running (in-place hot-reload, no app restart). 6. Persist it as a `LunCoProgramAPI` child prim on the target once it works. ## Anti-patterns (each has cost real time) - ❌ Persistent state in top-level `let` or read from a helper — invisible/unbound. Use `this`, in hooks only. - ❌ A per-tick control law (PID, force mixing) in rhai — belongs in Modelica. - ❌ `goto(...)` — reserved word; use `nav_to`. - ❌ Expecting an `emit` to be seen in the same scenario pass — it arrives on the next pass. - ❌ Assuming a scenario runs on clients — it's host-authoritative; clients get replicated state, not the script. - ❌ A generic `spawn(...)` — use `cmd("SpawnEntity", #{entry_id, position})` so clients reconstruct from the catalog. Actorless Rhai/API callers of raw-file scenes also supply a stable nonzero `producer_id`. - ❌ Reading raw `Transform` for position — use `world_pos` (float-origin correct). - ✅ Passing `Fn("named_action")` to `once`/`step`/`wait_until` when the callback is declared `fn named_action(me)`; the native driver binds the persistent state as `this`. ## The gate set — what the shipped scene tests guard `./scripts/run_scene_tests.sh` builds `luncosim` once and runs every gate scene through `luncosim test` headless and deterministically (`--threads 1 --jitter 0`) using four production processes by default. `-j/--jobs N` changes the process bound; it does not change the gate's deterministic flags. Exit 0=PASS / 1=FAIL / 2=no verdict. The set, and what each one is FOR: | Scene | Guards | |---|---| | `drivetrain_parity` · `ackermann_parity` · `six_independent_parity` | raycast ≡ physical for one authored parameter set (below) | | `parts_attached` | **nothing falls off the vehicle.** Drive the assembled rig and require every descendant to remain within the authored relative-distance tolerance. | | `lint_selftest` | **the linter itself.** A scene authored wrong on purpose, so `RunLint` → rules → `LintReport` can be shown to FIND the faults by rule id — and to stay silent on the correctly jointed wheel beside them | Two lessons those last two encode, worth copying into any new gate: - **Measure something rotation-invariant.** `parts_attached` compares `|p_part − p_vessel|` before and after a drive: a spinning wheel, a steering knuckle and a stroking suspension all leave it alone, while a part left on the ground changes it by the length of the drive. It walks `children()`, so it needs no list of part names and covers parts added later. - **Prove the measurement can fail.** Each of these asserts its subject actually MOVED (or that a deliberate fault was actually FOUND). A vessel that never simulates, a hook that never fires and a clean scene are indistinguishable otherwise — `parts_attached` excludes rucheyok for exactly that reason rather than counting a frozen rover as a pass. ## Comparative mechanics tests When two authored realizations implement one contract, place them in one scene test and drive them with identical commands. Assert that both realizations move, compare physical outputs with tolerances appropriate to the contract, and check direction as well as magnitude. Add an independent bound so two equally wrong implementations cannot satisfy parity together. The shipped drivetrain, attachment, and linter fixtures under `assets/scenes/tests/` and `assets/scenarios/tests/` demonstrate this shape. Keep the test scenario responsible for measured samples and the verdict; keep the mechanism and its parameters in USD, Modelica, or the owning engine subsystem.