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