Protocol 2.0 + 2.1 — account provisioning, world-state streams, Town Cryer news #7
@@ -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": "<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
|
||||
|
||||
Account and session moderation against the live shard. **These are privileged.** The sidecar does
|
||||
|
||||
@@ -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":"<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"}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
176
overlay/Scripts/Custom/Bridge/BridgeNews.cs
Normal file
176
overlay/Scripts/Custom/Bridge/BridgeNews.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
"#;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user