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