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

@@ -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());
}
});

View File

@@ -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<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> {
@@ -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
);
"#;

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("/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<AppState>, Path(id): Path<String>) ->
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) ----
#[derive(Deserialize)]