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)]