From 0db3a8ac737bab2c932edd6fdf12e11f4be8b17d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 9 Oct 2026 01:24:25 -0500 Subject: [PATCH] feat(rust): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328) install and update write PopupNotifications.json into the framework's config directory, as a banner across the top of the screen, only where the file is missing or every setting is still PopupNotifications 0.2.1's own default (the Version aside, numbers compared as numbers). A config with any setting changed, or one that does not parse, is kept whole. The file carries the plugin's Version, because PopupNotifications resets a config without one to its defaults on load. The plan prints a "popup look" row naming the case. Run against two scratch roots (a temporary local bypass of the RunicNPC check, since no published bundle carries RunicNPC yet): Oxide with no config got the banner; Carbon at the rig's defaults got the walk's banner file exactly; on update a hand-changed config and an existing banner were both kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- src/rustgame/install.rs | 38 +++++ src/rustgame/mod.rs | 1 + src/rustgame/popup.rs | 312 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 src/rustgame/popup.rs diff --git a/src/rustgame/install.rs b/src/rustgame/install.rs index 819b7cb..16f69ba 100644 --- a/src/rustgame/install.rs +++ b/src/rustgame/install.rs @@ -20,6 +20,7 @@ use anyhow::{bail, Context, Result}; use super::npc; use super::plugin::{self, ConfigView}; +use super::popup; use super::record::{ComponentRecord, DeployedFile, Instance, NpcRecord, RustRecord, SCHEMA}; use super::server::{self, RustServer}; use super::sidecar; @@ -49,6 +50,8 @@ struct Planned { retired_helpers: Vec, /// RunicNPC, when the bundle carries it (D224): absent, identical or replaced. npc_action: Option, + /// PopupNotifications' config: the banner where it is missing or the plugin's defaults (D327). + popup: popup::State, game_port: u16, web_port: u16, config_path: PathBuf, @@ -395,6 +398,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { || p.npc_action.is_some_and(|a| a.writes()) || p.helper_actions.iter().any(|a| a.writes()) || !p.retired_helpers.is_empty() + || p.popup.writes() }) .count(); if restored == 0 && !binary_action.writes() { @@ -537,10 +541,12 @@ fn plan_instance( Ok(_) => BinaryAction::Replace, }, ); + let popup = popup::inspect(&server.framework.config_dir(&server.path).join(popup::FILE)); let config_path = layout.rust_config(id); Ok(Planned { id: id.to_string(), running: server::is_running(&server.path), + popup, config_exists: config_path.exists(), config_path, db_path: layout.rust_db(id), @@ -641,6 +647,18 @@ fn print_plan( if p.config_exists { "kept" } else { "written" } ), ); + ui::row( + "popup look", + &format!( + "{} {}", + p.server + .framework + .config_dir(&p.server.path) + .join(popup::FILE) + .display(), + p.popup.label() + ), + ); ui::row( "ports", &format!( @@ -703,6 +721,26 @@ fn deploy_instance( ui::ok(&format!("wrote {}", plugin_config_path.display())); } + // PopupNotifications' look (D327): the banner, only over nothing or over the plugin's own + // defaults. Before the plugins, so a PopupNotifications that loads after them reads it; one + // already loaded keeps its old look until it is reloaded. + if let Some(banner) = popup::banner_for(plan.popup) { + let dir = plan.server.framework.config_dir(&plan.server.path); + std::fs::create_dir_all(&dir) + .with_context(|| format!("cannot create {}", dir.display()))?; + let path = dir.join(popup::FILE); + write_atomic(&path, banner.as_bytes())?; + ui::ok(&format!( + "wrote the PopupNotifications banner {}{}", + path.display(), + if plan.running { + " (takes effect when PopupNotifications next loads)" + } else { + "" + } + )); + } + // The sidecar's config, once; the token is the sidecar's to generate. if !plan.config_exists { if let Some(parent) = plan.config_path.parent() { diff --git a/src/rustgame/mod.rs b/src/rustgame/mod.rs index 290e27f..a682a37 100644 --- a/src/rustgame/mod.rs +++ b/src/rustgame/mod.rs @@ -15,6 +15,7 @@ mod doctor; mod install; mod npc; mod plugin; +mod popup; mod record; mod server; mod sidecar; diff --git a/src/rustgame/popup.rs b/src/rustgame/popup.rs new file mode 100644 index 0000000..8cf5181 --- /dev/null +++ b/src/rustgame/popup.rs @@ -0,0 +1,312 @@ +//! The recommended PopupNotifications look: a banner across the top of the screen (docs/runicnpc/ +//! PLAN.md stage 9e, D327). +//! +//! PopupNotifications is optional and the operator's to install (D141); its config is the +//! operator's too. The installer writes the banner into it only where it is **missing** or **still +//! exactly the plugin's own defaults** — every setting equal to 0.2.1's, the `Version` aside (D328). +//! A config with any one setting changed is somebody's choice and is kept whole, never merged into. +//! A config that does not parse is kept too: it is not ours to repair. +//! +//! The file carries a `Version`: PopupNotifications resets a config whose version is missing or +//! below 0.2.0 to its defaults on load, which would undo the banner at the first boot. + +use std::path::Path; + +use serde_json::Value; + +/// The config's file name, in the framework's config directory beside the bridge's. +pub const FILE: &str = "PopupNotifications.json"; + +/// What is there now, and so what happens to it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum State { + /// No config: the banner is written, and PopupNotifications reads it when it is installed. + Missing, + /// The plugin's own defaults, untouched: the banner replaces them. Holds the file's version. + Defaults(Version), + /// Changed by someone, or unreadable: kept. + Theirs, +} + +/// PopupNotifications' `VersionNumber`, as its config writes it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Version { + pub major: u64, + pub minor: u64, + pub patch: u64, +} + +/// The version written into a config made from nothing: the release the defaults are read from. +const WRITTEN_VERSION: Version = Version { + major: 0, + minor: 2, + patch: 1, +}; + +impl State { + pub fn writes(self) -> bool { + !matches!(self, Self::Theirs) + } + + pub fn label(self) -> &'static str { + match self { + Self::Missing => "banner written (no config yet)", + Self::Defaults(_) => "banner written (was the plugin's defaults)", + Self::Theirs => "kept (not the plugin's defaults)", + } + } +} + +/// Reads the config at `path` and says what it is. +pub fn inspect(path: &Path) -> State { + match std::fs::read(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => State::Missing, + Err(_) => State::Theirs, + Ok(bytes) => classify(&bytes), + } +} + +fn classify(bytes: &[u8]) -> State { + let Ok(Value::Object(mut found)) = serde_json::from_slice::(bytes) else { + return State::Theirs; + }; + let version = found.remove("Version"); + let Ok(Value::Object(mut defaults)) = serde_json::from_str::(&render(DEFAULTS, None)) + else { + unreachable!("the defaults are valid JSON") + }; + defaults.remove("Version"); + if !same(&Value::Object(found), &Value::Object(defaults)) { + return State::Theirs; + } + State::Defaults( + version + .as_ref() + .and_then(read_version) + .unwrap_or(WRITTEN_VERSION), + ) +} + +fn read_version(v: &Value) -> Option { + Some(Version { + major: v.get("Major")?.as_u64()?, + minor: v.get("Minor")?.as_u64()?, + patch: v.get("Patch")?.as_u64()?, + }) +} + +/// JSON equality with numbers compared as numbers: the plugin writes its floats through +/// Newtonsoft, and a value written `1.0` by one framework and `1` by another is the same setting. +fn same(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() < 1e-6, + _ => false, + }, + (Value::Object(x), Value::Object(y)) => { + x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same(v, w))) + } + (Value::Array(x), Value::Array(y)) => { + x.len() == y.len() && x.iter().zip(y).all(|(v, w)| same(v, w)) + } + _ => a == b, + } +} + +/// The banner, as the file to write for `state`. `None` for a config that is kept. +pub fn banner_for(state: State) -> Option { + match state { + State::Missing => Some(render(BANNER, Some(WRITTEN_VERSION))), + State::Defaults(version) => Some(render(BANNER, Some(version))), + State::Theirs => None, + } +} + +/// One look: the eleven settings PopupNotifications has. +struct Look { + left: &'static str, + bottom: &'static str, + width: &'static str, + height: &'static str, + spacing: &'static str, + close: bool, + alpha: &'static str, + font: &'static str, + font_size: u32, +} + +/// PopupNotifications 0.2.1's `GetBaseConfig()`: a small grey box at the right of the screen. +const DEFAULTS: Look = Look { + left: "0.8", + bottom: "0.78", + width: "0.19", + height: "0.1", + spacing: "0.01", + close: true, + alpha: "0.5", + font: "droidsansmono.ttf", + font_size: 12, +}; + +/// The recommended look (9d player session, 2026-10-09): a banner across the top of the screen. +const BANNER: Look = Look { + left: "0.15", + bottom: "0.87", + width: "0.7", + height: "0.07", + spacing: "0.005", + close: false, + alpha: "0.8", + font: "robotocondensed-bold.ttf", + font_size: 18, +}; + +/// The config file, laid out as the plugin writes it. Duration, the most shown, both colours and +/// the close button's transparency are the plugin's own in both looks. +fn render(look: Look, version: Option) -> String { + let version = version.unwrap_or(WRITTEN_VERSION); + format!( + r##"{{ + "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)": {left}, + "Position of the bottom of noticiation (0.0 - 1.0)": {bottom}, + "Width (0.0 - 1.0)": {width}, + "Height (0.0 - 1.0)": {height}, + "Space between notification (0.0 - 1.0)": {spacing} + }}, + "UI Options": {{ + "Show close button": {close}, + "Panel color (hex)": "#2b2b2b", + "Panel transparency (0.0 - 1.0)": {alpha}, + "Close button color (hex)": "#d85540", + "Close button transparency (0.0 - 1.0)": 0.5, + "Font": "{font}", + "Font size": {font_size} + }}, + "Version": {{ + "Major": {major}, + "Minor": {minor}, + "Patch": {patch} + }} +}}"##, + left = look.left, + bottom = look.bottom, + width = look.width, + height = look.height, + spacing = look.spacing, + close = look.close, + alpha = look.alpha, + font = look.font, + font_size = look.font_size, + major = version.major, + minor = version.minor, + patch = version.patch, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::TempDir; + + /// The file PopupNotifications 0.2.1 wrote on both rigs at its first load (2026-10-09). + const RIG_DEFAULTS: &str = r##"{ + "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 + }, + "Version": { + "Major": 0, + "Minor": 2, + "Patch": 1 + } +}"##; + + #[test] + fn a_missing_config_gets_the_banner_with_a_version() { + let dir = TempDir::new("rg-popup-missing").unwrap(); + let state = inspect(&dir.path().join(FILE)); + assert_eq!(state, State::Missing); + let written: Value = serde_json::from_str(&banner_for(state).unwrap()).unwrap(); + assert_eq!(written["UI Positioning"]["Width (0.0 - 1.0)"], 0.7); + assert_eq!(written["UI Options"]["Show close button"], false); + assert_eq!(written["UI Options"]["Font"], "robotocondensed-bold.ttf"); + assert_eq!(written["UI Options"]["Font size"], 18); + // Without a version the plugin resets the file to its defaults at load. + assert_eq!(written["Version"]["Minor"], 2); + } + + #[test] + fn the_plugins_own_defaults_are_replaced_and_their_version_kept() { + assert_eq!( + classify(RIG_DEFAULTS.as_bytes()), + State::Defaults(WRITTEN_VERSION) + ); + let later = RIG_DEFAULTS.replace("\"Patch\": 1", "\"Patch\": 4"); + let state = classify(later.as_bytes()); + assert_eq!( + state, + State::Defaults(Version { + major: 0, + minor: 2, + patch: 4 + }) + ); + assert!(banner_for(state).unwrap().contains("\"Patch\": 4")); + // A whole number written as a float is still the default. + let floats = RIG_DEFAULTS.replace("\"Font size\": 12", "\"Font size\": 12.0"); + assert!(matches!(classify(floats.as_bytes()), State::Defaults(_))); + } + + #[test] + fn the_rendered_defaults_are_the_file_the_plugin_writes() { + let ours: Value = serde_json::from_str(&render(DEFAULTS, None)).unwrap(); + let rig: Value = serde_json::from_str(RIG_DEFAULTS).unwrap(); + assert_eq!(ours, rig); + } + + #[test] + fn any_change_keeps_the_whole_config() { + for changed in [ + RIG_DEFAULTS.replace("\"Font size\": 12", "\"Font size\": 14"), + RIG_DEFAULTS.replace( + "\"Notification duration (in seconds)\": 8", + "\"Notification duration (in seconds)\": 9", + ), + RIG_DEFAULTS.replace("#2b2b2b", "#000000"), + render(BANNER, None), + "{ not json".to_string(), + "[]".to_string(), + ] { + let state = classify(changed.as_bytes()); + assert_eq!(state, State::Theirs, "{changed}"); + assert_eq!(banner_for(state), None); + } + } + + #[test] + fn a_config_with_a_setting_added_or_missing_is_theirs() { + let mut v: Value = serde_json::from_str(RIG_DEFAULTS).unwrap(); + v["Extra"] = Value::Bool(true); + assert_eq!(classify(v.to_string().as_bytes()), State::Theirs); + let mut v: Value = serde_json::from_str(RIG_DEFAULTS).unwrap(); + v.as_object_mut().unwrap().remove("UI Options"); + assert_eq!(classify(v.to_string().as_bytes()), State::Theirs); + } +}