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