diff --git a/egg/install.sh b/egg/install.sh index ad8eee0..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 @@ -138,6 +201,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" @@ -148,13 +226,42 @@ 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; } + # 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 + # 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, - 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 diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index b1e392d..5f554e9 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,23 @@ 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. +/// +/// 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; 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..bff518a 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, @@ -102,9 +106,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 @@ -139,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() @@ -436,6 +445,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, @@ -575,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. @@ -694,19 +736,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 +1073,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] @@ -1107,6 +1124,40 @@ 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() { + 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]