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