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

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