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;