Agent skill

Author Scenario

by LunCoSim in LunCoSim/lunco-sim

Author event-driven LunCoSim scenarios for missions, waypoints, reactions, or multi-entity coordination.

Apache-2.0Auto-check passedBackend & APIs

Install Author Scenario

skills CLI
$ npx skills add LunCoSim/lunco-sim --skill author-scenario -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install LunCoSim/lunco-sim author-scenario --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/LunCoSim/lunco-sim.git skills-src && mkdir -p .claude/skills && cp -r skills-src/skills/author-scenario .claude/skills/author-scenario && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
author-scenario
GitHub stars
107
Token cost
~12k tokens
SKILL.md length
5,944 words
Files
1
Skills in repo
40
Repo updated
First seen
Licence
Apache-2.0

At a glance

Author event-driven LunCoSim scenarios for missions, waypoints, reactions, or multi-entity coordination.

  • Works in 7 steps: Lifecycle hooks — the shape of every… → The verb surface (host bridge —… → Prelude helpers (hot-reloadable policy —… → …
  • Working with task
  • SKILL.md covers 1. Lifecycle hooks — the shape…, 2. The verb surface (host…, 3. Prelude helpers… and 3a. Writing a scene TEST, plus 9 more sections
  • Calls cargo

What it does

Author Scenario is an agent skill from LunCoSim/lunco-sim. Author event-driven LunCoSim scenarios for missions, waypoints, reactions, or multi-entity coordination. Use when working with task, mission, onevent, RunScenario, navto, 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.

Its SKILL.md is about 12k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

It sits in Backend & APIs, covering Event-driven systems. The repository describes itself as: Collaborative Multiphysics Cosimulator For Space Missions 🌎🚀🌚. The licence is Apache-2.0.

When your agent uses it

  • Working with task
  • Persistent this state

Example prompts

  • “/author-scenario”

Workflow steps

7 steps, taken from the step headings in SKILL.md.

  1. Lifecycle hooks — the shape of every scenario
  2. The verb surface (host bridge — everything else is prelude)
  3. Prelude helpers (hot-reloadable policy — no Rust rebuild)
  4. Missions & sequencing (task policy, both pure rhai)
  5. Events — the reactive spine
  6. Running & debugging
  7. Persistence — bake into the scene (USD)

What it can do on your machine

Read from SKILL.md and the folder at commit 43f1301. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    Shell commands in SKILL.md call:

    • cargo

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    No URLs in SKILL.md.

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names no API keys, tokens, secrets or passwords.

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Author Scenario loads about 12k tokens when it runs. Until then it costs about 99 tokens; SKILL.md has 5,944 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~99
When it runs · the whole SKILL.md, loaded when a task matches
~12k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check passed

The automated check found no risky patterns in SKILL.md.

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from LunCoSim/lunco-sim at commit 43f1301, republished under its Apache-2.0 licence (© LunCoSim). 5,944 words, ~11,701 tokens.

Download SKILL.mdSave it as .claude/skills/author-scenario/SKILL.md (or your agent's skills folder).
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 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.

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.
  • 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. 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 fns are pure — they CANNOT see top-level lets. 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)

VerbPurpose
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/childrenentity 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: 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/<name>.usdathe rig
assets/scenarios/tests/<name>.rhaiits 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 <title>: 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.

Show full SKILL.md (2,120 more words)Show less
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 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 / run-modelica 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:

SceneGuards
drivetrain_parity · ackermann_parity · six_independent_parityraycast ≡ physical for one authored parameter set (below)
parts_attachednothing falls off the vehicle. Drive the assembled rig and require every descendant to remain within the authored relative-distance tolerance.
lint_selftestthe 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.

© LunCoSim, Apache-2.0. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in skills/author-scenario of LunCoSim/lunco-sim.

Open the folder on GitHubat commit 43f1301

Compare with similar skills

Author Scenario next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

Author Scenario compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Author Scenario this skillLunCoSim/lunco-sim107—~12kAutomated safety check: PassApache-2.0
Stripe Projectsfossasia/eventyay1.7k5 repos~2kAutomated safety check: NotesApache-2.0
MQTTX CLIemqx/MQTTX5.1k—~1.9kAutomated safety check: PassApache-2.0
AWS Serverless Edazxkane/aws-skills3674 repos~3.2kAutomated safety check: PassMIT
Windmill Trigger Type Checklistwindmill-labs/windmill18k—~4.7kAutomated safety check: PassCustom licence
FoundatioFoundatioFx/Foundatio2.1k—~3.9kAutomated safety check: PassApache-2.0

Similar skills

  • Stripe Projects

    fossasia/eventyay

    A skill your agent uses when the user wants to provision infrastructure or third-party services using Stripe Projects.

    1.7k GitHub starsUsed in 5 repos~2k tokens
    Backend & APIsAuto-check: notes
  • MQTTX CLI

    emqx/MQTTX

    Operates the `mqttx` command line client to connect, publish, subscribe, benchmark and simulate data against an MQTT broker, with TLS and MQTT 5 support.

    5.1k GitHub stars~1.9k tokensUpdated yesterday
    Backend & APIsAuto-check passed
  • AWS Serverless Eda

    zxkane/aws-skills

    AWS serverless and event-driven architecture expert based on Well-Architected Framework.

    367 GitHub starsUsed in 4 repos~3.2k tokens
    Backend & APIsAuto-check passed
  • Windmill Trigger Type Checklist

    windmill-labs/windmill

    Checklist of every backend, frontend, CLI and capture change needed to add a new TriggerCrud-based trigger type, such as Azure, GCP or Kafka, to Windmill.

    18k GitHub stars~4.7k tokensUpdated today
    Backend & APIsAuto-check passed
  • Foundatio

    FoundatioFx/Foundatio

    A skill your agent uses when working with Foundatio infrastructure abstractions for .NET -- caching, queuing, messaging, file storage, distributed locking, or background jobs.

    2.1k GitHub stars~3.9k tokensUpdated 2 days ago
    Backend & APIsAuto-check passed
  • Homerail Dag Ops

    xiaotianfotos/homerail

    Design, start, supervise, inspect, and continue HomeRail DAG workflows from any agent with CLI or HTTP access.

    994 GitHub stars~1.5k tokensUpdated 15 days ago
    Backend & APIsAuto-check passed

More from LunCoSim/lunco-sim

All 40 skills in this repo
  • Nightly Changelog

    LunCoSim/lunco-sim

    Generate concise LunCoSim nightly GitHub release notes with platform downloads, installation guidance, an AI-agent mission prompt, and a changelog link.

    107 GitHub stars~1.1k tokensUpdated today
    Auto-check passed
  • Build or repair a reusable scene component through a live LunCoSim Editor session.

    107 GitHub stars~4.4k tokensUpdated today
    Auto-check passed
  • Assembly Quality

    LunCoSim/lunco-sim

    Build or review a componentized LunCoSim USD assembly with a realistic, dimensionally checkable presentation.

    107 GitHub stars~1.6k tokensUpdated today
    Auto-check passed
  • Author Rhai Tests

    LunCoSim/lunco-sim

    Author and review LunCoSim behavioral, asset-backed, component, mission, visual, and requirements-verification tests.

    107 GitHub stars~3.5k tokensUpdated today
    Auto-check passed
  • Author Rhai Tool

    LunCoSim/lunco-sim

    Create, extend, register, or debug a reusable LunCoSim Rhai tool library for live USD authoring, component linting, inspection, or test support.

    107 GitHub stars~5.2k tokensUpdated today
    Auto-check passed
  • Author Tutorial

    LunCoSim/lunco-sim

    Author an interactive tutorial, guided lesson, onboarding flow, coach-mark tour, or objectives checklist in LunCoSim.

    107 GitHub stars~1.4k tokensUpdated today
    Auto-check passed

Categories

Questions about Author Scenario

What does Author Scenario do?

Author event-driven LunCoSim scenarios for missions, waypoints, reactions, or multi-entity coordination. Author Scenario is an agent skill from LunCoSim/lunco-sim. Author event-driven LunCoSim scenarios for missions, waypoints, reactions, or multi-entity coordination.

When should I use Author Scenario?

Author Scenario fits situations like: working with task; persistent this state.

How do I install Author Scenario in Claude Code?

Run `npx skills add LunCoSim/lunco-sim --skill author-scenario -a claude-code`. Or copy the skill folder (skills/author-scenario in LunCoSim/lunco-sim) into .claude/skills/author-scenario in your project. Claude Code loads it when a task matches its description.

How do I install Author Scenario in Codex?

Run `npx skills add LunCoSim/lunco-sim --skill author-scenario -a codex`. Or copy the skill folder (skills/author-scenario in LunCoSim/lunco-sim) into .agents/skills/author-scenario in your project. Codex loads it when a task matches its description.

Can I use Author Scenario in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add LunCoSim/lunco-sim --skill author-scenario -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/author-scenario, .gemini/skills/author-scenario, .github/skills/author-scenario and .opencode/skills/author-scenario in your project.

What does Author Scenario need to run?

Going by SKILL.md and its folder, Author Scenario needs the command-line tools its instructions call (cargo).

Does Author Scenario access the network?

SKILL.md contains no URLs. Any network use would come from the scripts or tools the agent runs. This is read from the text; nothing was executed.

Is Author Scenario safe to install?

Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.

What licence does Author Scenario use?

Author Scenario is published under the Apache-2.0 licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Author Scenario use?

About 12k tokens (SKILL.md is roughly 47k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Author Scenario?

Skills that share tags, products or a category with Author Scenario: Stripe Projects (fossasia/eventyay, 1.7k stars), MQTTX CLI (emqx/MQTTX, 5.1k stars), AWS Serverless Eda (zxkane/aws-skills, 367 stars) and Windmill Trigger Type Checklist (windmill-labs/windmill, 18k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Author Scenario?

LunCoSim (a GitHub organization) maintains it in LunCoSim/lunco-sim, which has 107 GitHub stars. The repository holds 40 skills in this directory. The repository was last updated on October 10, 2026.

Source: LunCoSim/lunco-sim on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.