feat(protocol2): Town Cryer news-gump integration (§16, Protocol 2.1)

Website news articles now land in the modern Town Cryer News gump
(TownCryerSystem.NewsEntries), separate from the scrolling-crier lines.

Overlay BridgeNews (new): news.add / news.remove insert/remove a
TownCryerNewsEntry directly in the public NewsEntries list (no stock edit),
tracking our own id->entry map so stock uo.com news is left intact. Title,
HTML body, image, and URL are all supported (the stock gumps already branch on
TextDefinition.Number, so string content renders). On add the article title is
also proclaimed via GlobalTownCrierEntryList (announce defaults on; set
announce:false to suppress). Config caps: NewsMaxTitleLength/BodyLength/
External, NewsAnnounceDurationSec.

Sidecar: POST /news (add/replace, id-correlated), DELETE /news/{id}; news table
stores each article as its news.add command; on shard server.hello the sidecar
replays the stored set with announce:false (the shard rebuilds NewsEntries each
boot and does not persist ours, so the website is the source of truth).

Docs: PROTOCOL_2 §16 (design + verified), INTEGRATION.md /news endpoints.

Verified live: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors); booted shard + sidecar and exercised add/replace/
remove/error paths and the reconnect replay end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 11:27:13 -05:00
parent 6f76a8d35f
commit fd9c9fd96a
8 changed files with 334 additions and 3 deletions

View File

@@ -477,6 +477,28 @@ DELETE /towncrier/{id}
Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`. 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": "<CENTER>Double XP Weekend</CENTER><BR><BR>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 ### Staff moderation — the write plane
Account and session moderation against the live shard. **These are privileged.** The sidecar does Account and session moderation against the live shard. **These are privileged.** The sidecar does

View File

@@ -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) ## 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: 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: 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`). 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 ### 16.4 Protocol
@@ -522,7 +522,8 @@ On an inbound article the bridge does two things on the Core thread:
// website → sidecar → shard // website → sidecar → shard
{"kind":"news.add","id":"42","title":"Double XP Weekend", {"kind":"news.add","id":"42","title":"Double XP Weekend",
"body":"<CENTER>Double XP Weekend</CENTER><BR><BR>Starts Friday 7PM.", "body":"<CENTER>Double XP Weekend</CENTER><BR><BR>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"} {"kind":"news.remove","id":"42"}
``` ```

View File

@@ -58,6 +58,15 @@ TownCrierMaxLineLength=200
TownCrierMaxActive=20 TownCrierMaxActive=20
TownCrierMaxDurationSec=86400 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 # 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/ # 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 # broadcast) are honored. Authorization is enforced on the website; the shard trusts the

View File

@@ -43,6 +43,12 @@ namespace Server.Custom.Bridge
public static int TownCrierMaxActive { get; private set; } public static int TownCrierMaxActive { get; private set; }
public static int TownCrierMaxDurationSec { 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 bool AdminWriteEnabled { get; private set; }
public static AccessLevel AdminAccessFloor { get; private set; } public static AccessLevel AdminAccessFloor { get; private set; }
public static int AdminBroadcastMaxLength { get; private set; } public static int AdminBroadcastMaxLength { get; private set; }
@@ -108,6 +114,13 @@ namespace Server.Custom.Bridge
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20); TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400); 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); AdminWriteEnabled = Config.Get("Bridge.AdminWriteEnabled", false);
AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner); AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner);
AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300); AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300);

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using Server.Mobiles;
using Server.Services.TownCryer;
namespace Server.Custom.Bridge
{
/// <summary>
/// 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.
/// </summary>
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<string, TownCryerNewsEntry> _ours =
new Dictionary<string, TownCryerNewsEntry>(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<string, object> 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<string, object> 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");
}
}
/// <summary>Proclaims a single line — the article title — through the town criers.</summary>
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());
}
}
}

View File

@@ -79,6 +79,7 @@ async fn main() -> anyhow::Result<()> {
let route_rpc = rpc.clone(); let route_rpc = rpc.clone();
let event_store = store.clone(); let event_store = store.clone();
let last_event_ts = last_event.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; let mut total: u64 = 0;
tokio::spawn(async move { tokio::spawn(async move {
while let Some(ev) = event_rx.recv().await { 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()); let _ = feed_tx.send(ev.value.to_string());
} }
}); });

View File

@@ -280,6 +280,42 @@ impl Store {
.await?; .await?;
Ok(parse_json_column(rows)) 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<Vec<Value>> {
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<sqlx::sqlite::SqliteRow>) -> Vec<Value> { fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
@@ -338,4 +374,10 @@ CREATE TABLE IF NOT EXISTS houses (
json TEXT NOT NULL, json TEXT NOT NULL,
updated_t INTEGER 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
);
"#; "#;

View File

@@ -59,6 +59,9 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/link/:account", get(link_lookup).delete(link_delete)) .route("/link/:account", get(link_lookup).delete(link_delete))
.route("/towncrier", post(towncrier_add)) .route("/towncrier", post(towncrier_add))
.route("/towncrier/:id", axum::routing::delete(towncrier_remove)) .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; // Staff write plane (correlated by reqId). The shard enforces the real authorization;
// the website must gate these behind admin/moderator roles before calling. // the website must gate these behind admin/moderator roles before calling.
.route("/admin/kick", post(admin_kick)) .route("/admin/kick", post(admin_kick))
@@ -661,6 +664,50 @@ async fn towncrier_remove(State(st): State<AppState>, Path(id): Path<String>) ->
respond(st.rpc.call(&st.shard, cmd, &id).await) respond(st.rpc.call(&st.shard, cmd, &id).await)
} }
/// Body: {"id":"42","title":"...","body":"<html>","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<AppState>, Json(body): Json<Value>) -> 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<AppState>, Path(id): Path<String>) -> 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) ---- // ---- history (from SQLite) ----
#[derive(Deserialize)] #[derive(Deserialize)]