From 5e0b42b94862d9491777eeedc4023f727328e02a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 11:27:13 -0500 Subject: [PATCH] =?UTF-8?q?feat(protocol2):=20Town=20Cryer=20news-gump=20i?= =?UTF-8?q?ntegration=20(=C2=A716,=20Protocol=202.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- overlay/Config/Bridge.cfg | 9 + overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 13 ++ overlay/Scripts/Custom/Bridge/BridgeNews.cs | 176 ++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeNews.cs 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()); + } + } +}