From 0182732d6340980ef0c34970b7017fa64ea59d2c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 7 Sep 2026 01:51:35 -0500 Subject: [PATCH] feat(bridge): the world verbs an event owns (protocol 7, Phase 12a) Five verbs an author sees -- creatures, an enhanced "boss", an oracle NPC, a temporary gate, decoration -- and ONE command family underneath them, because every one of them ends in the same sentence: an object exists, and this run owns it. `world.spawn` / `world.despawn` / `world.owned` carry a `what` discriminator, and the per-verb differences are fields rather than kinds. The ownership registry is PERSISTED, and that is forced rather than chosen. A spawned creature is in the world save, so it survives the restart that proves a town-crier line gone -- which already rules out reconcile-by-boot-stamp. But the record of which run owns which serial has nowhere else to live: in memory it is lost in the restart the creatures survive, and only in the website's ledger it is not held here at all, so `world.despawn` would delete whatever serial it was handed and "never touches a creature it did not create" would have no mechanism behind it. So the Bridge gains its second persisted file beside `Participation.bin` -- written by the same world save as the objects it describes, so the two cannot get out of step. The oracle is ours rather than `XmlSpawner2.XmlDialog`'s, and that engine is the reason for both halves of the decision. Its `SpeechEntry` is the evidence the shape is right -- `Text` plus comma-separated `Keywords`, a keyword-less entry as the greeting, a proximity range, a conversation lock. It is also why not to build on it: `SpeechEntry` carries an `Action` string, XmlSpawner's command-scripting language, which would leave an arbitrary-command field one step from an event author. `Mobile.OnMovement` (delivered to every mobile in range -- the `HandlesOnMovement` filter applies only to Items) and `Mobile.HandlesOnSpeech`/`OnSpeech` are native virtuals and are all it needs. Every `Bridge.EventsMax*` REFUSES rather than clamps, on `LeaseMaxDurationSec`'s argument from 11b: the shard's bound exists for the case where the website is wrong. `Bridge.EventsEnabled` gates all of it -- spawning is the same consent 11b introduced that switch for, not a third one. Decoration carries an `itemId`, because `Static` accounts for 5031 of the tree's decoration placements under 1992 different graphics: for that class the graphic IS the identity. Never applied to a `BaseAddon`, whose own ItemID is not what a player sees. Containers are refused outright -- teardown would delete whatever a player had left inside. `tools/scaffolding` gains `worldgone `, which deletes an object behind the registry's back. It is the one outcome the rig cannot reach by asking the bridge -- every bridge verb that removes an object also drops its row -- and it is what a player's sword does every time they kill an event creature. Verified on a real ServUO 57.4 world (206k items, 42k mobiles) against the release sidecar: all five verbs place; every ceiling refuses; a container and an unknown type refuse; one run cannot despawn another's object; the registry and its objects both survive a save and a clean restart (`pruned: 0`); a creature deleted behind the registry's back comes back `gone` rather than `removed`; and a five-second gate is collected by the shard's own deadline with `world.expired` on the wire. Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- overlay.toml | 2 +- overlay/Config/Bridge.cfg | 43 + overlay/Scripts/Custom/Bridge/BridgeBoot.cs | 2 + overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 86 ++ overlay/Scripts/Custom/Bridge/BridgeJson.cs | 36 + overlay/Scripts/Custom/Bridge/BridgeOracle.cs | 293 +++++ overlay/Scripts/Custom/Bridge/BridgeWorld.cs | 1144 +++++++++++++++++ tools/scaffolding/BridgeRigDriver.cs | 46 + 8 files changed, 1651 insertions(+), 1 deletion(-) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeOracle.cs create mode 100644 overlay/Scripts/Custom/Bridge/BridgeWorld.cs diff --git a/overlay.toml b/overlay.toml index 87a0e59..7bc1fcc 100644 --- a/overlay.toml +++ b/overlay.toml @@ -24,7 +24,7 @@ # changes the emitters, exactly as link bumps PROTOCOL_VERSION. # # Current: 6 — see docs/link/v6.md (idempotency keys on inbound commands, champ.boss.killed). -protocol = 6 +protocol = 7 # ── ServUO compatibility ───────────────────────────────────────────────────── # diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index db5de9f..9b67429 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -239,6 +239,49 @@ ParticipationMaxRadius=300 ParticipationGraceSec=86400 ParticipationSnapshotChunk=100 +# ---- The world verbs (protocol 7) ---------------------------------------------------- +# What an event may PLACE in the world, all of it owned by the run that placed it and +# deleted when the run tears down. Every ceiling here REFUSES rather than clamps: this +# shard's bound exists for the case where the website is wrong, and a quiet clamp would +# leave the two halves disagreeing about what was actually placed. +# +# The defaults are the EM Program's published quotas, because they are the only numbers +# anyone has defended in public. + +# Per CALL: creatures, enhanced "boss" variants, oracle NPCs and decoration items. +EventsMaxCreatures=30 +EventsMaxBosses=4 +EventsMaxNpcs=5 +EventsMaxDecor=60 + +# The longest a temporary gate may stand. The shard closes it on its own when the time +# passes, whether or not the website is ever heard from again. +EventsMaxGateMinutes=240 + +# Per RUN, across every verb above. The per-call ceilings bound one request; this bounds +# a run that calls a verb in a loop, which is the shape a runaway schedule takes. +EventsMaxOwnedPerRun=200 + +# How far from the chosen spot things may be scattered. +EventsMaxSpread=40 + +# How much harder than normal a "boss" may be made. EVENTS.md calls it an enhanced +# regular mob, so this is low enough that the result is still the creature that was +# picked. +EventsMaxBossMultiplier=10.0 + +# The oracle NPC: how many keyword lines it answers to, how close a player must be to be +# greeted and to be heard, and how often it will speak to the same player. +EventsOracleMaxLines=5 +EventsOracleGreetRange=4 +EventsOracleSpeechRange=8 +EventsOracleGreetCooldownSec=60 +EventsOracleAnswerCooldownSec=5 + +# How often expired gates are collected and rows for objects the world has already lost +# are pruned. +EventsSweepSeconds=30 + # The test scaffolding in tools/scaffolding/ reads its own flags from this file # (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose: # Config.Get returns the default of false when a key is missing, so a deployed diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs index 9a3a66b..08c800e 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs @@ -204,6 +204,7 @@ namespace Server.Custom.Bridge BridgeMarket.Rearm(); BridgeParticipation.Rearm(); BridgeLeases.Rearm(); + BridgeWorld.Rearm(); // Not a sweep, so it has nothing to re-arm — but an operator who just edited a // .cfg wants the change on the site now, not after a shard restart. BridgeRuleset.Emit(); @@ -257,6 +258,7 @@ namespace Server.Custom.Bridge e.Mobile.SendMessage("Bridge: {0}", BridgeIdempotency.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeLeases.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgeWorld.Status()); break; } } diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index 41f6466..70e26f6 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -98,6 +98,26 @@ namespace Server.Custom.Bridge public static int ParticipationGraceSec { get; private set; } public static int ParticipationSnapshotChunk { get; private set; } + // The world verbs (protocol 7, EVENTS_PLAN.md Phase 12a). Each of these is the shard's + // OWN ceiling rather than a mirror of the module's budget dimension, and each REFUSES + // rather than clamps -- BridgeLeases' argument for LeaseMaxDurationSec, unchanged: the + // bound exists for the case where the website is wrong, and a quiet clamp would leave the + // two halves disagreeing about what was actually placed. + public static int EventsMaxCreatures { get; private set; } + public static int EventsMaxBosses { get; private set; } + public static int EventsMaxNpcs { get; private set; } + public static int EventsMaxDecor { get; private set; } + public static int EventsMaxGateMinutes { get; private set; } + public static int EventsMaxOwnedPerRun { get; private set; } + public static int EventsMaxSpread { get; private set; } + public static double EventsMaxBossMultiplier { get; private set; } + public static int EventsOracleMaxLines { get; private set; } + public static int EventsOracleGreetRange { get; private set; } + public static int EventsOracleSpeechRange { get; private set; } + public static int EventsOracleGreetCooldownSec { get; private set; } + public static int EventsOracleAnswerCooldownSec { get; private set; } + public static int EventsSweepSeconds { get; private set; } + // ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ---- public static SignupMode Signup { get; private set; } public static bool AccountCreateEnabled { get; private set; } @@ -310,6 +330,72 @@ namespace Server.Custom.Bridge if (ParticipationSnapshotChunk < 1) ParticipationSnapshotChunk = 1; + // The world verbs. PEC's published quotas are the defaults, because they are the only + // numbers anyone has ever defended in public: 30 creatures, a handful of bosses, five + // NPCs of five lines each, a four-hour gate. See EVENTS.md's PEC section. + EventsMaxCreatures = Config.Get("Bridge.EventsMaxCreatures", 30); + if (EventsMaxCreatures < 1) + EventsMaxCreatures = 1; + + EventsMaxBosses = Config.Get("Bridge.EventsMaxBosses", 4); + if (EventsMaxBosses < 1) + EventsMaxBosses = 1; + + EventsMaxNpcs = Config.Get("Bridge.EventsMaxNpcs", 5); + if (EventsMaxNpcs < 1) + EventsMaxNpcs = 1; + + EventsMaxDecor = Config.Get("Bridge.EventsMaxDecor", 60); + if (EventsMaxDecor < 1) + EventsMaxDecor = 1; + + EventsMaxGateMinutes = Config.Get("Bridge.EventsMaxGateMinutes", 240); + if (EventsMaxGateMinutes < 1) + EventsMaxGateMinutes = 1; + + // The whole run, across every verb. The per-verb ceilings above bound one CALL; this + // bounds a run that calls a verb in a loop, which is the shape a runaway schedule + // actually takes. + EventsMaxOwnedPerRun = Config.Get("Bridge.EventsMaxOwnedPerRun", 200); + if (EventsMaxOwnedPerRun < 1) + EventsMaxOwnedPerRun = 1; + + EventsMaxSpread = Config.Get("Bridge.EventsMaxSpread", 40); + if (EventsMaxSpread < 0) + EventsMaxSpread = 0; + + // "An enhanced regular mob", per EVENTS.md's boss row -- so a ceiling low enough that + // the result is still recognisably the creature the author picked. + EventsMaxBossMultiplier = Config.Get("Bridge.EventsMaxBossMultiplier", 10.0); + if (EventsMaxBossMultiplier < 1.0) + EventsMaxBossMultiplier = 1.0; + + EventsOracleMaxLines = Config.Get("Bridge.EventsOracleMaxLines", 5); + if (EventsOracleMaxLines < 1) + EventsOracleMaxLines = 1; + + EventsOracleGreetRange = Config.Get("Bridge.EventsOracleGreetRange", 4); + if (EventsOracleGreetRange < 1) + EventsOracleGreetRange = 1; + + EventsOracleSpeechRange = Config.Get("Bridge.EventsOracleSpeechRange", 8); + if (EventsOracleSpeechRange < 1) + EventsOracleSpeechRange = 1; + + EventsOracleGreetCooldownSec = Config.Get("Bridge.EventsOracleGreetCooldownSec", 60); + if (EventsOracleGreetCooldownSec < 0) + EventsOracleGreetCooldownSec = 0; + + EventsOracleAnswerCooldownSec = Config.Get("Bridge.EventsOracleAnswerCooldownSec", 5); + if (EventsOracleAnswerCooldownSec < 0) + EventsOracleAnswerCooldownSec = 0; + + // How often expired gates are collected and dead ownership rows pruned. Gates are a + // minutes-scale deadline, so one slow sweep beats a timer per object. + EventsSweepSeconds = Config.Get("Bridge.EventsSweepSeconds", 30); + if (EventsSweepSeconds < 1) + EventsSweepSeconds = 1; + // Account provisioning. An absent SignupMode defaults to Hybrid; a *present but // unrecognized* value falls back to Game (the safest — no website creation), so a // typo can never accidentally open provisioning. diff --git a/overlay/Scripts/Custom/Bridge/BridgeJson.cs b/overlay/Scripts/Custom/Bridge/BridgeJson.cs index ccf10d4..af2a9fa 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeJson.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeJson.cs @@ -484,6 +484,42 @@ namespace Server.Custom.Bridge return result; } + /// + /// Extracts a JSON array of OBJECTS, as a list of dictionaries. + /// + /// `JavaScriptSerializer` already materializes a nested object as another + /// `Dictionary<string, object>` when the target is `object`, so this needs no + /// parser work -- only the same defensive walk `GetStringList` does. Anything in the + /// array that is not an object is skipped rather than failing the whole field: a + /// malformed row in an oracle's dialogue should cost that row, not the NPC. + /// + /// Returns an empty list for a missing or non-array value, never null. + /// + public static List> GetObjectList( + Dictionary o, string key) + { + var result = new List>(); + + object v; + if (o == null || !o.TryGetValue(key, out v) || v == null) + return result; + + var enumerable = v as System.Collections.IEnumerable; + + if (enumerable == null || v is string) + return result; + + foreach (var item in enumerable) + { + var row = item as Dictionary; + + if (row != null) + result.Add(row); + } + + return result; + } + public static int GetInt(Dictionary o, string key, int fallback) { object v; diff --git a/overlay/Scripts/Custom/Bridge/BridgeOracle.cs b/overlay/Scripts/Custom/Bridge/BridgeOracle.cs new file mode 100644 index 0000000..869c22f --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeOracle.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +using Server.Items; +using Server.Mobiles; +using Server.Network; + +namespace Server.Custom.Bridge +{ + /// + /// An event-owned NPC that answers questions. Protocol 7, EVENTS_PLAN.md Phase 12a. + /// + /// EVENTS.md §G rates this the capability most worth having and gives the reason in one + /// line: *"this is literally a web form"*. An oracle is a greeting and a handful of + /// "when a player says X, reply Y" rows, and that is a form an event author can fill in + /// without knowing anything about Ultima Online — which is more than can be said for + /// choosing a spawn point. + /// + /// ── Why this is ours and not `XmlDialog` ─────────────────────────────────────────────── + /// + /// ServUO already ships a complete dialogue engine in `XmlSpawner2.XmlDialog`, and its + /// `SpeechEntry` is the evidence that the shape below is right rather than invented: `Text` + /// plus a comma-separated `Keywords` list, an entry with no keywords being the one that + /// fires automatically, a proximity range (`defProximityRange = 3`), a conversation lock so + /// two players cannot talk over each other. + /// + /// It is also exactly why this verb must not be built on it. `SpeechEntry` carries an + /// `Action` string — XmlSpawner's command-scripting language — and routing authored + /// dialogue through XmlDialog would leave an arbitrary-command field one field away from + /// an event author on the website. That is the `[set` that §G excludes, arriving through + /// the back door, in a subsystem this overlay does not own and an operator can switch off. + /// + /// What the verb actually needs are two native virtuals on `Server.Mobile`: + /// `OnMovement`, which is delivered to **every** mobile in range (the `HandlesOnMovement` + /// filter applies only to Items — `Server/Mobile.cs:3369` against `:3375`), and + /// `HandlesOnSpeech`/`OnSpeech`. Nothing on the wire is executable: keywords and text. + /// + /// ── It cannot be killed, moved or looted ─────────────────────────────────────────────── + /// + /// `CanBeDamaged()` is false, as `TownCrier`'s is, and it is `Blessed`, `Frozen` and + /// `CantWalk`. An event NPC that a player can drag out of the venue or kill for its robe is + /// an event NPC that stops being where the run's ledger says it is, and teardown deleting + /// something that has wandered two screens away is a worse outcome than it not moving. + /// + public class BridgeOracle : Mobile + { + /// One row of the form: what a player has to say, and what it answers. + public sealed class Line + { + /// Lower-cased, already trimmed. Matched as substrings of what was said. + public string[] Keywords; + + public string Text; + } + + private string m_Greeting; + private List m_Lines; + + /// + /// When each player was last spoken to, so an oracle cannot be farmed for spam. + /// + /// Deliberately not serialized. It is a rate limiter, not state anybody is owed across + /// a restart, and a restart is exactly the moment a fresh greeting is *correct* — the + /// player is arriving at the venue again as far as the world is concerned. + /// + private readonly Dictionary m_Greeted = new Dictionary(); + + private readonly Dictionary m_Answered = new Dictionary(); + + [CommandProperty(AccessLevel.GameMaster, true)] + public string Greeting { get { return m_Greeting; } set { m_Greeting = value; } } + + public List Lines + { + get { return m_Lines ?? (m_Lines = new List()); } + set { m_Lines = value; } + } + + public BridgeOracle() + : this(null, null, 0, false) + { + } + + public BridgeOracle(string name, string title, int hue, bool female) + { + Name = String.IsNullOrEmpty(name) ? "the oracle" : name; + Title = title; + Female = female; + Body = female ? 0x191 : 0x190; + Hue = hue > 0 ? hue : Utility.RandomSkinHue(); + + InitStats(100, 100, 25); + + AddItem(new Robe(Utility.RandomNeutralHue())); + AddItem(new Sandals()); + + // See the class header. A run's ledger records where this NPC is; letting the world + // move it would make that record a lie within a minute of a curious player arriving. + Blessed = true; + Frozen = true; + CantWalk = true; + } + + public BridgeOracle(Serial serial) + : base(serial) + { + } + + public override bool CanBeDamaged() + { + return false; + } + + public override bool ClickTitle { get { return false; } } + + // ---- the greeting ---- + + /// + /// Greet a player who has just come into range. + /// + /// "Just come into range" rather than "is in range": `OnMovement` fires on every step, + /// so greeting on proximity alone would have the oracle shouting at anyone who walked + /// past it. The old location is compared as well as the new one, which makes this fire + /// once per approach, and the per-player cooldown catches the player who paces the line. + /// + public override void OnMovement(Mobile m, Point3D oldLocation) + { + base.OnMovement(m, oldLocation); + + if (String.IsNullOrEmpty(m_Greeting) || m == this || m.Deleted || Deleted) + return; + + if (!(m is PlayerMobile) || !m.Alive || m.Map != Map) + return; + + var range = BridgeConfig.EventsOracleGreetRange; + + if (!InRange(m, range) || InRange(oldLocation, range)) + return; + + if (!Recent(m_Greeted, m, BridgeConfig.EventsOracleGreetCooldownSec)) + return; + + m_Greeted[m] = DateTime.UtcNow; + Direction = GetDirectionTo(m); + Say(m_Greeting); + } + + // ---- the keyword lines ---- + + public override bool HandlesOnSpeech(Mobile from) + { + return m_Lines != null && m_Lines.Count > 0 && from.Alive && from is PlayerMobile && + InRange(from, BridgeConfig.EventsOracleSpeechRange); + } + + public override void OnSpeech(SpeechEventArgs e) + { + base.OnSpeech(e); + + if (e.Handled || m_Lines == null || m_Lines.Count == 0 || Deleted) + return; + + var from = e.Mobile; + + if (from == null || !from.Alive || !(from is PlayerMobile) || + !InRange(from, BridgeConfig.EventsOracleSpeechRange)) + return; + + var said = (e.Speech ?? "").ToLower(CultureInfo.InvariantCulture); + + if (said.Length == 0) + return; + + var line = Match(said); + + if (line == null) + return; + + // The cooldown is consulted only once something actually matched. Checking it first + // would let a player burn their own cooldown on an unrelated sentence and then find + // the oracle mute when they finally said the word. + if (!Recent(m_Answered, from, BridgeConfig.EventsOracleAnswerCooldownSec)) + return; + + m_Answered[from] = DateTime.UtcNow; + Direction = GetDirectionTo(from); + Say(line.Text); + + e.Handled = true; + } + + private Line Match(string said) + { + for (int i = 0; i < m_Lines.Count; i++) + { + var line = m_Lines[i]; + + if (line == null || line.Keywords == null || String.IsNullOrEmpty(line.Text)) + continue; + + for (int j = 0; j < line.Keywords.Length; j++) + { + var keyword = line.Keywords[j]; + + if (!String.IsNullOrEmpty(keyword) && said.IndexOf(keyword, StringComparison.Ordinal) >= 0) + return line; + } + } + + return null; + } + + /// + /// Whether enough time has passed to speak to this player again, pruning as it goes. + /// + /// The prune matters: without it a busy venue leaves one dictionary entry per player + /// who ever walked past, held by a strong reference to a `Mobile` that may since have + /// been deleted, for as long as the NPC exists. + /// + private static bool Recent(Dictionary seen, Mobile m, int cooldownSec) + { + var now = DateTime.UtcNow; + var cooldown = TimeSpan.FromSeconds(cooldownSec); + + if (seen.Count > 64) + { + List stale = null; + + foreach (var pair in seen) + { + if (pair.Key == null || pair.Key.Deleted || now - pair.Value > cooldown) + (stale ?? (stale = new List())).Add(pair.Key); + } + + if (stale != null) + { + for (int i = 0; i < stale.Count; i++) + seen.Remove(stale[i]); + } + } + + DateTime last; + + return !seen.TryGetValue(m, out last) || now - last >= cooldown; + } + + // ---- persistence ---- + + public override void Serialize(GenericWriter writer) + { + base.Serialize(writer); + + writer.Write(0); // version + + writer.Write(m_Greeting ?? ""); + + var lines = m_Lines ?? new List(); + writer.Write(lines.Count); + + foreach (var line in lines) + { + writer.Write(String.Join(",", line.Keywords ?? new string[0])); + writer.Write(line.Text ?? ""); + } + } + + public override void Deserialize(GenericReader reader) + { + base.Deserialize(reader); + + reader.ReadInt(); // version + + m_Greeting = reader.ReadString(); + + var count = reader.ReadInt(); + m_Lines = new List(count); + + for (int i = 0; i < count; i++) + { + var keywords = reader.ReadString() ?? ""; + var text = reader.ReadString(); + + m_Lines.Add(new Line + { + Keywords = keywords.Length == 0 ? new string[0] : keywords.Split(','), + Text = text, + }); + } + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeWorld.cs b/overlay/Scripts/Custom/Bridge/BridgeWorld.cs new file mode 100644 index 0000000..3914a40 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeWorld.cs @@ -0,0 +1,1144 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +using Server.Items; +using Server.Mobiles; + +namespace Server.Custom.Bridge +{ + /// + /// Protocol 7. What an event OWNS: the five world verbs, the ownership registry that makes + /// them safe to undo, and the sweep that collects what has run out of time. + /// + /// EVENTS_PLAN.md Phase 12a. Five verbs an author sees — creatures, a boss, an oracle, a + /// gate, decoration — and **one** command family on the wire, because every one of them + /// ends in the same sentence: an object exists, and this run owns it. The differences (a + /// boss's multipliers, an oracle's lines, a gate's destination and deadline) are fields. + /// One ledger shape, one teardown path, one reconcile, rather than five near-identical + /// ones in three repos. + /// + /// ── Owning is the whole safety argument ──────────────────────────────────────────────── + /// + /// EVENTS.md §G excludes arbitrary `[add` and `[set` and then permits all of this, and the + /// distinction it draws is ownership: *an event may own what it creates and borrow what it + /// changes, and may never change something with no baseline recorded.* Everything in this + /// file is the first half. Nothing here touches an object it did not make — which is a + /// promise this file has to be able to KEEP, not merely intend, and that is what the + /// registry below is for. + /// + /// ── Why the registry is persisted, which is forced rather than chosen ────────────────── + /// + /// A spawned creature is a world object, so unlike a town-crier line it *survives* a + /// restart. That already rules out `reconcileByBootId` on the website's side: the boot + /// stamp would report gone the one class of resource that is still standing there. + /// + /// But the record of *which run owns which serial* has nowhere else to live: + /// + /// - In memory, it is lost in the restart the creatures themselves survive. The website + /// then holds serials the shard will not vouch for, and thirty orcs stand in Britain + /// until somebody deletes them by hand. + /// - Only in the website's ledger, it is not held on the shard at all — so `world.despawn` + /// would have to delete whatever serial it was handed, and "never touches a creature it + /// did not create" would be a sentence with no mechanism behind it. A bug in a step, or a + /// stolen sidecar token, would be a delete-anything primitive. + /// + /// So the Bridge gains its second persisted file, beside `Participation.bin`. Same + /// mechanism, same reasoning, and the same rule about attaching the hooks: `Configure()`, + /// not `Initialize()`, because `EventSink.WorldLoad` fires inside `World.Load()`. + /// + /// ── "Gone" is an ordinary outcome, not a failure ─────────────────────────────────────── + /// + /// Players kill event creatures. That is the point of spawning them. So a despawn that + /// finds its target already absent reports `gone` and counts as success, and the row leaves + /// the registry either way — §L's "gone, and that is fine". The failure this file reports + /// is a different one: a serial this run does not own, which comes back `refused` and is + /// the only answer here that means somebody asked for something they should not have. + /// + public static class BridgeWorld + { + private static readonly string SavePath = Path.Combine("Saves", "Bridge", "Owned.bin"); + + private const int SaveVersion = 1; + + /// The five things a run can own. Kept as strings: they cross the wire as text. + private const string WhatCreature = "creature"; + private const string WhatBoss = "boss"; + private const string WhatNpc = "npc"; + private const string WhatGate = "gate"; + private const string WhatDecor = "decor"; + + /// One object a run owns. + private sealed class Owned + { + public int Serial; + public string RunId; + public string What; + + /// + /// The type name as placed, kept for the console and for the reply. + /// + /// Read from the registry rather than from the object, so a row whose object has + /// since been deleted can still say what it was. "Something this run owned is + /// gone" is a less useful line in an audit than "the orc this run spawned is gone". + /// + public string Type; + + public long CreatedMs; + + /// When the shard collects this by itself. 0 for everything but a gate. + public long ExpiresMs; + } + + private static readonly Dictionary _owned = new Dictionary(); + + /// runId to the serials it owns. Rebuilt from `_owned`, never persisted separately. + private static readonly Dictionary> _byRun = + new Dictionary>(StringComparer.Ordinal); + + private static Timer _sweep; + + private static long _placed, _removed, _expired, _refused, _pruned; + + /// + /// Attaches persistence. Runs before `World.Load()`, which is when `EventSink.WorldLoad` + /// fires, so this cannot be deferred to Initialize — the same rule + /// `BridgeParticipation.Configure` follows and for the same reason. + /// + /// Attached unconditionally, ahead of the enabled gate: an operator who switches the + /// event plane off for an afternoon must not come back to a truncated registry and a + /// world full of creatures nothing admits to owning. + /// + [CallPriority(900)] + public static void Configure() + { + EventSink.WorldSave += OnWorldSave; + EventSink.WorldLoad += OnWorldLoad; + } + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("world.spawn", OnSpawn); + BridgeBoot.RegisterHandler("world.despawn", OnDespawn); + BridgeBoot.RegisterHandler("world.owned", OnOwned); + + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + Rearm(); + } + + /// Stops and recreates the sweep from current config. Called by `[bridge reload`. + public static void Rearm() + { + if (_sweep != null) + { + _sweep.Stop(); + _sweep = null; + } + + if (!BridgeConfig.Enabled) + return; + + var period = TimeSpan.FromSeconds(BridgeConfig.EventsSweepSeconds); + _sweep = Timer.DelayCall(period, period, Sweep); + } + + public static string Status() + { + return String.Format( + CultureInfo.InvariantCulture, + "owned={0} runs={1} placed={2} removed={3} expired={4} refused={5} pruned={6}", + _owned.Count, _byRun.Count, _placed, _removed, _expired, _refused, _pruned); + } + + // ── world.spawn ──────────────────────────────────────────────────────────────────────── + + private static void OnSpawn(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "spawn")) + return; + + var runId = BridgeJson.GetString(o, "runId"); + + if (String.IsNullOrEmpty(runId)) + { + Err(reqId, "spawn", "a run id is required"); + return; + } + + var what = (BridgeJson.GetString(o, "what") ?? "").Trim().ToLowerInvariant(); + + if (what != WhatCreature && what != WhatBoss && what != WhatNpc && + what != WhatGate && what != WhatDecor) + { + Err(reqId, "spawn", "unknown kind of thing to place: '" + what + "'"); + return; + } + + var mapName = BridgeJson.GetString(o, "map"); + var map = MapByName(mapName); + + if (map == null || map == Map.Internal) + { + Err(reqId, "spawn", "unknown map '" + (mapName ?? "") + "'"); + return; + } + + var x = BridgeJson.GetInt(o, "x", -1); + var y = BridgeJson.GetInt(o, "y", -1); + + if (x < 0 || y < 0) + { + Err(reqId, "spawn", "a place needs an x and a y"); + return; + } + + var z = BridgeJson.GetInt(o, "z", map.GetAverageZ(x, y)); + var anchor = new Point3D(x, y, z); + + var count = BridgeJson.GetInt(o, "count", 1); + var ceiling = CeilingFor(what); + + if (count < 1 || count > ceiling) + { + Refuse(reqId, "spawn", + String.Format(CultureInfo.InvariantCulture, + "this shard places 1 to {0} of '{1}' at a time, and {2} was asked for", + ceiling, what, count)); + return; + } + + // The per-verb ceiling above bounds one CALL. This bounds a run that calls a verb in a + // loop, which is the shape a runaway schedule actually takes. + var already = OwnedCount(runId); + + if (already + count > BridgeConfig.EventsMaxOwnedPerRun) + { + Refuse(reqId, "spawn", + String.Format(CultureInfo.InvariantCulture, + "run {0} already owns {1} things and this shard allows {2} per run", + runId, already, BridgeConfig.EventsMaxOwnedPerRun)); + return; + } + + var spread = BridgeJson.GetInt(o, "spread", 0); + + if (spread < 0 || spread > BridgeConfig.EventsMaxSpread) + { + Refuse(reqId, "spawn", + String.Format(CultureInfo.InvariantCulture, + "things are scattered up to {0} tiles, and {1} was asked for", + BridgeConfig.EventsMaxSpread, spread)); + return; + } + + string error; + var serials = new List(count); + var typeName = BridgeJson.GetString(o, "type"); + + switch (what) + { + case WhatGate: + error = PlaceGate(o, runId, map, anchor, serials); + break; + + case WhatNpc: + error = PlaceNpcs(o, runId, map, anchor, spread, count, serials); + break; + + case WhatDecor: + error = PlaceDecor(o, runId, typeName, map, anchor, spread, count, serials); + break; + + default: + error = PlaceCreatures(o, runId, what, typeName, map, anchor, spread, count, serials); + break; + } + + if (error != null) + { + // Whatever DID get placed before the failure is already registered, so the run owns + // it and teardown will collect it. Rolling back here would be a second delete path + // that only ever runs on the unhappy branch, which is the path least likely to be + // right; leaving the rows is the same answer §L gives everywhere else. + Refuse(reqId, "spawn", error); + return; + } + + _placed += serials.Count; + + var sb = BridgeJson.Begin("world.ok"); + if (reqId != null) sb.Str("reqId", reqId); + + sb.Str("action", "spawn") + .Str("runId", runId) + .Str("what", what) + .Num("placed", serials.Count); + + AppendSerials(sb, "serials", serials); + sb.Num("owned", OwnedCount(runId)); + + BridgeLink.Emit(sb.End()); + } + + private static int CeilingFor(string what) + { + switch (what) + { + case WhatBoss: return BridgeConfig.EventsMaxBosses; + case WhatNpc: return BridgeConfig.EventsMaxNpcs; + case WhatDecor: return BridgeConfig.EventsMaxDecor; + case WhatGate: return 1; + default: return BridgeConfig.EventsMaxCreatures; + } + } + + // ── the five placements ──────────────────────────────────────────────────────────────── + + /// + /// Creatures and bosses, which differ only in what is done to the thing after it is + /// constructed. A boss is EVENTS.md §G's "enhanced regular mob": the same allowlisted + /// type, made harder, and named. Not a new species and not a stock boss touched in + /// place — an event-owned template it created and will delete. + /// + private static string PlaceCreatures( + Dictionary o, string runId, string what, string typeName, + Map map, Point3D anchor, int spread, int count, List serials) + { + var type = ResolveType(typeName); + + if (type == null) + return "this shard has no type called '" + (typeName ?? "") + "'"; + + if (!typeof(BaseCreature).IsAssignableFrom(type)) + return "'" + typeName + "' is not a creature"; + + double hitsMul = 1.0, damageMul = 1.0, statMul = 1.0; + + if (what == WhatBoss) + { + hitsMul = BridgeJson.GetDouble(o, "hitsMultiplier", 1.0); + damageMul = BridgeJson.GetDouble(o, "damageMultiplier", 1.0); + statMul = BridgeJson.GetDouble(o, "statMultiplier", 1.0); + + var max = BridgeConfig.EventsMaxBossMultiplier; + + if (!Sane(hitsMul, max) || !Sane(damageMul, max) || !Sane(statMul, max)) + return String.Format(CultureInfo.InvariantCulture, + "a boss is made 1 to {0} times a normal one, and that is outside it", max); + } + + var name = BridgeJson.GetString(o, "name"); + var hue = BridgeJson.GetInt(o, "hue", 0); + + for (int i = 0; i < count; i++) + { + BaseCreature creature; + + try + { + creature = Activator.CreateInstance(type) as BaseCreature; + } + catch (Exception e) + { + // A constructible-looking type whose parameterless constructor throws is an + // ordinary fact about a shard's own scripts, not a bug here. Name it. + return "'" + typeName + "' could not be created: " + e.GetBaseException().Message; + } + + if (creature == null) + return "'" + typeName + "' could not be created"; + + if (!String.IsNullOrEmpty(name)) + creature.Name = name; + + if (hue > 0) + creature.Hue = hue; + + if (what == WhatBoss) + Enhance(creature, hitsMul, damageMul, statMul); + + creature.MoveToWorld(Scatter(map, anchor, spread), map); + Register(runId, what, creature.Serial.Value, type.Name, 0L); + serials.Add(creature.Serial.Value); + } + + return null; + } + + /// + /// Multiply a creature's stats without rebuilding it. + /// + /// `HitsMaxSeed` rather than `HitsMax`, because `HitsMax` is computed from `Str` on a + /// mobile with no seed set and assigning it would not stick. Setting `Hits` afterwards + /// is what makes the creature arrive at full health rather than at its old maximum. + /// + private static void Enhance(BaseCreature creature, double hits, double damage, double stat) + { + if (hits > 1.0) + { + var seed = creature.HitsMaxSeed > 0 ? creature.HitsMaxSeed : creature.HitsMax; + creature.HitsMaxSeed = Scale(seed, hits); + } + + if (stat > 1.0) + { + creature.RawStr = Scale(creature.RawStr, stat); + creature.RawDex = Scale(creature.RawDex, stat); + creature.RawInt = Scale(creature.RawInt, stat); + } + + if (damage > 1.0) + creature.SetDamage(Scale(creature.DamageMin, damage), Scale(creature.DamageMax, damage)); + + creature.Hits = creature.HitsMax; + creature.Mana = creature.ManaMax; + creature.Stam = creature.StamMax; + } + + private static int Scale(int value, double by) + { + var scaled = value * by; + + // Short of overflowing a stat into a negative number, which is a creature that dies to + // a stiff breeze after an author typed one zero too many. + return scaled >= Int32.MaxValue ? Int32.MaxValue : (int)Math.Round(scaled); + } + + private static bool Sane(double value, double max) + { + return !Double.IsNaN(value) && !Double.IsInfinity(value) && value >= 1.0 && value <= max; + } + + /// An oracle: a greeting, and up to `EventsOracleMaxLines` keyword rows. + private static string PlaceNpcs( + Dictionary o, string runId, Map map, Point3D anchor, int spread, + int count, List serials) + { + var name = BridgeJson.GetString(o, "name"); + var title = BridgeJson.GetString(o, "title"); + var hue = BridgeJson.GetInt(o, "hue", 0); + var female = String.Equals(BridgeJson.GetString(o, "sex"), "female", + StringComparison.OrdinalIgnoreCase); + var greeting = BridgeJson.GetString(o, "greeting"); + + var rows = BridgeJson.GetObjectList(o, "lines"); + + if (rows.Count > BridgeConfig.EventsOracleMaxLines) + return String.Format(CultureInfo.InvariantCulture, + "an oracle answers to at most {0} things, and {1} were given", + BridgeConfig.EventsOracleMaxLines, rows.Count); + + var lines = new List(rows.Count); + + foreach (var row in rows) + { + var text = BridgeJson.GetString(row, "text"); + var keywords = Keywords(BridgeJson.GetString(row, "keywords")); + + if (String.IsNullOrEmpty(text) || keywords.Length == 0) + return "every line an oracle answers with needs both a keyword and something to say"; + + lines.Add(new BridgeOracle.Line { Keywords = keywords, Text = text }); + } + + if (String.IsNullOrEmpty(greeting) && lines.Count == 0) + return "an oracle with no greeting and no lines would stand there in silence"; + + for (int i = 0; i < count; i++) + { + var npc = new BridgeOracle(name, title, hue, female) + { + Greeting = greeting, + Lines = new List(lines), + }; + + npc.MoveToWorld(Scatter(map, anchor, spread), map); + Register(runId, WhatNpc, npc.Serial.Value, "BridgeOracle", 0L); + serials.Add(npc.Serial.Value); + } + + return null; + } + + /// + /// Lower-case, de-duplicated, empties dropped. Lower-cased HERE rather than at match + /// time so that the comparison in `BridgeOracle.Match` is an ordinal one on two strings + /// that are already in the same case — culture-aware casing at match time would make an + /// oracle answer differently on a Turkish shard. + /// + private static string[] Keywords(string raw) + { + if (String.IsNullOrEmpty(raw)) + return new string[0]; + + var parts = raw.Split(','); + var seen = new List(parts.Length); + + foreach (var part in parts) + { + var word = part.Trim().ToLower(CultureInfo.InvariantCulture); + + if (word.Length > 0 && !seen.Contains(word)) + seen.Add(word); + } + + return seen.ToArray(); + } + + /// + /// A temporary gate. One per call, and it is the only verb here that carries a deadline + /// the shard enforces on its own. + /// + /// The deadline is a duration (`holdMs`), never an absolute time, for the reason + /// `v6.md` §8.2 gives about leases: an absolute deadline is measured against two clocks, + /// and a shard ten minutes fast would collect a ten-minute gate the moment it opened. + /// + private static string PlaceGate( + Dictionary o, string runId, Map map, Point3D anchor, List serials) + { + object raw; + + if (!o.TryGetValue("target", out raw)) + return "a gate needs somewhere to go"; + + var target = raw as Dictionary; + + if (target == null) + return "a gate's destination must be a map, an x and a y"; + + var targetMapName = BridgeJson.GetString(target, "map"); + var targetMap = MapByName(targetMapName); + + if (targetMap == null || targetMap == Map.Internal) + return "unknown destination map '" + (targetMapName ?? "") + "'"; + + var tx = BridgeJson.GetInt(target, "x", -1); + var ty = BridgeJson.GetInt(target, "y", -1); + + if (tx < 0 || ty < 0) + return "a gate's destination needs an x and a y"; + + var tz = BridgeJson.GetInt(target, "z", targetMap.GetAverageZ(tx, ty)); + + var holdMs = BridgeJson.GetLong(o, "holdMs", 0L); + var maxMs = (long)BridgeConfig.EventsMaxGateMinutes * 60000L; + + if (holdMs < 1L || holdMs > maxMs) + return String.Format(CultureInfo.InvariantCulture, + "a gate stands for 1 millisecond to {0} minutes, and {1} was asked for", + BridgeConfig.EventsMaxGateMinutes, holdMs); + + var gate = new Moongate(new Point3D(tx, ty, tz), targetMap) + { + // The event owns it and the event's teardown removes it. A dispellable gate is one + // a passing mage can end early, which would take the venue's only way in out from + // under everyone standing at it. + Dispellable = false, + }; + + var name = BridgeJson.GetString(o, "name"); + + if (!String.IsNullOrEmpty(name)) + gate.Name = name; + + var hue = BridgeJson.GetInt(o, "hue", 0); + + if (hue > 0) + gate.Hue = hue; + + gate.MoveToWorld(anchor, map); + Register(runId, WhatGate, gate.Serial.Value, "Moongate", BridgeJson.NowMs() + holdMs); + serials.Add(gate.Serial.Value); + + return null; + } + + /// + /// Event decoration, from the shard's own decoration vocabulary. + /// + /// The website's dropdown is built from `Data/Decoration/**/*.cfg`, so what an + /// author can pick is what this shard already calls decoration. This end validates + /// independently anyway — it cannot trust the website, and the two ends read the tree at + /// different times. + /// + /// Placed immovable, which is what "lockdown" means here: decoration a player can pick + /// up is decoration that is not where the ledger says it is by the time teardown runs. + /// + private static string PlaceDecor( + Dictionary o, string runId, string typeName, Map map, Point3D anchor, + int spread, int count, List serials) + { + var type = ResolveType(typeName); + + if (type == null) + return "this shard has no type called '" + (typeName ?? "") + "'"; + + if (!typeof(Item).IsAssignableFrom(type)) + return "'" + typeName + "' is not an item"; + + // A container placed as decoration is a container players will put things in, and + // deleting it at teardown would delete what they put there. Everything else in this + // file is safe to delete because the event made it; a container's contents are not. + if (typeof(Container).IsAssignableFrom(type)) + return "'" + typeName + "' is a container, and teardown would delete whatever was left in it"; + + var hue = BridgeJson.GetInt(o, "hue", 0); + var name = BridgeJson.GetString(o, "name"); + + // **The graphic, for the classes whose identity IS the graphic.** + // + // Measured on 57.4: `Static` accounts for 5031 of the tree's decoration + // placements under 1992 different item ids, because a `Static` is not a thing, it is + // a picture. A bare `new Static()` is whatever the class defaults to and never what + // the author chose, so the website sends the id the shard's own decoration files use + // for that type and this applies it. + // + // **Never to an addon.** A `BaseAddon` is a group of components and its own `ItemID` + // is not what a player sees; writing a graphic over it would leave a stone oven that + // renders as one arbitrary tile of itself. Addons already construct with the right + // appearance, so there is nothing to fix and everything to break. + var itemId = BridgeJson.GetInt(o, "itemId", 0); + var isAddon = typeof(Server.Items.BaseAddon).IsAssignableFrom(type); + + for (int i = 0; i < count; i++) + { + Item item; + + try + { + item = Activator.CreateInstance(type) as Item; + } + catch (Exception e) + { + return "'" + typeName + "' could not be created: " + e.GetBaseException().Message; + } + + if (item == null) + return "'" + typeName + "' could not be created"; + + if (itemId > 0 && !isAddon) + item.ItemID = itemId; + + if (hue > 0) + item.Hue = hue; + + if (!String.IsNullOrEmpty(name)) + item.Name = name; + + item.Movable = false; + + item.MoveToWorld(Scatter(map, anchor, spread), map); + Register(runId, WhatDecor, item.Serial.Value, type.Name, 0L); + serials.Add(item.Serial.Value); + } + + return null; + } + + // ── world.despawn ────────────────────────────────────────────────────────────────────── + + /// + /// Give back what a run owns. With no `serials`, everything it owns — which is the call + /// teardown actually makes. + /// + /// Three answers, and the split is the point of the whole registry: + /// + /// - `removed`: owned by this run, found, deleted. + /// - `gone`: owned by this run and already absent. A player killed it, which is what + /// event creatures are for. Success, and the row leaves. + /// - `refused`: NOT owned by this run. The row stays, nothing is touched, and this is + /// the only answer here that means somebody asked for something they should not have. + /// + private static void OnDespawn(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "despawn")) + return; + + var runId = BridgeJson.GetString(o, "runId"); + + if (String.IsNullOrEmpty(runId)) + { + Err(reqId, "despawn", "a run id is required"); + return; + } + + var asked = BridgeJson.GetStringList(o, "serials"); + List wanted; + + if (asked.Count == 0) + { + wanted = new List(SerialsOf(runId)); + } + else + { + wanted = new List(asked.Count); + + foreach (var text in asked) + { + int serial; + + // An unparseable serial is not silently skipped: it becomes a refusal below, + // because a teardown that quietly ignored a malformed ref would report a clean + // sweep while leaving the thing standing. + wanted.Add(TryParseSerial(text, out serial) ? serial : -1); + } + } + + var removed = new List(); + var gone = new List(); + var refused = new List(); + + foreach (var serial in wanted) + { + Owned row; + + if (serial < 0 || !_owned.TryGetValue(serial, out row) || + !String.Equals(row.RunId, runId, StringComparison.Ordinal)) + { + refused.Add(serial); + _refused++; + continue; + } + + if (Collect(row)) + removed.Add(serial); + else + gone.Add(serial); + } + + _removed += removed.Count; + + var sb = BridgeJson.Begin("world.ok"); + if (reqId != null) sb.Str("reqId", reqId); + + sb.Str("action", "despawn").Str("runId", runId); + + AppendSerials(sb, "removed", removed); + AppendSerials(sb, "gone", gone); + AppendSerials(sb, "refused", refused); + + sb.Num("owned", OwnedCount(runId)); + + BridgeLink.Emit(sb.End()); + } + + // ── world.owned ──────────────────────────────────────────────────────────────────────── + + /// + /// What a run still owns, pruning as it walks. + /// + /// This is what the website's `reconcile()` asks, and the prune is what keeps the + /// registry from growing for the life of the save: a row whose object is gone is a row + /// about nothing. Anything not listed is gone, which is the answer core wants — it + /// takes rows OUT of its ledger only on an explicit reply, and this is that reply. + /// + private static void OnOwned(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "owned")) + return; + + var runId = BridgeJson.GetString(o, "runId"); + + if (String.IsNullOrEmpty(runId)) + { + Err(reqId, "owned", "a run id is required"); + return; + } + + var live = new List(); + var pruned = 0; + + foreach (var serial in new List(SerialsOf(runId))) + { + Owned row; + + if (!_owned.TryGetValue(serial, out row)) + continue; + + if (Find(serial) == null) + { + Forget(row); + pruned++; + continue; + } + + live.Add(row); + } + + _pruned += pruned; + + var sb = BridgeJson.Begin("world.owned.ok"); + if (reqId != null) sb.Str("reqId", reqId); + + sb.Str("runId", runId); + sb.Append(",\"owned\":["); + + for (int i = 0; i < live.Count; i++) + { + var row = live[i]; + + if (i > 0) + sb.Append(','); + + sb.Append("{\"serial\":").Append(SerialText(row.Serial)); + sb.Append(",\"what\":"); + BridgeJson.Text(sb, row.What); + sb.Append(",\"type\":"); + BridgeJson.Text(sb, row.Type); + sb.Append(",\"createdMs\":").Append(row.CreatedMs); + sb.Append(",\"untilMs\":").Append(row.ExpiresMs); + sb.Append('}'); + } + + sb.Append(']'); + sb.Num("pruned", pruned); + + BridgeLink.Emit(sb.End()); + } + + // ── the sweep ────────────────────────────────────────────────────────────────────────── + + /// + /// Collect what has run out of time, and prune what the world has already lost. + /// + /// The deadline lives here rather than on the website for `v6.md` §8.3's reason: a gate + /// the shard closes by itself closes whether or not the website is ever heard from + /// again. A run whose engine died leaves a world that comes back early, not one stuck + /// open indefinitely. + /// + private static void Sweep() + { + if (_owned.Count == 0) + return; + + var now = BridgeJson.NowMs(); + List due = null; + + foreach (var row in _owned.Values) + { + if (row.ExpiresMs > 0L && row.ExpiresMs <= now) + (due ?? (due = new List())).Add(row); + else if (Find(row.Serial) == null) + (due ?? (due = new List())).Add(row); + } + + if (due == null) + return; + + foreach (var row in due) + { + var existed = Collect(row); + + if (existed && row.ExpiresMs > 0L) + { + _expired++; + + // Told, not asked. The website learns its gate closed without polling for it, + // exactly as `lease.expired` reports a lease the shard restored on its own. + var sb = BridgeJson.Begin("world.expired"); + sb.Str("runId", row.RunId) + .Str("what", row.What) + .Str("type", row.Type); + sb.Append(",\"serial\":").Append(SerialText(row.Serial)); + BridgeLink.Emit(sb.End()); + } + else if (!existed) + { + _pruned++; + } + } + } + + // ── the registry ─────────────────────────────────────────────────────────────────────── + + private static void Register(string runId, string what, int serial, string type, long expiresMs) + { + var row = new Owned + { + Serial = serial, + RunId = runId, + What = what, + Type = type, + CreatedMs = BridgeJson.NowMs(), + ExpiresMs = expiresMs, + }; + + _owned[serial] = row; + Index(row); + } + + private static void Index(Owned row) + { + HashSet set; + + if (!_byRun.TryGetValue(row.RunId, out set)) + { + set = new HashSet(); + _byRun[row.RunId] = set; + } + + set.Add(row.Serial); + } + + private static void Forget(Owned row) + { + _owned.Remove(row.Serial); + + HashSet set; + + if (_byRun.TryGetValue(row.RunId, out set)) + { + set.Remove(row.Serial); + + if (set.Count == 0) + _byRun.Remove(row.RunId); + } + } + + /// Delete the object if it is still there, and drop the row either way. + private static bool Collect(Owned row) + { + var entity = Find(row.Serial); + + Forget(row); + + if (entity == null) + return false; + + entity.Delete(); + return true; + } + + private static IEntity Find(int serial) + { + var entity = World.FindEntity((Serial)serial); + + // A deleted entity can still be handed back for a tick. Treating it as present would + // make teardown call Delete() on it and report `removed` for something already gone. + return entity == null || entity.Deleted ? null : entity; + } + + private static IEnumerable SerialsOf(string runId) + { + HashSet set; + return _byRun.TryGetValue(runId, out set) ? (IEnumerable)set : new int[0]; + } + + private static int OwnedCount(string runId) + { + HashSet set; + return _byRun.TryGetValue(runId, out set) ? set.Count : 0; + } + + // ── helpers ──────────────────────────────────────────────────────────────────────────── + + /// + /// A point within `spread` tiles of the anchor that something can stand on. + /// + /// Falls back to the anchor rather than refusing: an author who picked a spot in a + /// crowded room should get their creatures, stacked, rather than an error about tile + /// heights they cannot act on from a web form. + /// + private static Point3D Scatter(Map map, Point3D anchor, int spread) + { + if (spread <= 0) + return anchor; + + for (int i = 0; i < 12; i++) + { + var x = anchor.X + Utility.RandomMinMax(-spread, spread); + var y = anchor.Y + Utility.RandomMinMax(-spread, spread); + var z = map.GetAverageZ(x, y); + + if (map.CanFit(x, y, z, 16, false, false)) + return new Point3D(x, y, z); + } + + return anchor; + } + + /// + /// Resolve a type name the website sent. + /// + /// Case-insensitive, because the atlas and the decoration files disagree about casing + /// for the same type often enough that a case-sensitive lookup would reject types the + /// shard demonstrably has. The caller still checks what the type IS — this only says + /// the shard knows the name. + /// + private static Type ResolveType(string name) + { + if (String.IsNullOrEmpty(name)) + return null; + + var trimmed = name.Trim(); + + return trimmed.Length == 0 ? null : ScriptCompiler.FindTypeByName(trimmed, true); + } + + private static string SerialText(int serial) + { + return "\"0x" + ((uint)serial).ToString("X") + "\""; + } + + private static bool TryParseSerial(string text, out int serial) + { + serial = 0; + + if (String.IsNullOrEmpty(text)) + return false; + + var trimmed = text.Trim(); + uint parsed; + + if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + if (!UInt32.TryParse(trimmed.Substring(2), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out parsed)) + return false; + } + else if (!UInt32.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed)) + { + return false; + } + + serial = unchecked((int)parsed); + return true; + } + + private static void AppendSerials(StringBuilder sb, string name, List serials) + { + sb.Append(",\"").Append(name).Append("\":["); + + for (int i = 0; i < serials.Count; i++) + { + if (i > 0) + sb.Append(','); + + sb.Append(SerialText(serials[i])); + } + + sb.Append(']'); + } + + private static Map MapByName(string name) + { + if (String.IsNullOrEmpty(name)) + return null; + + for (int i = 0; i < Map.Maps.Length; i++) + { + var map = Map.Maps[i]; + + if (map != null && String.Equals(map.Name, name, StringComparison.OrdinalIgnoreCase)) + return map; + } + + return null; + } + + private static bool Ready(string reqId, string action) + { + if (!BridgeConfig.EventsEnabled) + { + Err(reqId, action, "the event plane is disabled on this shard (Bridge.EventsEnabled)"); + return false; + } + + return true; + } + + private static void Err(string reqId, string action, string reason) + { + var sb = BridgeJson.Begin("world.error"); + + if (reqId != null) + sb.Str("reqId", reqId); + + sb.Str("action", action).Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + + /// + /// A refusal a ceiling produced, as distinct from a malformed request. + /// + /// Same kind on the wire, and deliberately so: both are a 400 to the caller and both + /// are permanent, so a module that retried one would retry the other. The counter is + /// the difference, because "this shard refused 200 placements last week" is the line + /// that tells an operator their ceilings are set wrong. + /// + private static void Refuse(string reqId, string action, string reason) + { + _refused++; + Err(reqId, action, reason); + } + + // ── persistence ──────────────────────────────────────────────────────────────────────── + + private static void OnWorldSave(WorldSaveEventArgs e) + { + Persistence.Serialize( + SavePath, + writer => + { + writer.Write(SaveVersion); + writer.Write(_owned.Count); + + foreach (var row in _owned.Values) + { + writer.Write(row.Serial); + writer.Write(row.RunId ?? ""); + writer.Write(row.What ?? ""); + writer.Write(row.Type ?? ""); + writer.Write(row.CreatedMs); + writer.Write(row.ExpiresMs); + } + }); + } + + private static void OnWorldLoad() + { + Persistence.Deserialize( + SavePath, + reader => + { + var version = reader.ReadInt(); + + if (version < 1) + return; + + var count = reader.ReadInt(); + + for (int i = 0; i < count; i++) + { + var row = new Owned + { + Serial = reader.ReadInt(), + RunId = reader.ReadString(), + What = reader.ReadString(), + Type = reader.ReadString(), + CreatedMs = reader.ReadLong(), + ExpiresMs = reader.ReadLong(), + }; + + _owned[row.Serial] = row; + Index(row); + } + }); + } + } +} diff --git a/tools/scaffolding/BridgeRigDriver.cs b/tools/scaffolding/BridgeRigDriver.cs index fea804f..48ce16f 100644 --- a/tools/scaffolding/BridgeRigDriver.cs +++ b/tools/scaffolding/BridgeRigDriver.cs @@ -39,6 +39,8 @@ namespace Server.Custom /// activate <account> clear an account's inactivity, so its houses stop /// being Condemned and CAN be refreshed /// password <account> <pw> set a game account's password (for a login probe) + /// worldgone <serial> delete an object BEHIND the ownership registry's + /// back, playing the player who killed it /// save a world save /// shutdown a CLEAN shutdown, so the bridge emits server.shutdown /// @@ -146,6 +148,12 @@ namespace Server.Custom case "partprobe": BridgeParticipationProbe.Run(null, Arg(parts, 1), Int(Arg(parts, 2)), Int(Arg(parts, 3))); break; + // Phase 12a. `world.despawn` answering `gone` rather than `removed` is the + // path a player takes every time they kill an event creature, and it is the one + // outcome the rig cannot reach by asking the bridge: every bridge verb that + // removes an object also drops its registry row, so the two never disagree. + // This deletes the object and leaves the row, which is exactly what a sword does. + case "worldgone": WorldGone(Arg(parts, 1)); break; case "save": Say("saving"); Misc.AutoSave.Save(); break; // A clean shutdown, which is the only kind that EMITS. `Stop-Process` drops the // socket and the shard says nothing, so a killed shard is indistinguishable from @@ -156,6 +164,44 @@ namespace Server.Custom } } + /// + /// Deletes an object by serial, without telling anything. + /// + /// Accepts the `0x…` form the bridge writes serials in, so a serial can be pasted + /// straight out of a `world.owned` reply. + /// + private static void WorldGone(string raw) + { + if (String.IsNullOrEmpty(raw)) + { + Say("worldgone "); + return; + } + + var text = raw.Trim(); + uint parsed; + var ok = text.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? UInt32.TryParse(text.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed) + : UInt32.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed); + + if (!ok) + { + Say("worldgone: \"" + raw + "\" is not a serial"); + return; + } + + var entity = World.FindEntity((Serial)unchecked((int)parsed)); + + if (entity == null || entity.Deleted) + { + Say("worldgone: nothing at " + text); + return; + } + + entity.Delete(); + Say("worldgone: deleted " + text + " and told nobody"); + } + private static string Arg(string[] parts, int i) { return i < parts.Length ? parts[i] : null; -- 2.49.1