From 3b9a986e3ab42a7352c1becc04e52bf16e5b5cb2 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 04:21:53 -0500 Subject: [PATCH 1/2] feat(sidecar): forward RunicNPC's profiles and placements (runicnpc stage 4) GET and POST /npc/profiles, GET /npc/placements and POST /npc/placement: four thin forwards of the plugin's npc.* commands, stamped like every other. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/web.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 9eba2d9..bff518a 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -142,6 +142,12 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // held by the plugin in memory and read by BetterChat on the chat path. Nothing here knows // what a title is. .route("/titles", post(titles)) + // Protocol 13, RunicNPC (runicnpc PLAN.md stage 4): the site's NPC profiles and the + // server's placements. RunicNPC is called by the plugin, never by this process, and nothing + // here knows a profile from a placement. Four more thin forwards. + .route("/npc/profiles", get(npc_profiles).post(npc_profiles_set)) + .route("/npc/placements", get(npc_placements)) + .route("/npc/placement", post(npc_placement)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -585,6 +591,32 @@ async fn titles(State(st): State, Json(body): Json) -> Response forward_object(&st, body, "titles.set", "a title set").await } +/// The server's RunicNPC profiles as RunicNPC holds them: whether a site manages them, each profile, +/// and those refused. The website reads this before its first push, to adopt them (D244). Live. +async fn npc_profiles(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "npc.profiles", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Replace the whole profile set (a push). `npc.ok` lists the profiles RunicNPC refused. +async fn npc_profiles_set(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "npc.profiles.set", "a profile push").await +} + +/// Every placement, the routes one may walk and the cost warning. Live: an admin's `/rnpc` in game +/// changes it at any moment. +async fn npc_placements(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "npc.placements", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// One change to one placement, by `op`: add, set, remove, rename or respawn. +async fn npc_placement(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "npc.placement", "a placement change").await +} + /// What this map is, where its picture comes from, and its monuments (protocol 11, stage one). /// Live, and never cached here: a wipe changes the answer, and the module compares its key and /// hash against what it holds to decide whether to fetch at all. @@ -1092,6 +1124,26 @@ mod tests { assert!(stamp(json!(["9"]), "tally.close", "r-4").is_none()); } + /// RunicNPC's writes are stamped like every other forward: a placement change cannot become a + /// profile push, or anything else, by naming one. + #[test] + fn a_runicnpc_command_cannot_choose_its_own_command() { + let body = json!({ "cmd": "npc.profiles.set", "reqId": "theirs", "op": "remove", "id": "bandit-1" }); + let stamped = stamp(body, "npc.placement", "r-9").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "npc.placement"); + assert_eq!(stamped["reqId"], "r-9"); + assert_eq!(stamped["op"], "remove"); + assert_eq!(stamped["id"], "bandit-1"); + + let body = json!({ "cmd": "npc.placement", "profiles": { "warden": { "health": 250 } } }); + let stamped = stamp(body, "npc.profiles.set", "r-10").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "npc.profiles.set"); + assert_eq!(stamped["profiles"]["warden"]["health"], 250); + assert!(stamp(json!("warden"), "npc.profiles.set", "r-11").is_none()); + } + /// Protocol 13: an inventory page request keeps its paging fields and cannot become a sync. #[test] fn an_inventory_request_cannot_choose_its_own_command() { From 10221b7e2011d3922b73d887b392289d367c36bc Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 08:11:34 -0500 Subject: [PATCH 2/2] feat(egg): install RunicNPC when the bundle carries it (runicnpc stage 4, D224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install script fetches RunicNPC's tarball with the rest, checks it against the bundle's sha256, its API against the bundle and its file against its own manifest, and places RunicNPC.cs before the bridge. Its data directory is left for RunicNPC to make (runicnpc PLAN.md §1.5). rust-link/bundle.json names it. Walked against a mock Gitea with and without RunicNPC. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- egg/install.sh | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/egg/install.sh b/egg/install.sh index ce78fea..036ba9f 100755 --- a/egg/install.sh +++ b/egg/install.sh @@ -138,6 +138,21 @@ rg_install() { fetch '.sidecar.assets["linux-x86_64"]' rust-link-sidecar fetch '.sidecar.launcher' with-sidecar.sh fetch '.payload.asset' plugin.tar.gz + # RunicNPC (docs/runicnpc/PLAN.md D224), when the bundle carries it: a third + # file beside the bridge, checked the same way before anything is placed. + local npc="" + if jq -e '.npc != null' "${work}/bundle.json" >/dev/null; then + npc="$(jq -r '.npc.tag' "${work}/bundle.json")" + fetch '.npc.asset' runicnpc.tar.gz + tar -xzf "${work}/runicnpc.tar.gz" -C "${work}" + local npc_manifest="${work}/runicnpc/manifest.json" + [ -f "${npc_manifest}" ] || { echo "Runic Gateway: the RunicNPC tarball has no manifest.json"; return 1; } + [ "$(jq -r '.api' "${npc_manifest}")" = "$(jq -r '.npc.api' "${work}/bundle.json")" ] \ + || { echo "Runic Gateway: RunicNPC answers API $(jq -r '.api' "${npc_manifest}"), the bundle says $(jq -r '.npc.api' "${work}/bundle.json") - refusing it"; return 1; } + [ -f "${work}/runicnpc/RunicNPC.cs" ] || { echo "Runic Gateway: the RunicNPC tarball has no RunicNPC.cs"; return 1; } + echo "$(jq -r '.files["RunicNPC.cs"]' "${npc_manifest}") ${work}/runicnpc/RunicNPC.cs" | sha256sum -c --quiet - \ + || { echo "Runic Gateway: RunicNPC.cs does not match its manifest's sha256 - refusing it"; return 1; } + fi tar -xzf "${work}/plugin.tar.gz" -C "${work}" local manifest="${work}/runicgateway-rust-plugin/manifest.json" @@ -164,15 +179,22 @@ rg_install() { || { echo "Runic Gateway: ${file} does not match the plugin manifest's sha256 - refusing it"; return 1; } done [ -f "${work}/runicgateway-rust-plugin/RunicGateway.cs" ] || { echo "Runic Gateway: the plugin tarball has no RunicGateway.cs"; return 1; } + # RunicNPC before the bridge: the bridge reports it at hello. Its data directory + # is NOT made here - one made from outside the game is not writable by it + # (runicnpc PLAN.md §1.5); RunicNPC makes its own on first load. + if [ -n "${npc}" ]; then + install -m 644 "${work}/runicnpc/RunicNPC.cs" "${plugins}/RunicNPC.cs" + fi for file in $(jq -r '.files | keys[]' "${manifest}"); do install -m 644 "${work}/runicgateway-rust-plugin/${file}" "${plugins}/${file}" done # What is installed, readable from the panel's file manager. jq --arg framework "${FRAMEWORK}" --arg installed "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ '{ bundle, protocol, framework: $framework, installed: $installed, - sidecar: { tag: .sidecar.tag }, plugin: { tag: .payload.tag, commit: .payload.commit } }' \ + sidecar: { tag: .sidecar.tag }, plugin: { tag: .payload.tag, commit: .payload.commit } } + + (if .npc == null then {} else { runicnpc: { tag: .npc.tag, api: .npc.api } } end)' \ "${work}/bundle.json" > /mnt/server/rust-link/bundle.json - echo "Runic Gateway: installed sidecar $(jq -r '.sidecar.tag' "${work}/bundle.json") and plugin $(jq -r '.payload.tag' "${work}/bundle.json") (${FRAMEWORK})" + echo "Runic Gateway: installed sidecar $(jq -r '.sidecar.tag' "${work}/bundle.json") and plugin $(jq -r '.payload.tag' "${work}/bundle.json")${npc:+ and RunicNPC ${npc}} (${FRAMEWORK})" echo "Runic Gateway: add this server under Admin -> Rust -> Servers; the console prints its URL and, on first boot, its token." } # In a subshell so `set -e` inside cannot leak into the rest of this script, and