diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 9b2f943..ef9105f 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -477,6 +477,28 @@ DELETE /towncrier/{id} Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`. +### Publish / remove Town Cryer **news** (Protocol 2.1) + +Distinct from the scrolling-crier lines above: this puts a full article — title, HTML body, image, and a "more info" URL — into the in-game **Town Cryer News gump**, and (by default) has the criers proclaim the **title** in-world. + +``` +POST /news +{ "id": "42", "title": "Double XP Weekend", + "body": "
Double XP Weekend


Starts Friday 7PM.", + "image": 1614, "url": "https://yoursite/news/42" } +``` +→ **200** `{"kind":"news.ok","id":"42"}`. Re-posting the same `id` **replaces** the prior article in place. + +- `id`, `title` required. `body` (HTML supported), `image` (a UO gump id; a neutral scroll if omitted), `url` (a browser button in the gump) optional. +- `announce` defaults to **true** — the criers proclaim the title. Send `"announce": false` to post silently (e.g. a correction). + +``` +DELETE /news/{id} +``` +→ **200** `{"kind":"news.ok","id":"42"}`, or **404** `{"kind":"news.error","reason":"unknown id"}`. + +Caps apply (title/body length, max active articles). The **website is the source of truth**: the shard rebuilds its news list on restart and does not persist yours, so the sidecar automatically re-pushes your articles (silently) whenever the shard reconnects. Stock ServUO news is left intact — your articles are tracked separately. + ### Staff moderation — the write plane Account and session moderation against the live shard. **These are privileged.** The sidecar does diff --git a/docs/PROTOCOL_2.md b/docs/PROTOCOL_2.md index a3f0d46..7da8a15 100644 --- a/docs/PROTOCOL_2.md +++ b/docs/PROTOCOL_2.md @@ -482,7 +482,7 @@ Deployed the overlay to the ServUO checkout, booted the shard and the real sidec ## 16. Town Cryer news — website articles into the news gump (Protocol 2.1) -**Status:** Design, grounded in the shard's `Scripts/Services/Town Cryer/` files. Not yet built. +**Status:** **Built and smoke-tested live** (2026-07-17). `BridgeNews.cs` (pure overlay, no stock edit) + `POST /news` / `DELETE /news/{id}` + reconnect replay. Verified against a booted shard: `news.add` (full + title-only) → `news.ok`, missing title → 400, idempotent replace, `news.remove` → `news.ok`, unknown id → `news.error`, no shard exceptions, and the **reconnect replay** confirmed (after a shard restart the stored article was re-pushed with `announce:false` and re-accepted). The gump rendering itself is verified by source inspection (needs a UO client to view). There are **two** distinct town-crier surfaces in ServUO, and 2.0 has so far touched only the first: @@ -514,7 +514,7 @@ Everything else in the pasted note stands, and the "this is one of the easier in On an inbound article the bridge does two things on the Core thread: 1. **News gump** — build `new TownCryerNewsEntry(new TextDefinition(title), new TextDefinition(body), image, null, url)` and `Insert(0, …)` at the top of `TownCryerSystem.NewsEntries`, tracking it in `_ours`; trim `_ours` past the cap by removing the oldest (from both `_ours` and `NewsEntries`). -2. **Say the title** — reuse the scrolling-crier path (`GlobalTownCrierEntryList`, as `BridgeTownCrier` does) to announce a single line, the **title only**, for a short duration, so the crier proclaims it in-world. Optional per article (`announce: true`), so silent corrections don't re-proclaim. +2. **Say the title** — reuse the scrolling-crier path (`GlobalTownCrierEntryList`, as `BridgeTownCrier` does) to announce a single line, the **title only**, for a short duration, so the crier proclaims it in-world. **On by default**; set `announce: false` on an article to suppress it (e.g. a silent correction that should not re-proclaim). ### 16.4 Protocol @@ -522,7 +522,8 @@ On an inbound article the bridge does two things on the Core thread: // website → sidecar → shard {"kind":"news.add","id":"42","title":"Double XP Weekend", "body":"
Double XP Weekend


Starts Friday 7PM.", - "image":1614,"url":"https://uomysticmoon.com/news/42","announce":true} + "image":1614,"url":"https://uomysticmoon.com/news/42"} +// announce defaults to true; add "announce":false to suppress the crier proclamation {"kind":"news.remove","id":"42"} ``` diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index bf4fc04..7a5ef92 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -58,6 +58,15 @@ TownCrierMaxLineLength=200 TownCrierMaxActive=20 TownCrierMaxDurationSec=86400 +# Town Cryer news gump. Website articles (news.add) become entries in the modern Town +# Cryer News gump (TownCryerSystem.NewsEntries), separate from the scrolling-crier lines +# above. The article title is also proclaimed by the criers (announce defaults on). Caps +# are defense in depth on top of the loopback trust boundary. +NewsMaxTitleLength=100 +NewsMaxBodyLength=2000 +NewsMaxExternal=20 +NewsAnnounceDurationSec=300 + # Admin write plane (staff moderation from the website). OFF by default: the whole # feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/ # broadcast) are honored. Authorization is enforced on the website; the shard trusts the diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index e8aebcc..4ddd13e 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -43,6 +43,12 @@ namespace Server.Custom.Bridge public static int TownCrierMaxActive { get; private set; } public static int TownCrierMaxDurationSec { get; private set; } + // Town Cryer news gump (docs/PROTOCOL_2.md §16). + public static int NewsMaxTitleLength { get; private set; } + public static int NewsMaxBodyLength { get; private set; } + public static int NewsMaxExternal { get; private set; } + public static int NewsAnnounceDurationSec { get; private set; } + public static bool AdminWriteEnabled { get; private set; } public static AccessLevel AdminAccessFloor { get; private set; } public static int AdminBroadcastMaxLength { get; private set; } @@ -108,6 +114,13 @@ namespace Server.Custom.Bridge TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20); TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400); + NewsMaxTitleLength = Config.Get("Bridge.NewsMaxTitleLength", 100); + NewsMaxBodyLength = Config.Get("Bridge.NewsMaxBodyLength", 2000); + NewsMaxExternal = Config.Get("Bridge.NewsMaxExternal", 20); + NewsAnnounceDurationSec = Config.Get("Bridge.NewsAnnounceDurationSec", 300); + if (NewsAnnounceDurationSec < 1) + NewsAnnounceDurationSec = 1; + AdminWriteEnabled = Config.Get("Bridge.AdminWriteEnabled", false); AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner); AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300); diff --git a/overlay/Scripts/Custom/Bridge/BridgeNews.cs b/overlay/Scripts/Custom/Bridge/BridgeNews.cs new file mode 100644 index 0000000..c766406 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeNews.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; + +using Server.Mobiles; +using Server.Services.TownCryer; + +namespace Server.Custom.Bridge +{ + /// + /// Website news articles pushed into the modern Town Cryer News gump + /// (docs/PROTOCOL_2.md §16). Distinct from BridgeTownCrier, which drives the scrolling-crier + /// announcement lines (GlobalTownCrierEntryList). Here the full article — title, body (HTML), + /// image, and a "more info" URL — becomes a TownCryerNewsEntry in TownCryerSystem.NewsEntries, + /// which the stock news gumps already render (they branch on TextDefinition.Number, so string + /// content needs no gump change). + /// + /// No stock edit: NewsEntries is a public mutable list, so we insert/remove directly and keep + /// our own id -> entry map, leaving the stock entries untouched. On add we also proclaim just + /// the title through the existing crier say path (default on), so players hear it in-world. + /// + /// Everything runs on the Core thread (inbound lines are marshaled through Timer.DelayCall), + /// which is required to touch the shared news list and to send crier packets. + /// + public static class BridgeNews + { + // A neutral scroll gump when the website supplies no image. + private const int DefaultImage = 0x64E; + + // Website id -> the news entry we created for it, so a later remove/replace can find it. + private static readonly Dictionary _ours = + new Dictionary(StringComparer.Ordinal); + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("news.add", OnAdd); + BridgeBoot.RegisterHandler("news.remove", OnRemove); + } + + private static void OnAdd(Dictionary o) + { + var id = BridgeJson.GetString(o, "id"); + + if (id == null) + { + Reply("news.error", null, "missing id"); + return; + } + + var list = TownCryerSystem.NewsEntries; + if (list == null) + { + Reply("news.error", id, "town cryer unavailable"); + return; + } + + var title = BridgeJson.GetString(o, "title"); + if (String.IsNullOrEmpty(title)) + { + Reply("news.error", id, "missing title"); + return; + } + + var body = BridgeJson.GetString(o, "body") ?? ""; + var url = BridgeJson.GetString(o, "url"); + int image = BridgeJson.GetInt(o, "image", DefaultImage); + + // announce defaults to true (proclaim the title in-world); "announce":false suppresses it. + bool announce = true; + object rawAnnounce; + if (o.TryGetValue("announce", out rawAnnounce) && rawAnnounce is bool) + announce = (bool)rawAnnounce; + + if (title.Length > BridgeConfig.NewsMaxTitleLength) + title = title.Substring(0, BridgeConfig.NewsMaxTitleLength); + if (body.Length > BridgeConfig.NewsMaxBodyLength) + body = body.Substring(0, BridgeConfig.NewsMaxBodyLength); + + try + { + // Replace an existing id in place: drop the old entry first. + TownCryerNewsEntry old; + if (_ours.TryGetValue(id, out old) && old != null) + { + list.Remove(old); + _ours.Remove(id); + } + else if (_ours.Count >= BridgeConfig.NewsMaxExternal) + { + Reply("news.error", id, "too many news entries"); + return; + } + + var entry = new TownCryerNewsEntry( + new TextDefinition(title), + new TextDefinition(body), + image, + null, + url); + + list.Insert(0, entry); // newest first, as the gump reads top-down + _ours[id] = entry; + + if (announce) + Announce(title); + + Reply("news.ok", id, null); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] news.add threw: {0}", ex.Message); + Reply("news.error", id, "internal error"); + } + } + + private static void OnRemove(Dictionary o) + { + var id = BridgeJson.GetString(o, "id"); + + if (id == null) + { + Reply("news.error", null, "missing id"); + return; + } + + TownCryerNewsEntry entry; + if (!_ours.TryGetValue(id, out entry)) + { + Reply("news.error", id, "unknown id"); + return; + } + + _ours.Remove(id); + + try + { + var list = TownCryerSystem.NewsEntries; + if (list != null && entry != null) + list.Remove(entry); + + Reply("news.ok", id, null); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] news.remove threw: {0}", ex.Message); + Reply("news.error", id, "internal error"); + } + } + + /// Proclaims a single line — the article title — through the town criers. + private static void Announce(string title) + { + try + { + GlobalTownCrierEntryList.Instance.AddEntry( + new[] { title }, + TimeSpan.FromSeconds(BridgeConfig.NewsAnnounceDurationSec)); + } + catch (Exception ex) + { + // A failed proclamation must not fail the news add — the article is already posted. + Console.WriteLine("[Bridge] news announce threw: {0}", ex.Message); + } + } + + private static void Reply(string kind, string id, string reason) + { + var sb = BridgeJson.Begin(kind); + if (id != null) sb.Str("id", id); + if (reason != null) sb.Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + } +} diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index d60b849..c43b874 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -79,6 +79,7 @@ async fn main() -> anyhow::Result<()> { let route_rpc = rpc.clone(); let event_store = store.clone(); let last_event_ts = last_event.clone(); + let replay_handle = handle.clone(); // re-push external news to the shard on (re)connect let mut total: u64 = 0; tokio::spawn(async move { while let Some(ev) = event_rx.recv().await { @@ -187,6 +188,26 @@ async fn main() -> anyhow::Result<()> { } } + // On a shard (re)connect, re-push the stored external news: the shard rebuilds + // TownCryerSystem.NewsEntries from scratch each boot and does not persist ours. Replay + // with announce=false so a restart does not re-proclaim every article at once. news.add + // is idempotent by id, so replaying to a still-populated shard is harmless. + if ev.kind == "server.hello" { + match event_store.news_all().await { + Ok(items) => { + for mut item in items { + if let Some(obj) = item.as_object_mut() { + obj.insert("announce".to_string(), serde_json::json!(false)); + } + if !replay_handle.send(item.to_string()).await { + break; // shard went away mid-replay + } + } + } + Err(e) => tracing::warn!(error = %e, "news replay: could not read stored news"), + } + } + let _ = feed_tx.send(ev.value.to_string()); } }); diff --git a/sidecar/src/store.rs b/sidecar/src/store.rs index 3d476b8..d3ec36d 100644 --- a/sidecar/src/store.rs +++ b/sidecar/src/store.rs @@ -280,6 +280,42 @@ impl Store { .await?; Ok(parse_json_column(rows)) } + + // ---- Town Cryer news (Protocol 2.1) ---- + + /// Stores/replaces one external news article (the `news.add` command json), keyed by id. The + /// website is the source of truth; this lets the sidecar replay the set to the shard on reconnect + /// (the shard does not persist NewsEntries across a reboot). + pub async fn upsert_news(&self, id: &str, json: &str, t: i64) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO news (id, json, updated_t) VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET json = excluded.json, updated_t = excluded.updated_t", + ) + .bind(id) + .bind(json) + .bind(t) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Removes one external news article. + pub async fn delete_news(&self, id: &str) -> anyhow::Result<()> { + sqlx::query("DELETE FROM news WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Every stored external news article (as its `news.add` command), oldest first so a replay + /// re-inserts them in the same order the website added them. + pub async fn news_all(&self) -> anyhow::Result> { + let rows = sqlx::query("SELECT json FROM news ORDER BY updated_t") + .fetch_all(&self.pool) + .await?; + Ok(parse_json_column(rows)) + } } fn parse_json_column(rows: Vec) -> Vec { @@ -338,4 +374,10 @@ CREATE TABLE IF NOT EXISTS houses ( json TEXT NOT NULL, updated_t INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS news ( + id TEXT PRIMARY KEY, + json TEXT NOT NULL, + updated_t INTEGER NOT NULL +); "#; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 5657973..3d9fe46 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -59,6 +59,9 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/link/:account", get(link_lookup).delete(link_delete)) .route("/towncrier", post(towncrier_add)) .route("/towncrier/:id", axum::routing::delete(towncrier_remove)) + // Town Cryer news gump (Protocol 2.1). Add/replace an article; delete one. + .route("/news", post(news_add)) + .route("/news/:id", axum::routing::delete(news_remove)) // Staff write plane (correlated by reqId). The shard enforces the real authorization; // the website must gate these behind admin/moderator roles before calling. .route("/admin/kick", post(admin_kick)) @@ -661,6 +664,50 @@ async fn towncrier_remove(State(st): State, Path(id): Path) -> respond(st.rpc.call(&st.shard, cmd, &id).await) } +/// Body: {"id":"42","title":"...","body":"","image":1614,"url":"...","announce":true}. +/// Adds/replaces a Town Cryer news article. Correlated on `id`. A success is stored so the sidecar +/// can replay the article to the shard on reconnect (NewsEntries is not persisted across a reboot). +async fn news_add(State(st): State, Json(body): Json) -> impl IntoResponse { + let id = body.get("id").and_then(|i| i.as_str()).unwrap_or_default(); + let title_ok = body + .get("title") + .and_then(|t| t.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + if id.is_empty() || !title_ok { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "id and title are required"})), + ); + } + + let mut cmd = body.clone(); + cmd["kind"] = json!("news.add"); + let id = id.to_string(); + let result = st.rpc.call(&st.shard, cmd.clone(), &id).await; + + // Persist the article (as its news.add command) so it can be replayed on shard reconnect. + if let Ok(value) = &result { + if value.get("kind").and_then(|k| k.as_str()) == Some("news.ok") { + let t = value.get("t").and_then(|v| v.as_i64()).unwrap_or(0); + let _ = st.store.upsert_news(&id, &cmd.to_string(), t).await; + } + } + respond(result) +} + +async fn news_remove(State(st): State, Path(id): Path) -> impl IntoResponse { + let cmd = json!({"kind":"news.remove","id":id}); + let result = st.rpc.call(&st.shard, cmd, &id).await; + + if let Ok(value) = &result { + if value.get("kind").and_then(|k| k.as_str()) == Some("news.ok") { + let _ = st.store.delete_news(&id).await; + } + } + respond(result) +} + // ---- history (from SQLite) ---- #[derive(Deserialize)]