Merge pull request 'feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310)' (#38) from feat/runicnpc-required into edge

Reviewed-on: #38
This commit is contained in:
2026-10-07 02:42:56 +00:00
6 changed files with 65 additions and 53 deletions

View File

@@ -377,44 +377,39 @@ compose_rust() {
[ "$link_protocol" = "$plugin_protocol" ] || fail \ [ "$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." "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. # RunicNPC (docs/runicnpc/PLAN.md D224): a third file, REQUIRED since its stage 9
# The bridge's manifest says which RunicNPC API its npc.* commands need # (D310) — the bridge refuses every NPC an event places without it, and the
# (`runicnpc_api`, from overlay.toml); the latest RunicNPC release is carried # installer refuses a Rust bundle that does not carry it. The bridge's manifest
# only when it answers at least that. A bridge that needs none carries none: a # says which RunicNPC API it needs (`runicnpc_api`, from overlay.toml); the
# RunicNPC it cannot talk to would be an NPC plugin nobody manages. # latest RunicNPC release is carried only when it answers at least that. With
local npc='null' need npc_tag npc_tarball npc_manifest npc_api npc_carried='' # 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")" need="$(jq -r '.runicnpc_api // 0' "$manifest")"
if [ "$need" -gt 0 ]; then [ "$need" -gt 0 ] || fail \
rc=0 "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."
resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$? rc=0
if [ "$rc" -eq 2 ]; then resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$?
note "- **Rust**: the bridge needs RunicNPC API ${need}, and RunicNPC has not released yet — the bundle carries none." [ "$rc" -ne 2 ] || fail "the bridge needs RunicNPC API ${need}, and RunicNPC has not released — RunicNPC is required (D310)."
else npc_tag="$(release_tag runicnpc)"
npc_tag="$(release_tag runicnpc)" verify_assets runicnpc
verify_assets runicnpc npc_tarball="$(single_tarball runicnpc)"
npc_tarball="$(single_tarball runicnpc)" npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")"
npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")" [ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \
[ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \ || fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs"
|| fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs" npc_api="$(jq -r '.api' "$npc_manifest")"
npc_api="$(jq -r '.api' "$npc_manifest")" echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}"
echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}" [ "$npc_api" -ge "$need" ] || fail \
if [ "$npc_api" -lt "$need" ]; then "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."
note "- **Rust**: RunicNPC \`${npc_tag}\` answers API ${npc_api} and the bridge needs ${need} — the bundle carries none until RunicNPC releases one that does." npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \
else --argjson asset "$(asset_json runicnpc "$npc_tarball")" \
npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \ '{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api,
--argjson asset "$(asset_json runicnpc "$npc_tarball")" \ compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
'{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api, carbon: { min_version: $m[0].min_carbon_version } },
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version }, requires_plugins: $m[0].requires_plugins },
carbon: { min_version: $m[0].min_carbon_version } }, asset: $asset }')"
requires_plugins: $m[0].requires_plugins }, jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string"))
asset: $asset }')" and (.api | type == "number") and (.compat.requires_plugins | type == "array")' <<<"$npc" >/dev/null \
jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string")) || fail "the RunicNPC manifest is missing commit, api, a framework floor, or requires_plugins"
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
jq -n \ jq -n \
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \ --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 }, compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } }, carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins }, requires_plugins: $m[0].requires_plugins },
asset: $p_asset } } asset: $p_asset },
+ (if $npc == null then {} else { npc: $npc } end)' > "$WORK/rust-s2.json" npc: $npc }' > "$WORK/rust-s2.json"
# A manifest missing a key would compose a `null` the installer then trusts. # 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")) 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 \ and (.payload.compat.requires_plugins | type == "array")' "$WORK/rust-s2.json" >/dev/null \
@@ -446,7 +441,7 @@ compose_rust() {
local tag local tag
tag="$(fresh_tag "$PUBLISHED/v2/rust")" tag="$(fresh_tag "$PUBLISHED/v2/rust")"
publish_doc "$PUBLISHED/v2/rust" "$tag" "$WORK/rust-s2.json" 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" echo "rust ${tag}" >> "$WORK/published-games.txt"
} }

View File

@@ -19,7 +19,7 @@
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay # current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo" # v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins, # v2/rust/current.json, bundle-<tag>.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` # 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 # that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still

View File

@@ -362,8 +362,9 @@ pub struct RustBundle {
pub sidecar: RustSidecar, pub sidecar: RustSidecar,
pub payload: PluginPayload, pub payload: PluginPayload,
/// RunicNPC, Runic Gateway's NPC plugin (docs/runicnpc/PLAN.md D224): a third file placed in /// 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 /// the plugins directory beside the bridge. Required since RunicNPC's stage 9 (D310): a bundle
/// required, so a bundle without it — every one before stage 4 — still parses and installs. /// 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub npc: Option<NpcComponent>, pub npc: Option<NpcComponent>,
} }

View File

@@ -178,9 +178,10 @@ fn instance_rows(
} }
} }
// ── RunicNPC: ours when the bundle carried it (D224) ────────────────────── // ── RunicNPC: ours, and required (D224, D310) ─────────────────────────────
// A warning, like a helper's: until RunicNPC's stage 9 the bridge runs without it, and what is // A failure when it is missing or was never placed: since RunicNPC's stage 9 the bridge
// lost is the site's NPC profiles and placements and events' profile NPCs. // 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 { if let Some(file) = &instance.npc {
let tag = record.npc.as_ref().map(|n| n.tag.as_str()).unwrap_or("?"); let tag = record.npc.as_ref().map(|n| n.tag.as_str()).unwrap_or("?");
match std::fs::read(&file.path) { 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"), .note("edited or replaced by hand; `update --game rust` puts the released one back"),
), ),
Err(_) => rows.push( Err(_) => rows.push(
Row::warn(&label("RunicNPC"), format!("{} is missing", file.path)) Row::fail(&label("RunicNPC"), format!("{} is missing", file.path))
.note("without it the site's NPC profiles and placements, and events' profile NPCs, are off") .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"), .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 ─────────────────────────────────── // ── The plugin's config: the website's ───────────────────────────────────

View File

@@ -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 { let npc_released = match &bundle.npc {
Some(component) => { Some(component) => {
let path = scratch.path().join(&component.asset.name); 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)?; let released = npc::read_tarball(&path)?;
if released.manifest.api != component.api { if released.manifest.api != component.api {
bail!( 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, bundle.bundle,
released.manifest.api, released.manifest.api,
component.api component.api
@@ -205,7 +208,12 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
} }
Some(released) 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 ────────────────────────────────────────────────── // ── Plan every instance ──────────────────────────────────────────────────

View File

@@ -8,8 +8,9 @@
//! it. RunicNPC makes that directory itself on first load, because one made from outside the game //! 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). //! 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 //! It is required since RunicNPC's stage 9 (D310): the bridge refuses every NPC an event places
//! without it runs the bridge with Rust's own scientists only (D243). //! 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::collections::BTreeMap;
use std::io::Read; use std::io::Read;