From 522187ddd1630e70d2041ed92959deb439f418d4 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 17:08:37 -0500 Subject: [PATCH 1/6] =?UTF-8?q?fix(sidecar):=20protocol=2013=20=E2=80=94?= =?UTF-8?q?=20a=20configuration=20write=20no=20longer=20waits=20for=20its?= =?UTF-8?q?=20reload=20(F9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin now answers config.write once the files are on disk and reports the reload as a config.outcome event, which this process files and feeds by its type like any other event. The reload window no longer has to fit inside REPLY_TIMEOUT, so CONFIG_RELOAD_WINDOW and the test that asserted the pairing are gone, and the 504 body stops pointing at a rollback. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 21 ++++++++++++------ sidecar/src/web.rs | 52 ++++++++++++--------------------------------- 2 files changed, 27 insertions(+), 46 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index b1e392d..226badc 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -101,11 +101,9 @@ use tracing_subscriber::EnvFilter; /// for the reload to announce itself, and **puts the old files back automatically** if it does /// not. /// -/// Two things about that reach this process. The write is the only route here that causes a write -/// on the game host, and it is the only one whose reply routinely spends seconds rather than -/// milliseconds — the plugin holds the correlation open across a reload and, at worst, across a -/// rollback as well. `web::CONFIG_RELOAD_WINDOW` is that budget, mirrored from the plugin, and a -/// test asserts the pairing rather than trusting it. +/// The write is the only route here that causes a write on the game host. Since protocol 13 its +/// reply comes back as soon as the files are on disk, marked `pending`; how the reload went follows +/// later as a `config.outcome` event, which this process files and feeds like any other. /// /// # Protocol 6 — first-party clans /// @@ -165,10 +163,19 @@ use tracing_subscriber::EnvFilter; /// keeps it, and positions are asked for while somebody is looking and never touch the database /// (D111). Which layer a viewer may see is decided on the website, never here. /// +/// # Protocol 13 — the first player walk's fixes +/// +/// Opened by the configuration save (the module's PLAN_FIXES.md F9, D177): `POST /config/write` +/// answers `pending` at once instead of holding the RPC across a reload, and the outcome arrives +/// as a `config.outcome` event. Nothing new to route — an event is filed and fed by its `type`, +/// whatever its kind — so what changes here is what no longer has to fit: the reload window that +/// had to sit inside [`rpc::REPLY_TIMEOUT`] is gone. +/// /// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the /// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases, §15 the world verbs, -/// §16 the rewards, §17 the map, §18 the optional mods; this constant is one of its four declaration sites. -pub const PROTOCOL_VERSION: u32 = 12; +/// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes; this constant is one +/// of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 13; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 6a38d1f..b542d1e 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -102,9 +102,8 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // an operator edited over SSH and the website then overwrote. .route("/config/files", get(config_files)) .route("/config/file", get(config_file)) - // The only route on this sidecar that causes a WRITE on the game host, and the only one - // whose reply can take most of the RPC budget: the plugin holds it open across a reload - // and, at worst, across a rollback as well. See `CONFIG_RELOAD_WINDOW`. + // The only route on this sidecar that causes a WRITE on the game host. It answers once the + // files are on disk; the reload's outcome follows as a `config.outcome` event (protocol 13). .route("/config/write", post(config_write)) // Protocol 8 (the module's PLAN.md §27): an event borrowing a value and giving it back. // Three correlated round trips, and the sidecar knows nothing about any of them — not @@ -694,19 +693,14 @@ async fn config_file(State(st): State, Query(q): Query, Json(body): Json) -> Re let result = st.rpc.call(&st.game, command, &req_id).await; if matches!(result, Err(RpcError::Timeout)) { - // A bare `504` on a route that writes reads as "did my change land, and is the plugin - // still up?" — and unlike every other timeout on this sidecar, that question has a good - // answer. The plugin writes a whole set or restores a whole set, never half of either, so - // a re-read settles it; and a write that is still in flight is most likely inside the - // rollback this window pays for. + // A bare `504` on a route that writes reads as "did my change land?" — and unlike every + // other timeout on this sidecar, that question has a good answer. The plugin writes a + // whole set or none of it, and restores a whole set or none of it, so a re-read settles + // it. Since protocol 13 the reply no longer waits for a reload, so this is a slow host or + // a busy main thread, not a rollback in progress. return ( StatusCode::GATEWAY_TIMEOUT, Json(json!({ "error": "the plugin did not report within the write budget", - "reloadWindowSeconds": CONFIG_RELOAD_WINDOW.as_secs(), "hint": "re-read the files: the plugin writes the whole set or restores it", })), ) @@ -1037,25 +1030,6 @@ mod tests { assert_eq!(command["grants"], json!([])); } - /// A command larger than the game link's own line cap is refused here, where the caller learns - /// why. Forwarded, it would be discarded by both ends without a word and present as a `504`. - /// The pairing in `CONFIG_RELOAD_WINDOW`'s own words, asserted rather than trusted. - /// - /// The worst path through one configuration write is two windows — wait for the edited - /// plugin's reload, give up, restore the files, reload again — plus the write, the reads and - /// a line each way. If that does not fit inside the RPC timeout, the caller is abandoned at - /// exactly the moment the rollback saved it, and the website reports a timeout over a server - /// that is healthy and has the old config back. - #[test] - fn a_rollback_fits_inside_the_rpc_budget() { - let worst = CONFIG_RELOAD_WINDOW * 2; - assert!( - worst + Duration::from_secs(1) <= crate::rpc::REPLY_TIMEOUT, - "two reload windows ({worst:?}) plus a second of slack must fit in {:?}", - crate::rpc::REPLY_TIMEOUT - ); - } - /// Protocol 8's routes cannot choose their own command or correlation id either, and they keep /// everything else the caller sent, unread. #[test] From fe7b2822c51ac65dd2b58463a028b8da4aaea734 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 21:25:35 -0500 Subject: [PATCH 2/6] feat(egg): install every plugin file the release lists, helpers included (D182) Rust-Plugins now releases a ZoneManager helper, RunicGatewayZones.cs, beside the bridge (docs/modules/rust/PLAN_FIXES.md D181, D182), installed by default. The egg copied only RunicGateway.cs out of the tarball. It now takes every .cs the plugin manifest lists in `files`, checks each against its own sha256 before anything is placed (the bridge's own checksum was never checked by the egg before), and refuses a name that is not a plain .cs. A release older than helpers lists only the bridge and installs exactly what it did before. Exercised in an Alpine shell against both, a tampered helper, a missing one, `../evil.cs` and `evil.dll`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- egg/install.sh | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/egg/install.sh b/egg/install.sh index ad8eee0..ce78fea 100755 --- a/egg/install.sh +++ b/egg/install.sh @@ -148,7 +148,25 @@ rg_install() { mkdir -p /mnt/server/rust-link "${plugins}" install -m 755 "${work}/rust-link-sidecar" /mnt/server/rust-link/rust-link-sidecar install -m 755 "${work}/with-sidecar.sh" /mnt/server/rust-link/with-sidecar.sh - install -m 644 "${work}/runicgateway-rust-plugin/RunicGateway.cs" "${plugins}/RunicGateway.cs" + # Every .cs the release's manifest lists: the bridge, and the helpers shipped + # beside it (docs/modules/rust/PLAN_FIXES.md D182 - today RunicGatewayZones.cs, + # the ZoneManager helper), each checked against its own sha256 first. A name + # is written into the plugins directory, so only a plain .cs is taken. + local file sha + for file in $(jq -r '.files | keys[]' "${manifest}"); do + case "${file}" in + *[!A-Za-z0-9_.]* | .* | *..* | *[!s] ) echo "Runic Gateway: the plugin manifest lists ${file}, which is not a plugin file - refusing it"; return 1 ;; + esac + [ "${file%.cs}" != "${file}" ] || { echo "Runic Gateway: the plugin manifest lists ${file}, which is not a .cs file - refusing it"; return 1; } + [ -f "${work}/runicgateway-rust-plugin/${file}" ] || { echo "Runic Gateway: the plugin manifest lists ${file} but the tarball has none"; return 1; } + sha="$(jq -r --arg f "${file}" '.files[$f]' "${manifest}")" + echo "${sha} ${work}/runicgateway-rust-plugin/${file}" | sha256sum -c --quiet - \ + || { 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; } + 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, From 3662363caf8b2ade65f61473274717a4b736481a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 28 Sep 2026 06:58:43 -0500 Subject: [PATCH 3/6] feat(sidecar): POST /permissions/inventory forwards the plugin's store read Protocol 13, the permission manager (PLAN_REDESIGNS section 1): one more opaque forward. The body is {snapshotId?, page?}; paging is the plugin's and the website's, and no page is kept here. Refs RunicGateway/Module-Rust#21 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 4 ++++ sidecar/src/web.rs | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 226badc..5f554e9 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -171,6 +171,10 @@ use tracing_subscriber::EnvFilter; /// whatever its kind — so what changes here is what no longer has to fit: the reload window that /// had to sit inside [`rpc::REPLY_TIMEOUT`] is gone. /// +/// The permission manager's redesign (the module's PLAN_REDESIGNS.md §1, D160) adds one route, +/// `POST /permissions/inventory`: the plugin's whole store, in pages it sizes to the game link's +/// line cap. Forwarded like every other object; the pages are never kept here. +/// /// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the /// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases, §15 the world verbs, /// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes; this constant is one diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index b542d1e..9eba2d9 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -95,6 +95,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // plugin will do with any of it. It puts `cmd` and `reqId` on the object and forwards it, // exactly as it forwards a link code. .route("/permissions/sync", post(perm_sync)) + // Protocol 13 (D160): the whole store, owners included, in pages. The body is + // `{snapshotId?, page?}` and is forwarded as it is; paging is the plugin's and the + // website's business, never this process's, which keeps no copy of any page. + .route("/permissions/inventory", post(perm_inventory)) // Protocol 5 (R18): the plugin's own view of the game host's configuration tree. All // three are correlated round trips and all three fail when the game is down, because // "what is on that host's disk" has no stale answer worth giving — and, unlike a board, @@ -435,6 +439,13 @@ async fn perm_sync(State(st): State, Json(body): Json) -> Respo respond(st.rpc.call(&st.game, command, &req_id).await) } +/// One page of the plugin's permission inventory (protocol 13). Page 0 without a `snapshotId` +/// starts a fresh read; any other page names the snapshot page 0 answered with. A `perm.error` +/// (`busy`, `stale`, `too-large`) is an answer, and arrives as a `200` like the sync's. +async fn perm_inventory(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "perm.inventory", "an inventory request").await +} + #[derive(Debug, Deserialize)] struct ConfigPathQuery { path: String, @@ -1081,6 +1092,20 @@ mod tests { assert!(stamp(json!(["9"]), "tally.close", "r-4").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() { + let body = + json!({ "cmd": "perm.sync", "reqId": "theirs", "snapshotId": "abc123", "page": 2 }); + let stamped = stamp(body, "perm.inventory", "r-7").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "perm.inventory"); + assert_eq!(stamped["reqId"], "r-7"); + assert_eq!(stamped["snapshotId"], "abc123"); + assert_eq!(stamped["page"], 2); + assert!(stamp(json!([0]), "perm.inventory", "r-8").is_none()); + } + /// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's /// and the plugin's business, so it must arrive byte for byte as the website composed it. #[test] From 3b9a986e3ab42a7352c1becc04e52bf16e5b5cb2 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 04:21:53 -0500 Subject: [PATCH 4/6] 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 5/6] 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 From 4eee1fbe1781eafb3eef434bc3c54e80983893df Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 9 Oct 2026 01:24:27 -0500 Subject: [PATCH 6/6] feat(egg): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328) The install writes PopupNotifications.json into oxide/config or carbon/configs as a banner across the top of the screen, by the installer's rule: only where the file is missing or every setting is still the plugin's own default (the Version aside). Anything else is kept whole. The plugin's Version is written or kept, because PopupNotifications resets a config without one. A failed write is said in the console and does not fail the install: it is a look, not the bridge. Tested in ghcr.io/ptero-eggs/installers:debian: missing, the rig's defaults, defaults from a later release (version kept), four kinds of change, not JSON, an existing banner, and an unmakeable directory. egg/build.sh builds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- egg/install.sh | 73 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/egg/install.sh b/egg/install.sh index 036ba9f..aaa25dd 100755 --- a/egg/install.sh +++ b/egg/install.sh @@ -88,6 +88,69 @@ if [ $WORLD_SEED == "0" ]; then fi ## ── The Runic Gateway bridge ───────────────────────────────────────────────── +# PopupNotifications' recommended look (docs/runicnpc/PLAN.md D327): a banner +# across the top of the screen. PopupNotifications is optional and the operator +# installs it (D141), so its config is theirs: the banner goes in only where the +# file is missing or is still exactly the plugin's own defaults, every setting +# equal and the Version aside (D328). Anything else is kept whole. The Version +# is written too: PopupNotifications resets a config without one to defaults. +rg_popup() { # + local file="$1/PopupNotifications.json" + local banner='{ + "Notification duration (in seconds)": 8, + "Maximum notifications shown at any time": 6, + "UI Positioning": { + "Position of the left side of notification (0.0 - 1.0)": 0.15, + "Position of the bottom of noticiation (0.0 - 1.0)": 0.87, + "Width (0.0 - 1.0)": 0.7, + "Height (0.0 - 1.0)": 0.07, + "Space between notification (0.0 - 1.0)": 0.005 + }, + "UI Options": { + "Show close button": false, + "Panel color (hex)": "#2b2b2b", + "Panel transparency (0.0 - 1.0)": 0.8, + "Close button color (hex)": "#d85540", + "Close button transparency (0.0 - 1.0)": 0.5, + "Font": "robotocondensed-bold.ttf", + "Font size": 18 + }, + "Version": { "Major": 0, "Minor": 2, "Patch": 1 } + }' + # PopupNotifications 0.2.1's GetBaseConfig(): a small grey box at the right. + local defaults='{ + "Notification duration (in seconds)": 8, + "Maximum notifications shown at any time": 6, + "UI Positioning": { + "Position of the left side of notification (0.0 - 1.0)": 0.8, + "Position of the bottom of noticiation (0.0 - 1.0)": 0.78, + "Width (0.0 - 1.0)": 0.19, + "Height (0.0 - 1.0)": 0.1, + "Space between notification (0.0 - 1.0)": 0.01 + }, + "UI Options": { + "Show close button": true, + "Panel color (hex)": "#2b2b2b", + "Panel transparency (0.0 - 1.0)": 0.5, + "Close button color (hex)": "#d85540", + "Close button transparency (0.0 - 1.0)": 0.5, + "Font": "droidsansmono.ttf", + "Font size": 12 + } + }' + if [ ! -e "${file}" ]; then + mkdir -p "$1" || return 1 + jq -n "${banner}" > "${file}" || return 1 + echo "Runic Gateway: wrote the recommended PopupNotifications banner to ${file}" + elif jq -e --argjson d "${defaults}" 'type == "object" and del(.Version) == $d' "${file}" >/dev/null 2>&1; then + jq --argjson b "${banner}" '$b + { Version: (.Version // $b.Version) }' "${file}" > "${file}.rg" \ + && mv "${file}.rg" "${file}" || { rm -f "${file}.rg"; return 1; } + echo "Runic Gateway: ${file} was PopupNotifications' defaults; wrote the recommended banner" + else + echo "Runic Gateway: kept ${file} (not PopupNotifications' defaults)" + fi +} + # After the wipe, so REMOVE_FILES can never delete the plugin this just placed. rg_install() { set -euo pipefail @@ -95,10 +158,10 @@ rg_install() { # just the variables an egg declares, and this one is not declared. local api="${RUNICGATEWAY_BUNDLE_API:-https://gitea.whitlocktech.com/api/v1/repos/RunicGateway/installer/contents/v2/rust}" local work=/tmp/runicgateway - local plugins + local plugins config case "${FRAMEWORK:-vanilla}" in - oxide) plugins=/mnt/server/oxide/plugins ;; - carbon) plugins=/mnt/server/carbon/plugins ;; + oxide) plugins=/mnt/server/oxide/plugins; config=/mnt/server/oxide/config ;; + carbon) plugins=/mnt/server/carbon/plugins; config=/mnt/server/carbon/configs ;; *) # Not a failure (§34.4): failing would leave the operator without a game # server over a bridge they may not want yet. The startup skips the @@ -188,6 +251,10 @@ rg_install() { for file in $(jq -r '.files | keys[]' "${manifest}"); do install -m 644 "${work}/runicgateway-rust-plugin/${file}" "${plugins}/${file}" done + # A look, not the bridge: if it cannot be written, say so and finish the install. + if ! rg_popup "${config}"; then + echo "Runic Gateway: could not write the PopupNotifications banner to ${config}; the bridge is installed regardless" + fi # 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,