From 04bab677789a4793447551f1c64b4f3614b5f276 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 6 Oct 2026 16:58:31 -0500 Subject: [PATCH] feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310) - install/update refuse a Rust bundle that carries no RunicNPC, before anything is written, and say how to get one that does. - doctor fails when RunicNPC is missing or was never installed; a file edited by hand stays a warning, since it still loads. - compose-bundles.sh composes no Rust bundle without a RunicNPC release that answers the bridge's runicnpc_api (the published one stays), and fails a bridge that declares none; every Rust bundle now has `npc`. - A RunicNPC API-mismatch message lost its line continuation; restored. The bundle type still parses a bundle without `npc`, so doctor and an old pin can name it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/scripts/compose-bundles.sh | 75 +++++++++++++++---------------- .gitea/workflows/bundle.yml | 2 +- src/bundle.rs | 5 ++- src/rustgame/doctor.rs | 17 ++++--- src/rustgame/install.rs | 14 ++++-- src/rustgame/npc.rs | 5 ++- 6 files changed, 65 insertions(+), 53 deletions(-) diff --git a/.gitea/scripts/compose-bundles.sh b/.gitea/scripts/compose-bundles.sh index e4e2b39..ca61da8 100755 --- a/.gitea/scripts/compose-bundles.sh +++ b/.gitea/scripts/compose-bundles.sh @@ -377,44 +377,39 @@ compose_rust() { [ "$link_protocol" = "$plugin_protocol" ] || fail \ "PROTOCOL MISMATCH — Rust-Link ${link_tag} speaks ${link_protocol}, Rust-Plugins ${plugin_tag} declares ${plugin_protocol}. The game link has no 409: a mismatched plugin would mis-parse. Fix: land the matching half and let its release cut, or bump Rust-Plugins/overlay.toml." - # RunicNPC (docs/runicnpc/PLAN.md D224): a third file, optional until its stage 9. - # The bridge's manifest says which RunicNPC API its npc.* commands need - # (`runicnpc_api`, from overlay.toml); the latest RunicNPC release is carried - # only when it answers at least that. A bridge that needs none carries none: a - # RunicNPC it cannot talk to would be an NPC plugin nobody manages. - local npc='null' need npc_tag npc_tarball npc_manifest npc_api npc_carried='' + # RunicNPC (docs/runicnpc/PLAN.md D224): a third file, REQUIRED since its stage 9 + # (D310) — the bridge refuses every NPC an event places without it, and the + # installer refuses a Rust bundle that does not carry it. The bridge's manifest + # says which RunicNPC API it needs (`runicnpc_api`, from overlay.toml); the + # latest RunicNPC release is carried only when it answers at least that. With + # none that does, the Rust bundle is not composed: the published one stays. + local npc need npc_tag npc_tarball npc_manifest npc_api need="$(jq -r '.runicnpc_api // 0' "$manifest")" - if [ "$need" -gt 0 ]; then - rc=0 - resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$? - if [ "$rc" -eq 2 ]; then - note "- **Rust**: the bridge needs RunicNPC API ${need}, and RunicNPC has not released yet — the bundle carries none." - else - npc_tag="$(release_tag runicnpc)" - verify_assets runicnpc - npc_tarball="$(single_tarball runicnpc)" - npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")" - [ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \ - || fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs" - npc_api="$(jq -r '.api' "$npc_manifest")" - echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}" - if [ "$npc_api" -lt "$need" ]; then - note "- **Rust**: RunicNPC \`${npc_tag}\` answers API ${npc_api} and the bridge needs ${need} — the bundle carries none until RunicNPC releases one that does." - else - npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \ - --argjson asset "$(asset_json runicnpc "$npc_tarball")" \ - '{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api, - compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version }, - carbon: { min_version: $m[0].min_carbon_version } }, - requires_plugins: $m[0].requires_plugins }, - asset: $asset }')" - jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string")) - and (.api | type == "number") and (.compat.requires_plugins | type == "array")' <<<"$npc" >/dev/null \ - || fail "the RunicNPC manifest is missing commit, api, a framework floor, or requires_plugins" - npc_carried="$npc_tag" - fi - fi - fi + [ "$need" -gt 0 ] || fail \ + "Rust-Plugins ${plugin_tag} declares no runicnpc_api, and RunicNPC is required (D310) — a bridge older than RunicNPC's stage 4 cannot be bundled any more." + rc=0 + resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$? + [ "$rc" -ne 2 ] || fail "the bridge needs RunicNPC API ${need}, and RunicNPC has not released — RunicNPC is required (D310)." + npc_tag="$(release_tag runicnpc)" + verify_assets runicnpc + npc_tarball="$(single_tarball runicnpc)" + npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")" + [ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \ + || fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs" + npc_api="$(jq -r '.api' "$npc_manifest")" + echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}" + [ "$npc_api" -ge "$need" ] || fail \ + "RunicNPC ${npc_tag} answers API ${npc_api} and Rust-Plugins ${plugin_tag} needs ${need} — RunicNPC is required (D310), so no Rust bundle is composed until it releases one that does." + npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \ + --argjson asset "$(asset_json runicnpc "$npc_tarball")" \ + '{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api, + compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version }, + carbon: { min_version: $m[0].min_carbon_version } }, + requires_plugins: $m[0].requires_plugins }, + asset: $asset }')" + jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string")) + and (.api | type == "number") and (.compat.requires_plugins | type == "array")' <<<"$npc" >/dev/null \ + || fail "the RunicNPC manifest is missing commit, api, a framework floor, or requires_plugins" jq -n \ --arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \ @@ -432,8 +427,8 @@ compose_rust() { compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version }, carbon: { min_version: $m[0].min_carbon_version } }, requires_plugins: $m[0].requires_plugins }, - asset: $p_asset } } - + (if $npc == null then {} else { npc: $npc } end)' > "$WORK/rust-s2.json" + asset: $p_asset }, + npc: $npc }' > "$WORK/rust-s2.json" # A manifest missing a key would compose a `null` the installer then trusts. jq -e '([.payload.compat.frameworks[].min_version, .payload.commit] | all(type == "string")) and (.payload.compat.requires_plugins | type == "array")' "$WORK/rust-s2.json" >/dev/null \ @@ -446,7 +441,7 @@ compose_rust() { local tag tag="$(fresh_tag "$PUBLISHED/v2/rust")" publish_doc "$PUBLISHED/v2/rust" "$tag" "$WORK/rust-s2.json" - note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}${npc_carried:+, RunicNPC \`${npc_tag}\`}." + note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}, RunicNPC \`${npc_tag}\`." echo "rust ${tag}" >> "$WORK/published-games.txt" } diff --git a/.gitea/workflows/bundle.yml b/.gitea/workflows/bundle.yml index 76b577a..480aac8 100644 --- a/.gitea/workflows/bundle.yml +++ b/.gitea/workflows/bundle.yml @@ -19,7 +19,7 @@ # current.json, bundle-.json schema 1, ServUO: link + overlay # v2/servuo/current.json, bundle-.json schema 2, game "servuo" # v2/rust/current.json, bundle-.json schema 2, game "rust": Rust-Link + Rust-Plugins, -# and RunicNPC when the bridge needs it (D224) +# and RunicNPC, required since its stage 9 (D224, D310) # # Schema 2 (D146) names ONE game, with a `game` discriminant and a `payload` # that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still diff --git a/src/bundle.rs b/src/bundle.rs index 439e387..3407726 100644 --- a/src/bundle.rs +++ b/src/bundle.rs @@ -362,8 +362,9 @@ pub struct RustBundle { pub sidecar: RustSidecar, pub payload: PluginPayload, /// RunicNPC, Runic Gateway's NPC plugin (docs/runicnpc/PLAN.md D224): a third file placed in - /// the plugins directory beside the bridge. Optional until RunicNPC's stage 9 makes it - /// required, so a bundle without it — every one before stage 4 — still parses and installs. + /// the plugins directory beside the bridge. Required since RunicNPC's stage 9 (D310): a bundle + /// without it — every one before stage 4 — still PARSES, so `doctor` and an old pin can name + /// it, but `install` and `update` refuse it. #[serde(default, skip_serializing_if = "Option::is_none")] pub npc: Option, } diff --git a/src/rustgame/doctor.rs b/src/rustgame/doctor.rs index 22ea06b..74a7179 100644 --- a/src/rustgame/doctor.rs +++ b/src/rustgame/doctor.rs @@ -178,9 +178,10 @@ fn instance_rows( } } - // ── RunicNPC: ours when the bundle carried it (D224) ────────────────────── - // A warning, like a helper's: until RunicNPC's stage 9 the bridge runs without it, and what is - // lost is the site's NPC profiles and placements and events' profile NPCs. + // ── RunicNPC: ours, and required (D224, D310) ───────────────────────────── + // A failure when it is missing or was never placed: since RunicNPC's stage 9 the bridge + // refuses every NPC an event places without it. A file edited by hand still loads, so that + // stays a warning, as the bridge's own helpers are. if let Some(file) = &instance.npc { let tag = record.npc.as_ref().map(|n| n.tag.as_str()).unwrap_or("?"); match std::fs::read(&file.path) { @@ -192,11 +193,17 @@ fn instance_rows( .note("edited or replaced by hand; `update --game rust` puts the released one back"), ), Err(_) => rows.push( - Row::warn(&label("RunicNPC"), format!("{} is missing", file.path)) - .note("without it the site's NPC profiles and placements, and events' profile NPCs, are off") + Row::fail(&label("RunicNPC"), format!("{} is missing", file.path)) + .note("RunicNPC is required: without it every NPC an event places is refused, Rust's own scientists included") .note("`update --game rust` puts it back"), ), } + } else { + rows.push( + Row::fail(&label("RunicNPC"), "not installed") + .note("RunicNPC is required: without it every NPC an event places is refused, Rust's own scientists included") + .note("`update --game rust` installs it from the current bundle"), + ); } // ── The plugin's config: the website's ─────────────────────────────────── diff --git a/src/rustgame/install.rs b/src/rustgame/install.rs index 0b1cf3e..819b7cb 100644 --- a/src/rustgame/install.rs +++ b/src/rustgame/install.rs @@ -183,7 +183,9 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { ); } - // RunicNPC (D224), fetched and checked beside the plugin, before anything is planned. + // RunicNPC (D224), fetched and checked beside the plugin, before anything is planned. It is + // required since RunicNPC's stage 9 (D310): the bridge refuses every NPC an event places + // without it, so a bundle that cannot place it is refused before anything is written. let npc_released = match &bundle.npc { Some(component) => { let path = scratch.path().join(&component.asset.name); @@ -191,7 +193,8 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { let released = npc::read_tarball(&path)?; if released.manifest.api != component.api { bail!( - "RunicNPC in bundle {} answers API {}, but the bundle says {} — refusing a component the bundle does not describe.", + "RunicNPC in bundle {} answers API {}, but the bundle says {} — refusing a \ + component the bundle does not describe.", bundle.bundle, released.manifest.api, component.api @@ -205,7 +208,12 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { } Some(released) } - None => None, + None => bail!( + "bundle {} carries no RunicNPC, and Runic Gateway's Rust events need it: without it \ + the bridge refuses every NPC an event places. Install the current bundle (leave \ + --bundle off), or pin one published with RunicNPC v1.0.0 or later.", + bundle.bundle + ), }; // ── Plan every instance ────────────────────────────────────────────────── diff --git a/src/rustgame/npc.rs b/src/rustgame/npc.rs index a35b74f..5d6e33f 100644 --- a/src/rustgame/npc.rs +++ b/src/rustgame/npc.rs @@ -8,8 +8,9 @@ //! it. RunicNPC makes that directory itself on first load, because one made from outside the game //! is not writable by it (runicnpc PLAN.md §1.5). //! -//! It is optional until RunicNPC's stage 9: a bundle without it installs as before, and a host -//! without it runs the bridge with Rust's own scientists only (D243). +//! It is required since RunicNPC's stage 9 (D310): the bridge refuses every NPC an event places +//! without it, Rust's own scientists included, so `install` and `update` refuse a bundle that does +//! not carry it and `doctor` fails a host that lacks it. use std::collections::BTreeMap; use std::io::Read;