diff --git a/overlay.toml b/overlay.toml index 3392ee0..7bc1fcc 100644 --- a/overlay.toml +++ b/overlay.toml @@ -23,8 +23,8 @@ # manual duty: when the protocol changes, bump it here in the same PR that # changes the emitters, exactly as link bumps PROTOCOL_VERSION. # -# Current: 5 — see docs/link/v5.md (house.decay scheduling, vendor.listing fees, account.login.result). -protocol = 5 +# Current: 6 — see docs/link/v6.md (idempotency keys on inbound commands, champ.boss.killed). +protocol = 7 # ── ServUO compatibility ───────────────────────────────────────────────────── # diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index c79076f..5da1626 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -199,6 +199,102 @@ RequireIpForCreate=true AccountNameMaxLength=16 AccountPasswordMaxLength=30 +# ── The event plane (docs/link/v6.md 8) ────────────────────────────────────── +# +# Leases and the participation ledger: the website holding a live config value for a bounded +# time, and this shard counting who took part in a run. Both are driven on a SCHEDULE, by an +# event the website starts unattended. +# +# This is deliberately NOT AdminWriteEnabled. Turning the admin plane on is consenting to +# staff moderation driven from a screen a human is looking at; turning this on is consenting +# to the website changing and watching your world at four in the morning. One switch could +# not honestly express both. +# +# A lease always carries its own deadline and this shard restores the baseline when it +# passes, whether or not the website is ever heard from again -- and a lease is never written +# to disk, so a restart puts every leased value back too. +EventsEnabled=false + +# The longest this shard will hold a lease, whatever the website asks for. Thirty days. +# A longer request is REFUSED rather than shortened: a silently-clamped lease would leave the +# two halves disagreeing about when the world comes back. +LeaseMaxDurationSec=2592000 + +# How long a finished lease stays listed after its deadline restored it, so a teardown that +# arrives late still gets a definite verdict instead of finding nothing. +LeaseGraceSec=86400 + +# How often the participation sweep credits everyone standing in a run's area, and what one +# kill inside it is worth against one minute of being there. +ParticipationSweepSeconds=30 +ParticipationKillWeight=5.0 + +# Bounds. Runs counted at once, members per run, and the widest area an event may declare. +ParticipationMaxRuns=8 +ParticipationMaxMembers=2000 +ParticipationMaxRadius=300 + +# How long a closed run's tally stays readable before this shard forgets it, and how many +# members one snapshot resolves before yielding the Core thread. +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 + +# Item grants (Phase 12b). The first bounds how many characters one grant may reach -- +# the run's participation ledger is the recipient list, so this is a bound on the size of +# an event rather than on a number somebody typed. The second bounds one hand. +# Both REFUSE rather than clamp: the website records what was handed out. +EventsMaxGrantPerRun=200 +EventsMaxGrantStack=1000 + +# The shortest gap between world saves, counted from the last save by anybody -- +# ServUO's own autosave included. A save stops the world, so this is a rate limit rather +# than a cap, and a save asked for too soon is refused rather than queued: a queued save +# would land at a moment nobody chose. Set to 0 to allow a save at any time. +EventsMinSaveIntervalSec=300 + # 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 c7e7f4b..3d6c131 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs @@ -133,7 +133,42 @@ namespace Server.Custom.Bridge return; } - handler(obj); + // Protocol 6. A command may carry an `idempotencyKey`, and one that does is executed at + // most once: a repeat is answered with the original reply rather than re-run. The gate + // is here rather than in each handler so it covers every inbound kind — including the + // ones a later protocol adds, which is the half that is easy to forget. A command with + // no key behaves exactly as it did before, which is what keeps the admin screens (which + // send none) unchanged. + var idempotencyKey = BridgeJson.GetString(obj, "idempotencyKey"); + + if (idempotencyKey == null) + { + handler(obj); + return; + } + + if (BridgeIdempotency.Intercept(idempotencyKey, obj)) + return; // already answered: a replay of the original reply, or bridge.busy + + string error = null; + + try + { + handler(obj); + } + catch (Exception ex) + { + // Swallowed deliberately, and only on the keyed path: the key must be closed out + // with a definite answer (see BridgeIdempotency's header) rather than left in + // flight by an exception unwinding past Finish. Unkeyed commands still throw the + // way they always have. + error = ex.Message; + Console.WriteLine("[Bridge] handler for '{0}' threw: {1}", kind, ex); + } + finally + { + BridgeIdempotency.Finish(idempotencyKey, error); + } } private static void OnPing(Dictionary o) @@ -167,6 +202,9 @@ namespace Server.Custom.Bridge BridgeHousing.Rearm(); BridgePoints.Rearm(); 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(); @@ -188,6 +226,7 @@ namespace Server.Custom.Bridge BridgeHousing.SweepOnce(); BridgePoints.SweepOnce(); BridgeMarket.SweepOnce(); + BridgeParticipation.SweepOnce(); e.Mobile.SendMessage("Bridge: ran one sweep of each stream."); e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status()); @@ -197,6 +236,7 @@ namespace Server.Custom.Bridge e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status()); break; default: @@ -215,6 +255,11 @@ namespace Server.Custom.Bridge e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status()); + 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()); + e.Mobile.SendMessage("Bridge: {0}", BridgeOneShots.Status()); break; } } diff --git a/overlay/Scripts/Custom/Bridge/BridgeChamps.cs b/overlay/Scripts/Custom/Bridge/BridgeChamps.cs index 8c36273..8eb429f 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeChamps.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeChamps.cs @@ -41,7 +41,17 @@ namespace Server.Custom.Bridge // Item and Mobile serials occupy disjoint ranges, so one map safely spans all three families. private static readonly Dictionary _last = new Dictionary(); - private static long _sweeps, _emitted, _removed; + // Protocol 6. Which spawn a live champion belongs to, refreshed by the sweep. The kill itself + // is detected by TYPE (see OnCreatureDeath), so this map only ever supplies CONTEXT — which + // altar, at what level. A boss that popped and died inside one sweep interval is still + // reported; it simply arrives without its spawn. + private static readonly Dictionary _bossOf = new Dictionary(); + + // How many damage entries a kill reports. Deep enough that a real champion fight's meaningful + // contributors are all present, shallow enough that the frame stays one line on the wire. + private const int MaxDamagers = 20; + + private static long _sweeps, _emitted, _removed, _bossKills; public static void Initialize() { @@ -56,12 +66,24 @@ namespace Server.Custom.Bridge // Re-emit the full board whenever the sidecar (re)connects, so a sidecar that restarted // independently of the shard rebuilds its state within one sweep. BridgeLink.Connected_Core += OnConnected; + + // Protocol 6. A boss defeat was previously only INFERABLE — champ.update going bossUp + // true then false, correlated against a mob.killed nearby — and that inference is both + // fragile and silent about who did the work. It is a real moment in a shard's week and + // an event's phase condition wants to name it, so it becomes a kind of its own. + EventSink.CreatureDeath += OnCreatureDeath; + Rearm(); } private static void OnConnected() { _last.Clear(); + + // _bossOf is deliberately NOT cleared. It is a fact about the world, not a diff cache: + // dropping it on a sidecar reconnect would lose the spawn attribution for a boss that is + // up right now, and it refills from the sweep only if that boss's record happens to + // change again before it dies. } /// Stops and recreates the timer from current config. Called by `[bridge reload`. @@ -82,8 +104,160 @@ namespace Server.Custom.Bridge public static string Status() { - return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3})", - _sweeps, _emitted, _removed, _last.Count); + return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3} bossKills={4} bossesUp={5})", + _sweeps, _emitted, _removed, _last.Count, _bossKills, _bossOf.Count); + } + + // ---- champ.boss.killed (Protocol 6) ---- + + /// + /// Fires for every creature death on the shard, so the first thing it does is decide + /// this is not one. Detection is by TYPE — BaseChampion, which + /// BaseSeaChampion derives from, so one check covers both families — with the + /// sweep's map used only to name the altar. A boss that popped and died between two + /// sweeps is therefore still reported; it simply arrives without a spawn. + /// + /// The damage table is read here and nowhere else, because it exists here and nowhere + /// else: ServUO discards a creature's damage entries with the creature, and the shard is + /// the only party that ever sees them. Entries are reported whether or not ServUO + /// considers them expired — expiry governs LOOTING RIGHTS, and someone who fought the + /// first two thirds of a champion fight and then died took part in it regardless of what + /// they are owed from the corpse. + /// + private static void OnCreatureDeath(CreatureDeathEventArgs e) + { + try + { + var boss = e.Creature; + + if (boss == null) + return; + + Serial spawnSerial; + bool attributed = _bossOf.TryGetValue(boss.Serial, out spawnSerial); + + if (!(boss is BaseChampion) && !attributed) + return; + + _bossOf.Remove(boss.Serial); + _bossKills++; + + var spawn = attributed ? World.FindItem(spawnSerial) as ChampionSpawn : null; + var name = String.IsNullOrEmpty(boss.Name) ? boss.GetType().Name : boss.Name; + + var sb = BridgeJson.Begin("champ.boss.killed") + .Str("category", boss is BaseSeaChampion ? "sea" : "champion") + .Ser("bossSerial", boss.Serial) + .Str("boss", name) + .Str("bossType", boss.GetType().Name) + .Str("map", boss.Map == null ? null : boss.Map.Name) + .Num("x", boss.X).Num("y", boss.Y).Num("z", boss.Z); + + // The altar's own record, when the kill could be attributed to one. `serial` is the + // SPAWN here, matching champ.update, so a consumer can join the two without a rule + // about which of two serials on the frame means what. + if (spawn != null) + { + sb.Ser("serial", spawn.Serial) + .Str("type", spawn.Type.ToString()) + .Num("level", spawn.Level); + } + + // A named region is what a phase condition can actually match on ("the boss in + // Yew"); coordinates are not. Emitted alongside the coordinates rather than + // instead, because large stretches of the map belong to no named region at all. + // + // **The innermost region here is ANONYMOUS, and the rig is the only thing that was + // ever going to say so.** A champion killed in the middle of Britain produced a + // frame with no region at all, because an active `ChampionSpawn` registers a + // `ChampionSpawnRegion` over its own spawn area — constructed with a null name and + // with the town region as its PARENT (`ChampionSpawn.cs`, its constructor). So the + // most specific region containing a champion boss is, by construction, the one + // region on the map guaranteed to have no name. + // + // It also explains why this looked fine for twenty seconds: region registration is + // deferred, so a lookup immediately after the altar is placed still answers + // "Britain" and one at the kill does not. A first read at spawn time would have + // confirmed a bug into the design. + // + // Walking to the nearest NAMED ancestor is the general answer rather than a special + // case for champions: a house region, a dungeon sub-region and a guarded-zone + // overlay are all anonymous children of somewhere a player would name. + var region = NamedRegionAt(boss.Location, boss.Map); + + if (region != null) + sb.Str("region", region); + + if (e.Killer != null) + sb.Actor("killer", e.Killer); + + sb.Damagers("damagers", TopDamagers(boss), MaxDamagers); + + BridgeLink.Emit(sb.End()); + } + catch (Exception ex) + { + // A death handler must never be the thing that breaks a death. + Console.WriteLine("[Bridge] champ.boss.killed threw: {0}", ex.Message); + } + } + + /// + /// Player damage against this creature, highest first. Totals are summed per damager + /// rather than trusted to be one entry each: ServUO's own registration folds repeat + /// damage into an existing entry, but an entry that expired and was re-created leaves + /// two, and a table that listed the same player twice would be read as two participants. + /// + /// + /// The nearest NAMED region containing a point, walking outward from the most specific + /// one, or null when nothing on the way out has a name. + /// + /// Null rather than "" so the caller can leave the field off the frame entirely: a + /// consumer reading `region: ""` cannot tell "outdoors, nowhere in particular" from + /// "somewhere, but the shard would not say", and only one of those is true here. + /// + /// The map's own default region terminates the walk with its parentless empty name, so + /// a point in open countryside answers null without a special case. + /// + private static string NamedRegionAt(Point3D p, Map map) + { + if (map == null) + return null; + + for (var region = Region.Find(p, map); region != null; region = region.Parent) + { + if (!String.IsNullOrEmpty(region.Name)) + return region.Name; + } + + return null; + } + + private static List> TopDamagers(Mobile boss) + { + var totals = new Dictionary(); + + var entries = boss.DamageEntries; + + if (entries != null) + { + for (int i = 0; i < entries.Count; i++) + { + var de = entries[i]; + + if (de == null || de.Damager == null || de.Damager.Deleted || !de.Damager.Player) + continue; + + int running; + totals.TryGetValue(de.Damager, out running); + totals[de.Damager] = running + de.DamageGiven; + } + } + + var ranked = totals.ToList(); + ranked.Sort((a, b) => b.Value.CompareTo(a.Value)); + + return ranked; } /// Runs one sweep now. Wired into `[bridge sweepnow`. @@ -107,6 +281,15 @@ namespace Server.Custom.Bridge { if (s.Deleted) continue; + + // Protocol 6. Remember which altar a live champion belongs to so its death can + // name one. Recorded here rather than looked up at death because the lookup + // would be a scan of World.Items on every creature death on the shard. + var champion = s.Champion; + + if (champion != null && !champion.Deleted) + _bossOf[champion.Serial] = s.Serial; + Track(seen, s.Serial, SigChampion(s), WriteChampion(s)); } @@ -133,6 +316,19 @@ namespace Server.Custom.Bridge BridgeLink.Emit(BridgeJson.Begin("champ.remove").Ser("serial", serial).End()); _removed++; } + + // A defeated champion's attribution is consumed by OnCreatureDeath, but one deleted + // by a GM or lost to a world reload never dies, so the map is swept too. Cheap: it + // holds at most one entry per altar with a boss currently up. + if (_bossOf.Count > 0) + { + var vanished = _bossOf.Keys + .Where(k => { var m = World.FindMobile(k); return m == null || m.Deleted; }) + .ToList(); + + foreach (var k in vanished) + _bossOf.Remove(k); + } } catch (Exception ex) { diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index ee067fe..dedf4bc 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -78,6 +78,49 @@ namespace Server.Custom.Bridge public static int AdminReasonMaxLength { get; private set; } public static int AdminBanMaxDurationSec { get; private set; } + // ---- the event plane (docs/link/v6.md §8, EVENTS_PLAN.md Phase 11b) ---- + // + // **Its own gate, deliberately not AdminWriteEnabled** (org lead, 2026-09-04). Enabling the + // admin plane is an operator consenting to staff moderation driven from the website - a + // human pressing kick or ban on a screen. A lease and a participation ledger are the + // website changing and watching the world on a SCHEDULE, unattended, at four in the + // morning. Those are different consents, and one switch cannot express both. + public static bool EventsEnabled { get; private set; } + + public static int LeaseMaxDurationSec { get; private set; } + public static int LeaseGraceSec { get; private set; } + + public static int ParticipationSweepSeconds { get; private set; } + public static double ParticipationKillWeight { get; private set; } + public static int ParticipationMaxRuns { get; private set; } + public static int ParticipationMaxMembers { get; private set; } + public static int ParticipationMaxRadius { get; private set; } + 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; } + public static int EventsMaxGrantPerRun { get; private set; } + public static int EventsMaxGrantStack { get; private set; } + public static int EventsMinSaveIntervalSec { 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; } @@ -235,6 +278,147 @@ namespace Server.Custom.Bridge AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400); AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000); + // The event plane. Off until an operator says otherwise - see the field block above for + // why this is not AdminWriteEnabled. + EventsEnabled = Config.Get("Bridge.EventsEnabled", false); + + // Thirty days, matching core's own MAX_LEASE_MS. This is the shard's INDEPENDENT + // ceiling rather than a mirror of it: the website bounds what it will ask for, and a + // shard that trusted the asking would have no bound of its own at the one moment it + // matters, which is when the website is wrong. + LeaseMaxDurationSec = Config.Get("Bridge.LeaseMaxDurationSec", 2592000); + if (LeaseMaxDurationSec < 1) + LeaseMaxDurationSec = 1; + + // How long a finished lease stays listed after its deadline restored it, so teardown + // still gets a definite verdict rather than finding nothing and having to guess. + LeaseGraceSec = Config.Get("Bridge.LeaseGraceSec", 86400); + if (LeaseGraceSec < 0) + LeaseGraceSec = 0; + + ParticipationSweepSeconds = Config.Get("Bridge.ParticipationSweepSeconds", 30); + if (ParticipationSweepSeconds < 1) + ParticipationSweepSeconds = 1; + + // What one kill inside the area is worth against one minute of standing in it. Both + // halves live on the shard because the score IS the shard's number: core stores an + // opaque decimal it never interprets, so a weight core could edit would be a weight + // nobody could explain from either side. + ParticipationKillWeight = Config.Get("Bridge.ParticipationKillWeight", 5.0); + if (ParticipationKillWeight < 0.0) + ParticipationKillWeight = 0.0; + + ParticipationMaxRuns = Config.Get("Bridge.ParticipationMaxRuns", 8); + if (ParticipationMaxRuns < 1) + ParticipationMaxRuns = 1; + + ParticipationMaxMembers = Config.Get("Bridge.ParticipationMaxMembers", 2000); + if (ParticipationMaxMembers < 1) + ParticipationMaxMembers = 1; + + // A radius, not a rectangle, and bounded: an area big enough to cover a facet makes + // "took part" meaningless and the sweep expensive in the same stroke. + ParticipationMaxRadius = Config.Get("Bridge.ParticipationMaxRadius", 300); + if (ParticipationMaxRadius < 1) + ParticipationMaxRadius = 1; + + ParticipationGraceSec = Config.Get("Bridge.ParticipationGraceSec", 86400); + if (ParticipationGraceSec < 0) + ParticipationGraceSec = 0; + + // How many members one snapshot resolves before yielding the Core thread. See + // BridgeParticipation: this is what makes the handler DEFER, which is what makes + // `bridge.busy` reachable at all. + ParticipationSnapshotChunk = Config.Get("Bridge.ParticipationSnapshotChunk", 100); + 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; + + // Phase 12b. How many characters one grant may reach, and how many of one item may go + // into one hand. Both refuse rather than clamp, on `LeaseMaxDurationSec`'s argument: + // the website records what was handed out, and a silent clamp would make its ledger a + // description of a grant that did not happen. + EventsMaxGrantPerRun = Config.Get("Bridge.EventsMaxGrantPerRun", 200); + if (EventsMaxGrantPerRun < 0) + EventsMaxGrantPerRun = 0; + + EventsMaxGrantStack = Config.Get("Bridge.EventsMaxGrantStack", 1000); + if (EventsMaxGrantStack < 1) + EventsMaxGrantStack = 1; + + // A save stops the world, so this one is a rate limit rather than a cap. It counts from + // the last save by ANYBODY -- ServUO's own autosave included -- because an event save + // thirty seconds after the hourly one is the same freeze twice, and this shard is the + // only half that can see both. + EventsMinSaveIntervalSec = Config.Get("Bridge.EventsMinSaveIntervalSec", 300); + if (EventsMinSaveIntervalSec < 0) + EventsMinSaveIntervalSec = 0; + // 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. @@ -313,9 +497,10 @@ namespace Server.Custom.Bridge public static string Describe() { return String.Format( - "enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11})", + "enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11}) events={12}", Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds, - ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled); + ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled, + EventsEnabled); } } } diff --git a/overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs b/overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs new file mode 100644 index 0000000..d44cf68 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs @@ -0,0 +1,541 @@ +using System; +using System.Collections.Generic; + +namespace Server.Custom.Bridge +{ + /// + /// Protocol 6. Makes a repeated command safe. + /// + /// The website's event runner retries a step that did not come back, and until now a command + /// whose acknowledgement was lost was indistinguishable from one that never applied. There + /// was no way to tell the difference from either end, so every world-writing verb had to be + /// declared un-retryable — a lost announcement being cheaper than a doubled one. That is not + /// a position you can hold once an event can spawn creatures or lease a config value. + /// + /// So a command may now carry an `idempotencyKey`, and the shard promises: **a key is + /// executed at most once.** A repeat is never re-run. It is answered with the original + /// reply — the same acknowledgement the caller lost — under the repeat's own correlation id. + /// + /// ── Reserve on receipt, not on completion ────────────────────────────────────────────── + /// + /// The key is recorded BEFORE the handler is dispatched, not after it returns. A handler that + /// finishes inside its own inbound call can never see a repeat (the Core thread processes one + /// line at a time), but a handler that defers — a lease that arms a timer, a spawn that + /// waits for a save — completes long after `OnInboundLine` has returned, and that is exactly + /// the window a lost ack opens. Reserving late would leave it uncovered. + /// + /// A repeat of a key that is still in flight is answered `bridge.busy`: it runs nothing and + /// tells the caller to come back. `bridge.busy` is deliberately not an error — the work is + /// happening, and the module classifies it retryable. + /// + /// ── A key that has begun is never released — EXCEPT on a refusal ────────────────────── + /// + /// Not when the handler throws. Releasing it would let a retry re-run a command that may have + /// applied half of itself, which is precisely the failure this file exists to prevent. A + /// handler that throws stores a `bridge.error` reply instead, so the retry gets a definite + /// answer and the step fails once rather than looping. + /// + /// A REFUSAL is the third case, and it was missing until the Phase 16 acceptance walk. A + /// handler that ran to completion and answered `*.error` did not do anything: every refusal + /// on this plane is a guard — a missing runId, an unknown item, a cap, a rate limit, a write + /// that failed and left the value alone. Remembering it froze the answer for ever, so a + /// refusal that WAITING FIXES could never be retried past. `uo.world.save` is the case that + /// found it: the shard saves at most every 300 seconds, the module says in as many words that + /// this is "the one refusal on this plane that waiting fixes", and six attempts over four + /// minutes all replayed one frozen sentence — "the last save was 227 seconds ago" — because + /// the number was the first reply's, not the clock's. + /// + /// So a refusal releases the key: nothing happened, and the caller is free to ask again. The + /// refusal is still EMITTED to the caller, which is what ends the attempt; it is simply not + /// remembered as this key's answer. The safety argument is that "nothing happened" is a + /// property of every `*.error` reply here, and it is a property this file cannot verify — so + /// it is a rule handlers must keep: **do not answer `*.error` after changing the world.** + /// Report a partial change in an `ok` reply, as the item grant does with `granted`/`missed` + /// and the despawn with `removed`/`gone`/`refused`. + /// + /// ── The one hole, and why it is loud ────────────────────────────────────────────────── + /// + /// The set is bounded, so an evicted key's repeat WOULD be applied a second time. The bounds + /// are chosen to put that far outside reach — an hour, against core's fifteen-minute step + /// lease — and an eviction that drops a key which had not yet expired prints a warning naming + /// the count. If the guarantee is ever actually breached, an operator sees it here rather + /// than discovering a doubled spawn in the world. + /// + public static class BridgeIdempotency + { + /// + /// How long a key is remembered. Core's step lease is 15 minutes and its retry backoff + /// is bounded well inside that, so an hour is not a tuned number — it is a margin wide + /// enough that expiry should never be the thing that ends a key's life. + /// + private static readonly TimeSpan Ttl = TimeSpan.FromHours(1.0); + + /// + /// Hard bound on remembered keys, in the same spirit as BridgeLink's outbound queue cap: + /// the Core thread never holds an unbounded collection. At command rates this is days of + /// traffic, so reaching it means something is wrong — hence the warning on eviction. + /// + private const int Cap = 4096; + + /// + /// The correlation fields the sidecar routes replies on, in the order `rpc.rs` tries + /// them. A command carries exactly one; the reply echoes it. A replay must be stamped + /// with the REPEAT's value, not the original's — the sidecar's `reqId` is a fresh + /// per-process counter, so the retry is waiting on an id the first attempt never used. + /// + private static readonly string[] CorrFields = { "reqId", "code", "id" }; + + private sealed class Entry + { + public DateTime Reserved; // when the key was first seen + public bool Done; // the handler has finished (successfully or not) + public string Reply; // the correlated reply line, verbatim; null if there was none + public string Corr; // the correlation value the original reply carries + public string CorrField; // which of CorrFields that value sits in + public string Kind; // for diagnostics only + public bool Refused; // the reply was a `*.error`: nothing happened, so do not keep the key + } + + /// + /// Is this reply a refusal — a handler that ran and deliberately did nothing? + /// + /// Every refusal on this plane is emitted as a `kind` ending in `.error` + /// (`world.error`, `lease.error`, `oneshot.error`, `participation.error`, …). Matched on + /// the suffix rather than a list, so a handler family added later is covered without + /// anyone remembering to extend an enumeration here. + /// + /// `bridge.error` is deliberately EXCLUDED: that is the reply this file writes itself + /// when a handler THREW, and a throw is exactly the case whose key must be kept. + /// + private static bool IsRefusal(string replyLine) + { + if (replyLine == null) + return false; + + var parsed = BridgeJson.Parse(replyLine); + + if (parsed == null) + return false; + + var kind = BridgeJson.GetString(parsed, "kind"); + + if (kind == null || String.Equals(kind, "bridge.error", StringComparison.Ordinal)) + return false; + + return kind.EndsWith(".error", StringComparison.Ordinal); + } + + private static readonly Dictionary _byKey = + new Dictionary(StringComparer.Ordinal); + + // Insertion order, so the cap evicts oldest-first without sorting the dictionary. + private static readonly Queue _order = new Queue(); + + // ---- capture state; Core thread only, one keyed command at a time ---- + + private static Entry _open; + private static string _openCorr; + private static string _openCorrField; + + private static long _seen, _replayed, _busy, _evicted, _uncorrelated, _refusals; + + /// + /// True while a keyed command's handler is running. BridgeLink.Emit checks this on every + /// emit, so it is a plain bool read rather than anything that costs the sweep path. + /// + public static bool Capturing + { + get { return _open != null; } + } + + public static string Status() + { + return String.Format( + "idem(keys={0} seen={1} replayed={2} busy={3} evicted={4} uncorrelated={5} refused={6})", + _byKey.Count, _seen, _replayed, _busy, _evicted, _uncorrelated, _refusals); + } + + /// + /// Called by BridgeBoot for every inbound command that carries an `idempotencyKey`, + /// before the handler runs. + /// + /// Returns TRUE when the command must not be executed — this call has already emitted the + /// answer (a replay of the original reply, or `bridge.busy`). Returns FALSE when the key + /// is new: the key is now reserved and capture is open, and the caller MUST pair this + /// with in a finally. + /// + public static bool Intercept(string key, Dictionary command) + { + _seen++; + Sweep(); + + string corrField = null; + string corr = null; + + for (int i = 0; i < CorrFields.Length; i++) + { + var v = BridgeJson.GetString(command, CorrFields[i]); + + if (v != null) + { + corrField = CorrFields[i]; + corr = v; + break; + } + } + + Entry prior; + + if (_byKey.TryGetValue(key, out prior)) + { + if (prior.Done) + Replay(key, prior, corrField, corr); + else + Busy(key, prior, corrField, corr); + + return true; + } + + var entry = new Entry + { + Reserved = DateTime.UtcNow, + Done = false, + Kind = BridgeJson.GetString(command, "kind"), + }; + + Remember(key, entry); + + _open = entry; + _openCorr = corr; + _openCorrField = corrField; + + return false; + } + + /// + /// Called by BridgeBoot in a finally, once the handler has returned. Closes capture and + /// marks the key done. `error` is non-null when the handler threw. + /// + /// A handler that deferred its work calls first; this then leaves the + /// key reserved and in flight, and the handler completes it later. + /// + public static void Finish(string key, string error) + { + var entry = _open; + + _open = null; + var corr = _openCorr; + var corrField = _openCorrField; + _openCorr = null; + _openCorrField = null; + + if (entry == null || entry.Done) + return; // Hold() released it to its own completion, or there was nothing open + + if (error != null) + { + // The handler threw. The key stays claimed — see the class header — and the stored + // answer is the failure, so the retry ends the step instead of re-running a command + // that may have applied part of itself. + var sb = BridgeJson.Begin("bridge.error"); + + if (corrField != null) + sb.Str(corrField, corr); + + sb.Str("reason", "handler threw: " + error) + .Str("idempotencyKey", key); + + entry.Reply = sb.End(); + entry.Corr = corr; + entry.CorrField = corrField; + entry.Done = true; + + Console.WriteLine("[Bridge] idempotency: {0} threw under key {1}; the retry will be answered with the failure", + entry.Kind, key); + return; + } + + if (entry.Reply == null) + { + // Nothing the sidecar could have correlated was emitted. That is a defect in the + // handler rather than a state to model: the FIRST attempt has already timed out at + // the sidecar, and the retry would time out identically forever. Store a definite + // answer so the retry terminates, and say so. + _uncorrelated++; + + var sb = BridgeJson.Begin("bridge.error"); + + if (corrField != null) + sb.Str(corrField, corr); + + sb.Str("reason", "the original command produced no correlated reply") + .Str("idempotencyKey", key); + + entry.Reply = sb.End(); + entry.Corr = corr; + entry.CorrField = corrField; + + Console.WriteLine("[Bridge] idempotency: {0} under key {1} emitted no reply the sidecar could correlate", + entry.Kind, key); + } + else if (entry.Refused) + { + // The handler ran and refused, so nothing happened and this key is not spent. The + // refusal has already gone out to the caller; it just is not remembered as the + // answer. Without this, a refusal that waiting fixes could never be retried past — + // see the class header. + Release(key); + _refusals++; + return; + } + + entry.Done = true; + } + + /// + /// For a handler that finishes AFTER its inbound call returns. It keeps the key reserved + /// (so a repeat is answered `bridge.busy` rather than executed) and takes on the duty of + /// calling with the reply it eventually emits. + /// + /// 11a built this door and had nothing to walk through it. `participation.snapshot` is + /// the first: above a threshold it walks its members in chunks across Core ticks, so it + /// completes long after its inbound call returned, and a repeat arriving in between is + /// the first `bridge.busy` this shard can actually produce. + /// + public static void Hold(string key) + { + var entry = _open; + + if (entry == null) + return; + + // The caller must be holding the key it was dispatched under. A mismatch would leave + // the OPEN key marked done by Finish while the named one stayed in flight forever, so + // it is refused rather than honoured: capture stays open and the ordinary path runs. + if (key == null || !_byKey.ContainsKey(key)) + { + Console.WriteLine("[Bridge] idempotency: Hold called with an unknown key '{0}'; ignoring", key); + return; + } + + // Close capture without marking done: the key stays in flight until Complete. + _open = null; + _openCorr = null; + _openCorrField = null; + } + + /// + /// Completes a key a handler previously held. `replyLine` is the line the handler emits + /// as its answer; it is stored so a later repeat replays it. + /// + public static void Complete(string key, string replyLine) + { + Entry entry; + + if (key == null || !_byKey.TryGetValue(key, out entry) || entry.Done) + return; + + var parsed = replyLine == null ? null : BridgeJson.Parse(replyLine); + + if (parsed != null) + { + for (int i = 0; i < CorrFields.Length; i++) + { + var v = BridgeJson.GetString(parsed, CorrFields[i]); + + if (v != null) + { + entry.CorrField = CorrFields[i]; + entry.Corr = v; + break; + } + } + } + + // A deferred handler can refuse too — a lease whose target vanished while the timer was + // armed answers `lease.error` here rather than from inside the inbound call. Same rule: + // nothing happened, so the key is not spent. + if (IsRefusal(replyLine)) + { + Release(key); + _refusals++; + return; + } + + entry.Reply = replyLine; + entry.Done = true; + } + + /// + /// Give a key back, as though it had never been seen. + /// + /// Only ever called for a refusal — see the class header. It removes the entry from the + /// lookup; the stale key left in `_order` is harmless, because eviction re-reads + /// `_byKey` and skips what is no longer there. + /// + private static void Release(string key) + { + if (key != null) + _byKey.Remove(key); + } + + /// + /// Every line a keyed handler emits passes through here. Only the one the sidecar would + /// correlate with THIS command is kept: an `admin.audit` broadcast that happens to be + /// emitted alongside the reply is a fact about the world and must not be replayed, while + /// the reply is an answer to a caller and must be. + /// + public static void Observe(string line) + { + var entry = _open; + + if (entry == null || line == null || _openCorrField == null || _openCorr == null) + return; + + // Cheap reject before parsing: the correlation value is a string field on the reply, so + // if it does not appear in the line at all this cannot be the reply. + if (line.IndexOf(_openCorr, StringComparison.Ordinal) < 0) + return; + + var parsed = BridgeJson.Parse(line); + + if (parsed == null) + return; + + if (!String.Equals(BridgeJson.GetString(parsed, _openCorrField), _openCorr, StringComparison.Ordinal)) + return; + + entry.Reply = line; + entry.Corr = _openCorr; + entry.CorrField = _openCorrField; + entry.Refused = IsRefusal(line); + } + + // ---- internals ---- + + private static void Replay(string key, Entry prior, string corrField, string corr) + { + _replayed++; + + // A repeat with no correlation field is nobody's outstanding call. Re-emitting the + // original reply would put a stale answer on the event feed, where a subscriber would + // read it as a fresh one, so the repeat is absorbed silently instead. + if (corrField == null || corr == null) + { + Console.WriteLine("[Bridge] idempotency: absorbed an uncorrelated repeat of key {0} ({1})", + key, prior.Kind); + return; + } + + // Stamp the repeat's correlation id over the original's. The sidecar is waiting on the + // id IT sent this time; replaying the first attempt's id would leave the call hanging + // until the reply timeout, which is the very failure being answered. + string line = null; + + if (prior.Reply != null && String.Equals(corrField, prior.CorrField, StringComparison.Ordinal)) + line = BridgeJson.RewriteStringField(prior.Reply, corrField, corr); + + if (line == null) + { + // Either the original produced no reply to replay, or the repeat correlates on a + // different field than the original did. Nothing sensible can be replayed under an + // id the caller is not waiting on, so answer plainly rather than hang the call. + BridgeLink.Emit(BridgeJson.Begin("bridge.error") + .Str(corrField, corr) + .Str("reason", "the original reply for this idempotency key cannot be replayed") + .Str("idempotencyKey", key) + .End()); + return; + } + + line = BridgeJson.WithTrueFlag(line, "replayed"); + + Console.WriteLine("[Bridge] idempotency: replaying the original reply for key {0} ({1})", + key, prior.Kind); + + BridgeLink.Emit(line); + } + + private static void Busy(string key, Entry prior, string corrField, string corr) + { + _busy++; + + var sb = BridgeJson.Begin("bridge.busy"); + + if (corrField != null) + sb.Str(corrField, corr); + + // **`busyKind`, not `kind`, and the name is the whole bug.** `Begin` has already + // written this frame's own `kind` as `bridge.busy`, so a second `kind` field made the + // object carry two -- and every JSON parser worth the name takes the LAST. The sidecar + // matches `bridge.busy` to decide on a 425, read `participation.snapshot` instead, and + // answered an ordinary 200 with a body saying nothing had happened. + // + // It shipped in 11a and could not be seen there: with only synchronous handlers a + // repeat can never arrive mid-flight, so this arm was unreachable on a live shard and + // the unit test that covers the sidecar's mapping was, correctly, feeding it a frame + // built by hand. The first deferring handler produced it on its first collision. + sb.Str("idempotencyKey", key) + .Str("busyKind", prior.Kind) + .Str("reason", "a command with this idempotency key is still in flight"); + + BridgeLink.Emit(sb.End()); + } + + private static void Remember(string key, Entry entry) + { + _byKey[key] = entry; + _order.Enqueue(key); + + while (_order.Count > Cap) + { + var oldest = _order.Dequeue(); + + Entry dropped; + + if (!_byKey.TryGetValue(oldest, out dropped)) + continue; + + _byKey.Remove(oldest); + + // Expired keys leave silently; they are supposed to. A key evicted while still + // inside its TTL is the guarantee's one hole, so it never leaves quietly. + if (DateTime.UtcNow - dropped.Reserved < Ttl) + { + _evicted++; + Console.WriteLine( + "[Bridge] idempotency: evicted key {0} ({1}) while still live — the cap of {2} was reached, so a repeat of it WOULD be applied again ({3} so far)", + oldest, dropped.Kind, Cap, _evicted); + } + } + } + + /// Drops keys past their TTL. Runs on the command path, which is human-rate. + private static void Sweep() + { + if (_order.Count == 0) + return; + + var cutoff = DateTime.UtcNow - Ttl; + + while (_order.Count > 0) + { + var oldest = _order.Peek(); + + Entry entry; + + if (!_byKey.TryGetValue(oldest, out entry)) + { + _order.Dequeue(); + continue; + } + + if (entry.Reserved > cutoff) + return; // insertion-ordered, so nothing behind this is older + + _order.Dequeue(); + _byKey.Remove(oldest); + } + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeJson.cs b/overlay/Scripts/Custom/Bridge/BridgeJson.cs index bb6bd17..af2a9fa 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeJson.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeJson.cs @@ -139,6 +139,53 @@ namespace Server.Custom.Bridge return sb; } + /// + /// A named array of actor objects each carrying a damage total — a boss kill's damage + /// table (Protocol 6), and the first actor array whose entries are ranked rather than + /// merely listed. + /// + /// The pairs are written in the order given, so the CALLER owns the sort. That is + /// deliberate: "the top damagers" is a judgement about a fight, and the shard's job is + /// to report the numbers it holds rather than to decide what counts as a contribution. + /// + /// Each entry is the standard actor object plus `damage`, which means it carries `acct` + /// and `webId` and is therefore governed by the website's locked-field rule exactly as + /// every other actor is. A shard that considers the whole table too revealing hides it + /// with one field rule rather than by dropping the kind. + /// + public static StringBuilder Damagers( + this StringBuilder sb, string name, IList> pairs, int count) + { + sb.Append(",\"").Append(name).Append("\":["); + + if (pairs != null) + { + var end = Math.Min(count, pairs.Count); + bool first = true; + + for (int i = 0; i < end; i++) + { + var m = pairs[i].Key; + + if (m == null) + continue; + + if (!first) + sb.Append(','); + + sb.Append('{'); + WriteActorFields(sb, m); + sb.Append(",\"damage\":").Append(pairs[i].Value); + sb.Append('}'); + + first = false; + } + } + + sb.Append(']'); + return sb; + } + /// /// A roster member: the standard actor object plus the member's rank in their guild. /// @@ -260,6 +307,19 @@ namespace Server.Custom.Bridge return sb.ToString(); } + /// + /// Writes a bare JSON string value, or `null`, with no leading comma and no field name. + /// For the hand-built arrays the event plane emits, where would + /// throw on the null a nullable field is entitled to be. + /// + public static void Text(StringBuilder sb, string value) + { + if (value == null) + sb.Append("null"); + else + Escape(sb, value); + } + public static void Escape(StringBuilder sb, string value) { sb.Append('"'); @@ -289,6 +349,75 @@ namespace Server.Custom.Bridge sb.Append('"'); } + // ---- rewriting an already-built line (protocol 6) ---- + // + // BridgeIdempotency replays a stored reply under the REPEAT's correlation id. It could + // parse the line, edit the dictionary and re-serialize, but a round trip through + // JavaScriptSerializer would silently renormalise every number and string in a reply this + // file went to the trouble of writing by hand. These two edit the text instead, so a + // replayed reply is byte-for-byte the original apart from the field that had to change. + + /// + /// Replaces the value of a top-level STRING field, honouring backslash escapes when + /// finding the value's end. Returns null if the field is not present as a string — + /// never a half-rewritten line. + /// + public static string RewriteStringField(string line, string name, string value) + { + if (line == null || name == null || value == null) + return null; + + // The leading comma is part of the needle: every top-level field is written by Str() + // after Begin() has already emitted `t` and `kind`, so a real one always has one. It + // is the cheapest thing that stops the search matching the same text inside a value. + var needle = ",\"" + name + "\":\""; + int at = line.IndexOf(needle, StringComparison.Ordinal); + + if (at < 0) + return null; + + int valueStart = at + needle.Length; + int i = valueStart; + + while (i < line.Length) + { + char c = line[i]; + + if (c == '\\') + { + i += 2; // an escape consumes the next character, whatever it is + continue; + } + + if (c == '"') + break; + + i++; + } + + if (i >= line.Length) + return null; // unterminated: refuse rather than guess + + var sb = new StringBuilder(line.Length + value.Length); + sb.Append(line, 0, valueStart - 1); // up to and excluding the opening quote + Escape(sb, value); + sb.Append(line, i + 1, line.Length - i - 1); + + return sb.ToString(); + } + + /// + /// Appends `"name":true` to an already-closed object. Returns the line unchanged if it + /// is not one, so a malformed reply is passed through rather than corrupted further. + /// + public static string WithTrueFlag(string line, string name) + { + if (String.IsNullOrEmpty(line) || line[line.Length - 1] != '}') + return line; + + return line.Substring(0, line.Length - 1) + ",\"" + name + "\":true}"; + } + // ---- inbound ---- /// @@ -355,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; @@ -371,5 +536,50 @@ namespace Server.Custom.Bridge return fallback; } } + + /// + /// Epoch milliseconds and lease durations do not fit an int, and JavaScriptSerializer + /// hands a large JSON number back as a long or a decimal depending on its magnitude, so + /// the conversion is done rather than the cast attempted. + /// + public static long GetLong(Dictionary o, string key, long fallback) + { + object v; + + if (o == null || !o.TryGetValue(key, out v) || v == null) + return fallback; + + try + { + return Convert.ToInt64(v, CultureInfo.InvariantCulture); + } + catch + { + return fallback; + } + } + + /// + /// A lease VALUE arrives as text on the wire whatever its declared type (see + /// BridgeLeases), so this exists for the numbers that are genuinely numbers - a radius, + /// a weight. InvariantCulture throughout: a shard running under a comma-decimal locale + /// must read the same bytes the same way as one that is not. + /// + public static double GetDouble(Dictionary o, string key, double fallback) + { + object v; + + if (o == null || !o.TryGetValue(key, out v) || v == null) + return fallback; + + try + { + return Convert.ToDouble(v, CultureInfo.InvariantCulture); + } + catch + { + return fallback; + } + } } } diff --git a/overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs b/overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs new file mode 100644 index 0000000..3a4cc7d --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs @@ -0,0 +1,708 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; + +using Server.Engines.SeasonalEvents; +using Server.Mobiles; + +namespace Server.Custom.Bridge +{ + /// + /// Protocol 7, part b. The two lease planes whose value lives on something that is + /// already in the world — a property on an existing object, and a seasonal event's + /// status. + /// + /// `BridgeLeases` owns the wire, the deadline, the compare-and-set and the bookkeeping; + /// this file owns everything that is specific to ServUO, which is the same split the config + /// plane has had since 11b. What is new is that both planes here are targeted: a + /// lease names a key AND the thing it applies to, because `Spawner.MaxCount` is one + /// capability over thousands of spawners rather than one value. + /// + /// ── Why a lease here must be PERSISTED, and the config plane's must not ──────────────── + /// + /// 11b's config lease is deliberately memory-only, and its header states the reason: a lease + /// that never reaches disk makes a shard restart a *free* restore. That argument depends + /// entirely on the leased value being memory-only too, and here it is not. + /// + /// A spawner is an `Item`. It is in the world save. A seasonal entry is written to + /// `Saves/Misc/SeasonalEvents.bin` by ServUO's own `EventSink.WorldSave`. So a restart does + /// not put either of them back — it puts the CHANGE back and throws away the deadline timer + /// that was going to undo it. The world is then stuck at the leased value with nothing on + /// this shard remembering that it is borrowed, which is the exact failure the lease framing + /// exists to make impossible. + /// + /// So the hold is persisted, in the Bridge's third save file, beside 11b's + /// `Participation.bin` and 12a's `Owned.bin` — and, like both of those, written by the same + /// `EventSink.WorldSave` that writes what it describes, so it cannot get out of step with + /// it. The deadline is re-armed on load, from the stored absolute time. + /// + /// **A deadline that has already passed while the shard was down fires at once**, rather + /// than being dropped or extended. The promise the website was given is "back at baseline by + /// then"; a shard that was off for the whole hold has not kept it, and restoring immediately + /// is the only reading of it that is still true. + /// + /// ── Reflection, bounded by an allowlist ─────────────────────────────────────────────── + /// + /// Properties are read and written through reflection, and the allowlist below is what makes + /// that defensible rather than `[set` with extra steps. A pair not named here does not exist + /// as far as this plane is concerned, whatever a caller sends; every entry additionally + /// requires the property to carry `CommandProperty`, so nothing internal is reachable even + /// if a pair were added carelessly. Reflection rather than a hand-written switch is what + /// lets the boot self-check (§N10) actually verify a pair — a switch would compile happily + /// against a property ServUO had renamed. + /// + public static class BridgeLeaseTargets + { + // ---- the object-property allowlist ---- + + /// + /// One allowlisted property, and every type it may be applied to. + /// + /// **`Spawner` and `XmlSpawner` share all four names**, which is a fact about this tree + /// rather than a convenience: the shard's own `Spawns/*.xml` load as XmlSpawners and + /// `[add spawner` makes the native one, so a catalog that named only one of them would + /// work on a shard until the day it did not. They also share the semantics — `MaxCount` + /// is the ceiling the next tick spawns up to on both. + /// + private sealed class PropEntry + { + public string Key; + public string Label; + public string Property; + public string[] Types; + public BridgeLeases.LeaseType Type; + public double Min; + public double Max; + + /// + /// True when the CLR property is a `TimeSpan` and the wire carries seconds. + /// + /// The lease type vocabulary is int/float/bool/string and there is no duration in + /// it, so a respawn window has to cross as a number. Seconds rather than minutes + /// because the spawn files' own `DelayInSec` flag proves both are in use, and a unit + /// that cannot express five seconds cannot express the shard's own data. + /// + public bool Seconds; + } + + private static readonly string[] SpawnerTypes = + { + "Server.Mobiles.Spawner", + "Server.Mobiles.XmlSpawner", + }; + + private static readonly PropEntry[] Props = + { + new PropEntry + { + Key = "Spawner.MaxCount", + Label = "Spawner: how many at once", + Property = "MaxCount", + Types = SpawnerTypes, + Type = BridgeLeases.LeaseType.Int, + Min = 0.0, + Max = 100.0, + }, + new PropEntry + { + Key = "Spawner.MinDelay", + Label = "Spawner: shortest respawn wait", + Property = "MinDelay", + Types = SpawnerTypes, + Type = BridgeLeases.LeaseType.Int, + Min = 0.0, + Max = 86400.0, + Seconds = true, + }, + new PropEntry + { + Key = "Spawner.MaxDelay", + Label = "Spawner: longest respawn wait", + Property = "MaxDelay", + Types = SpawnerTypes, + Type = BridgeLeases.LeaseType.Int, + Min = 0.0, + Max = 86400.0, + Seconds = true, + }, + new PropEntry + { + Key = "Spawner.Running", + Label = "Spawner: running", + Property = "Running", + Types = SpawnerTypes, + Type = BridgeLeases.LeaseType.Bool, + Min = 0.0, + Max = 0.0, + }, + }; + + // ---- the seasonal allowlist ---- + + /// + /// The seasonal events an event may hold, and the one it may not. + /// + /// **`TreasuresOfTokuno` is excluded, and its exclusion is the whole argument for §N10's + /// self-check made concrete.** `SeasonalEventEntry.IsActive()` special-cases it and reads + /// `TreasuresOfTokuno.DropEra` instead of `Status`, so setting its status writes a field + /// that nothing consults. The write succeeds, the value reads back, a compare-and-set + /// restore would pass — every mechanism in this file would report a working lease over a + /// capability that does nothing at all. That is the failure N10 names ("a capability that + /// lies"), and no runtime probe can catch this one, so it is caught by reading the source + /// and excluded here by name. + /// + /// The remaining eight are real, and six of them do MORE than flip a flag: + /// `OnStatusChange()` calls a `CheckEnabled()` that generates or removes world content + /// for Doom, Khaldun, Sorcerer's Dungeon, Krampus, Rising Tide and Fellowship. §G called + /// this toggle "small and safe"; it is safe, because ServUO does it to itself from a + /// staff gump, but it is not small, and an author scheduling one should be told so. The + /// label says it. + /// + private static readonly EventType[] SeasonalExcluded = + { + EventType.TreasuresOfTokuno, + }; + + /// The status values a seasonal lease may hold. `EventStatus` has exactly three. + public static readonly string[] SeasonalValues = { "Inactive", "Active", "Seasonal" }; + + public const string SeasonalKey = "Seasonal.Status"; + + // ---- what the catalog offers ---- + + /// Every targeted key this shard offers, in `lease.list` order. + public static IEnumerable Catalog() + { + for (int i = 0; i < Props.Length; i++) + { + var p = Props[i]; + + if (_dropped.Contains(p.Key)) + continue; + + yield return new BridgeLeases.Catalog + { + Key = p.Key, + Label = p.Label, + Kind = BridgeLeases.LeaseKind.ObjectProperty, + Type = p.Type, + Min = p.Min, + Max = p.Max, + Default = p.Type == BridgeLeases.LeaseType.Bool ? "true" : "0", + TargetLabel = "Which spawner", + }; + } + + if (!_dropped.Contains(SeasonalKey)) + { + yield return new BridgeLeases.Catalog + { + Key = SeasonalKey, + Label = "Seasonal event status", + Kind = BridgeLeases.LeaseKind.Seasonal, + Type = BridgeLeases.LeaseType.Text, + Default = "Inactive", + Values = SeasonalValues, + TargetLabel = "Which seasonal event", + }; + } + } + + /// The seasonal events an author may name, for the module's option source. + public static IEnumerable SeasonalTargets() + { + foreach (EventType type in Enum.GetValues(typeof(EventType))) + { + if (Array.IndexOf(SeasonalExcluded, type) >= 0) + continue; + + if (SeasonalEventSystem.GetEntry(type) == null) + continue; + + yield return type.ToString(); + } + } + + // ---- reading and writing ---- + + /// + /// Reads a targeted key, or answers null when the target cannot be resolved. + /// + /// **Null is "I could not find it", never a value**, and the caller turns it into a + /// refusal. A missing spawner answered as `0` would let a lease be taken over nothing, + /// record `0` as the baseline, and restore that baseline onto whatever object later + /// claimed the serial. + /// + public static string Read(BridgeLeases.Catalog entry, string target, out string why) + { + why = null; + + if (entry.Kind == BridgeLeases.LeaseKind.Seasonal) + { + var seasonal = SeasonalEntry(target, out why); + return seasonal == null ? null : seasonal.Status.ToString(); + } + + var prop = Lookup(entry.Key); + + if (prop == null) + { + why = "no lease is offered for key '" + entry.Key + "'"; + return null; + } + + object obj = Resolve(target, prop, out why); + + if (obj == null) + return null; + + var info = Info(obj.GetType(), prop, out why); + + if (info == null) + return null; + + var raw = info.GetValue(obj, null); + return Render(prop, raw); + } + + /// Writes a targeted key. Answers false with a reason rather than throwing. + public static bool Write(BridgeLeases.Catalog entry, string target, string canonical, out string why) + { + why = null; + + if (entry.Kind == BridgeLeases.LeaseKind.Seasonal) + { + var seasonal = SeasonalEntry(target, out why); + + if (seasonal == null) + return false; + + EventStatus status; + + if (!TryParseStatus(canonical, out status)) + { + why = "'" + canonical + "' is not one of " + String.Join(", ", SeasonalValues); + return false; + } + + // The setter fires `OnStatusChange()`, which for six of the eight generates or + // removes world content. That is ServUO's own behaviour from its own staff gump and + // is exactly what makes the toggle worth having; it is noted here so nobody reads + // this line as a field assignment. + seasonal.Status = status; + return true; + } + + var prop = Lookup(entry.Key); + + if (prop == null) + { + why = "no lease is offered for key '" + entry.Key + "'"; + return false; + } + + object obj = Resolve(target, prop, out why); + + if (obj == null) + return false; + + var info = Info(obj.GetType(), prop, out why); + + if (info == null) + return false; + + object value; + + if (!Parse(prop, canonical, out value, out why)) + return false; + + info.SetValue(obj, value, null); + return true; + } + + // ---- target resolution ---- + + /// + /// Finds the object a target names. + /// + /// **Two ways to name one, and both are needed.** A serial is what `[props` shows a GM + /// and what a rig can type; an `XmlSpawner.UniqueId` is what the shard's own + /// `Spawns/*.xml` carry, which is the only naming the website can offer from the atlas + /// without the shard being up. A dropdown built from serials is impossible — they are + /// assigned when the world is built, and nothing off-shard knows them. + /// + /// The UniqueId lookup is a scan of `World.Items`, and it stays a scan on purpose: it + /// runs once per lease apply, which is a rare, human-scheduled operation, and a cache + /// would be a second copy of the world to keep correct across `[add` and deletion. + /// + private static object Resolve(string target, PropEntry prop, out string why) + { + why = null; + + if (String.IsNullOrEmpty(target)) + { + why = "this lease needs a target"; + return null; + } + + Item item = null; + int serial; + + if (TryParseSerial(target, out serial)) + { + item = World.FindItem((Serial)serial); + + if (item == null) + { + why = "nothing on this shard has serial " + target; + return null; + } + } + else + { + item = World.Items.Values + .OfType() + .FirstOrDefault(s => String.Equals(s.UniqueId, target, StringComparison.OrdinalIgnoreCase)); + + if (item == null) + { + why = "no spawner on this shard carries the id '" + target + "'"; + return null; + } + } + + if (item.Deleted) + { + why = "that object has been deleted"; + return null; + } + + // **The allowlist is checked against the object's OWN type, not against what was + // asked for.** This is the sentence the whole plane rests on: a serial is a number a + // caller chooses, so the only thing standing between `Spawner.MaxCount` and any item on + // the shard is this check. + var name = item.GetType().FullName; + var ok = false; + + for (int i = 0; i < prop.Types.Length && !ok; i++) + { + // Assignable rather than equal, so a shard's own subclass of Spawner is leasable — + // an operator who derived from it has not changed what `MaxCount` means. + var declared = ScriptCompiler.FindTypeByFullName(prop.Types[i]); + ok = declared != null && declared.IsInstanceOfType(item); + } + + if (!ok) + { + why = String.Format("{0} is a {1}, and this lease applies to {2}", + target, name, String.Join(" or ", prop.Types)); + return null; + } + + return item; + } + + private static SeasonalEventEntry SeasonalEntry(string target, out string why) + { + why = null; + + if (String.IsNullOrEmpty(target)) + { + why = "this lease needs a target"; + return null; + } + + EventType type; + + try + { + type = (EventType)Enum.Parse(typeof(EventType), target, true); + } + catch + { + why = "'" + target + "' is not a seasonal event on this shard"; + return null; + } + + if (Array.IndexOf(SeasonalExcluded, type) >= 0) + { + why = target + " reads its own era rather than this status, so leasing it would do nothing"; + return null; + } + + var entry = SeasonalEventSystem.GetEntry(type); + + if (entry == null) + { + why = "this shard has no entry for " + target; + return null; + } + + return entry; + } + + // ---- the boot self-check (EVENTS.md N10) ---- + + private static readonly HashSet _dropped = new HashSet(StringComparer.Ordinal); + + /// + /// Drops any targeted key that cannot possibly work, and says so on the console. + /// + /// **It cannot be the config plane's check, and that is a property of the thing rather + /// than a shortcut.** A config key is probed by writing to it and reading it back, + /// because there is exactly one of it. A property has thousands of instances and no + /// canonical one; probing would mean picking somebody's spawner at boot and writing to + /// it. So what is verified here is everything that can be verified without touching the + /// world: the type still resolves, the property still exists on it, it is still public + /// and settable, it still carries `CommandProperty`, and its CLR type is still the one + /// this file knows how to render. That is precisely the failure N10 was written for — a + /// property that a later ServUO renamed or made read-only — and it catches it at boot + /// rather than at 3am inside an unattended run. + /// + public static void SelfCheck() + { + _dropped.Clear(); + + for (int i = 0; i < Props.Length; i++) + { + var prop = Props[i]; + string why; + + if (Verify(prop, out why)) + continue; + + _dropped.Add(prop.Key); + Console.WriteLine("[Bridge] lease {0}: DROPPED from the catalog -- {1}", prop.Key, why); + } + + // The seasonal plane's own check is the one thing it can verify without writing: that + // this shard has entries at all. `SeasonalEventSystem.LoadEntries()` runs in + // `Configure()`, so an empty list here means an operator has removed the system rather + // than that the check ran too early. + if (!SeasonalTargets().Any()) + { + _dropped.Add(SeasonalKey); + Console.WriteLine("[Bridge] lease {0}: DROPPED from the catalog -- this shard has no seasonal events", SeasonalKey); + } + } + + private static bool Verify(PropEntry prop, out string why) + { + why = null; + var found = 0; + + for (int i = 0; i < prop.Types.Length; i++) + { + var type = ScriptCompiler.FindTypeByFullName(prop.Types[i]); + + if (type == null) + continue; + + string detail; + var info = Info(type, prop, out detail); + + if (info == null) + { + why = prop.Types[i] + ": " + detail; + return false; + } + + found++; + } + + if (found == 0) + { + why = "none of " + String.Join(", ", prop.Types) + " exists on this shard"; + return false; + } + + return true; + } + + /// + /// The property, if it is one this plane may touch. Null with a reason otherwise. + /// + /// `CommandProperty` is required and is not decoration: it is ServUO's own marker for + /// "a staff member may set this", so requiring it means this plane can never reach + /// further into an object than `[set` could — which is the bound §G draws, kept even + /// though the allowlist already makes it unreachable. + /// + private static PropertyInfo Info(Type type, PropEntry prop, out string why) + { + why = null; + + var info = type.GetProperty(prop.Property, BindingFlags.Public | BindingFlags.Instance); + + if (info == null) + { + why = "no property named " + prop.Property; + return null; + } + + if (!info.CanRead || !info.CanWrite) + { + why = prop.Property + " is not both readable and writable"; + return null; + } + + if (info.GetCustomAttributes(typeof(CommandPropertyAttribute), true).Length == 0) + { + why = prop.Property + " is not a CommandProperty"; + return null; + } + + if (!Matches(prop, info.PropertyType)) + { + why = prop.Property + " is a " + info.PropertyType.Name + ", which this lease cannot carry"; + return null; + } + + return info; + } + + private static bool Matches(PropEntry prop, Type clr) + { + if (prop.Seconds) + return clr == typeof(TimeSpan); + + switch (prop.Type) + { + case BridgeLeases.LeaseType.Int: return clr == typeof(int); + case BridgeLeases.LeaseType.Float: return clr == typeof(double); + case BridgeLeases.LeaseType.Bool: return clr == typeof(bool); + default: return clr == typeof(string); + } + } + + // ---- value rendering ---- + + private static string Render(PropEntry prop, object raw) + { + if (raw == null) + return ""; + + if (prop.Seconds) + return ((long)((TimeSpan)raw).TotalSeconds).ToString(CultureInfo.InvariantCulture); + + switch (prop.Type) + { + case BridgeLeases.LeaseType.Int: + return Convert.ToInt64(raw, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture); + + case BridgeLeases.LeaseType.Float: + return Convert.ToDouble(raw, CultureInfo.InvariantCulture).ToString("R", CultureInfo.InvariantCulture); + + case BridgeLeases.LeaseType.Bool: + return ((bool)raw) ? "true" : "false"; + + default: + return Convert.ToString(raw, CultureInfo.InvariantCulture); + } + } + + private static bool Parse(PropEntry prop, string canonical, out object value, out string why) + { + value = null; + why = null; + + if (prop.Seconds) + { + double seconds; + + if (!Double.TryParse(canonical, NumberStyles.Float, CultureInfo.InvariantCulture, out seconds)) + { + why = "'" + canonical + "' is not a number of seconds"; + return false; + } + + value = TimeSpan.FromSeconds(seconds); + return true; + } + + switch (prop.Type) + { + case BridgeLeases.LeaseType.Int: + { + double n; + + if (!Double.TryParse(canonical, NumberStyles.Float, CultureInfo.InvariantCulture, out n)) + { + why = "'" + canonical + "' is not a number"; + return false; + } + + value = (int)n; + return true; + } + + case BridgeLeases.LeaseType.Bool: + { + value = String.Equals(canonical, "true", StringComparison.OrdinalIgnoreCase) || canonical == "1"; + return true; + } + + default: + { + double d; + + if (!Double.TryParse(canonical, NumberStyles.Float, CultureInfo.InvariantCulture, out d)) + { + why = "'" + canonical + "' is not a number"; + return false; + } + + value = d; + return true; + } + } + } + + // ---- helpers ---- + + private static PropEntry Lookup(string key) + { + for (int i = 0; i < Props.Length; i++) + { + if (String.Equals(Props[i].Key, key, StringComparison.Ordinal)) + return Props[i]; + } + + return null; + } + + private static bool TryParseSerial(string raw, out int serial) + { + serial = 0; + + if (String.IsNullOrEmpty(raw)) + return false; + + if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + return Int32.TryParse(raw.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out serial); + } + + // A bare decimal is a serial too, but a UniqueId is a GUID and never all digits, so + // there is nothing to disambiguate. + return Int32.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out serial); + } + + private static bool TryParseStatus(string raw, out EventStatus status) + { + status = EventStatus.Inactive; + + for (int i = 0; i < SeasonalValues.Length; i++) + { + if (!String.Equals(SeasonalValues[i], raw, StringComparison.OrdinalIgnoreCase)) + continue; + + status = (EventStatus)Enum.Parse(typeof(EventStatus), SeasonalValues[i], false); + return true; + } + + return false; + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeLeases.cs b/overlay/Scripts/Custom/Bridge/BridgeLeases.cs new file mode 100644 index 0000000..d10effd --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeLeases.cs @@ -0,0 +1,1405 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; + +namespace Server.Custom.Bridge +{ + /// + /// Protocol 6 part b, extended by protocol 7 part b. The lease plane: a value the website + /// may hold for a bounded time, and which this shard puts back on its own when the + /// time is up. + /// + /// **Three planes now, and this file owns what is common to all of them** — the wire, the + /// deadline, the compare-and-set, the bookkeeping and the persistence rule. The config plane + /// is here because it is small and was first; the two TARGETED planes (a property on an + /// existing object, a seasonal event's status) live in , + /// because everything specific about them is specific to ServUO rather than to leasing. + /// + /// A targeted key names a capability over many things — `Spawner.MaxCount` is one lease over + /// thousands of spawners — so a hold is keyed by the key AND its target, and two runs may + /// hold the same key on two different spawners. The website composes its own ledger ref the + /// same way and for the same reason. + /// + /// EVENTS.md calls the lease the primitive underneath the whole event system, and the two + /// mechanisms it names are the whole of this file: + /// + /// 1. **Restore is compare-and-set, never a blind write.** Before writing the baseline + /// back, the current value must still equal what the event applied. If it does not, + /// somebody moved it deliberately: report `drifted`, leave the world alone, and let an + /// operator decide. Blindly restoring would silently revert a staff member's change, + /// which is the one failure that would make operators distrust the feature. + /// + /// 2. **The expiry lives here, not only in core.** The deadline comes down the wire and + /// this shard honours it whether or not the website is ever heard from again. Core + /// drives the normal restore; this is the backstop. That inversion is what makes an + /// unattended, scheduled world change defensible: the failure mode is a world back at + /// baseline early, never a world stuck changed indefinitely. + /// + /// ── What a CONFIG lease is made of, and why it alone is memory-only ──────────────────── + /// + /// Everything in this section is true of the config plane and **false of the other two**, + /// which is the single most important thing to know before changing this file. A config + /// value lives in memory, so a restart restores it for free; a spawner is in the world save + /// and a seasonal status is in `Saves/Misc/SeasonalEvents.bin`, so a restart preserves the + /// CHANGE and destroys only the timer that was going to undo it. That is why those two + /// planes' holds are written to `Saves/Bridge/Leases.bin` and this one's are not. + /// + /// `Server.Config` is a runtime key-value store. `Config.Set` mutates the in-memory entry + /// table; `Config.Load()` is guarded by `_Initialized` and so runs exactly once at boot, + /// which is what makes a Set survive every later Get. **Nothing here ever calls + /// `Config.Save()`**, and that is a decision rather than an omission (org lead, 2026-09-04): + /// a lease that never reaches disk means a shard restart is a *free* restore. It is the + /// strongest fail-safe available and it costs nothing, and it is also why `lease.list` + /// reports an empty hand after a restart, which is exactly what lets the website's + /// reconcile notice that the lease is gone. + /// + /// A pleasant consequence of `Config.Entry.Set`: restoring the baseline restores the entry's + /// ORIGINAL default marker too, because the entry compares against the value it was loaded + /// with. Restoring a key that was `@`-defaulted in a cfg file leaves it `@`-defaulted. + /// + /// ── The catalog is short on purpose, and shorter than EVENTS.md expected ─────────────── + /// + /// §D describes the 258 `Config.Get` call sites as splitting into two patterns — cached at + /// type initialisation (a lease does nothing) and read live (a lease takes effect at once). + /// Measured on ServUO 57.4 the split is not near even: of the 158 non-Bridge call sites in + /// `Scripts/`, roughly **eight** are live reads. A lease on any of the others applies + /// cleanly and changes nothing, which is the worst failure this feature has. + /// + /// So the catalog below is an allowlist of keys verified by reading the call site, never + /// "any config key", and Phase 11b ships exactly one. Phase 12 adds the rest along with the + /// boot-time self-check that drops a key from the advertised catalog if it does not take. + /// + /// ── Drift cannot happen by accident on a stock shard ─────────────────────────────────── + /// + /// `Config.Set` has exactly ONE caller in the whole of ServUO 57.4 + /// (`Server/ScriptCompiler.cs`, for `Compiler.Dynamic`). There is no in-game command, gump + /// or console path that writes a config key, so on a stock shard a GM cannot drift a config + /// lease even deliberately. The compare-and-set below is still correct and still required — + /// Phase 12's object-property leases are trivially driftable, and a shard with custom + /// scripts may well write config at runtime — but proving the `drifted` path needs the + /// scaffolding command in `tools/scaffolding/`, and this paragraph is why. + /// + public static class BridgeLeases + { + public enum LeaseType + { + Float, + Int, + Bool, + Text + } + + /// + /// Which plane a key lives on, and therefore where its value actually is. + /// + /// **The distinction is not cosmetic: it decides whether the hold is persisted.** A + /// `Config` value is memory-only, so a restart restores it for free and writing the + /// hold to disk would throw that away. An `ObjectProperty` is on an `Item` in the world + /// save and a `Seasonal` status is in `Saves/Misc/SeasonalEvents.bin`, so for both of + /// those a restart preserves the CHANGE and loses only the timer that would undo it — + /// which is why those two, and only those two, are written down. See + /// . + /// + public enum LeaseKind + { + Config, + ObjectProperty, + Seasonal + } + + /// One allowlisted key: what it is, what it holds, and what it is worth by default. + public sealed class Catalog + { + public string Key; + public string Label; + public LeaseKind Kind; + public LeaseType Type; + public double Min; + public double Max; + + /// + /// The closed set of values a `Text` key may hold, or null when it is free text. + /// + /// It exists because the seasonal status is a three-value enum and `Min`/`Max` bound + /// only the numeric types — so without it the sole check on that value would be the + /// `Enum.Parse` at the point of writing, which is a refusal arriving mid-run rather + /// than on the form. + /// + public string[] Values; + + /// + /// What the thing this key applies to is CALLED, or null when the key is a single + /// value. Non-null is what makes a key targeted, on the wire and in `lease.list`. + /// + public string TargetLabel; + + /// + /// The value the shard's own call site passes as its default, as text. + /// + /// It is carried rather than inferred because `Config.Get` cannot tell "absent" from + /// "absent, and here is what the caller would have used" — it just returns whatever + /// default it is handed. Reading a key with the WRONG default would make the + /// baseline a fiction, and restoring that fiction would leave the shard running on + /// a number no source file ever chose. + /// + public string Default; + } + + /// + /// Phase 11b's one proven key (org lead, 2026-09-04). + /// + /// `Scripts/Misc/CharacterCreation.cs` reads it live, inside the per-character creation + /// path, and divides by ten to get the per-skill cap. So it takes effect on the next + /// character created and is observable without a restart, which is what "proven" has to + /// mean here — the failure this catalog exists to prevent is a key that applies cleanly + /// and does nothing at all. + /// + private static readonly Catalog[] ConfigKeys = + { + new Catalog + { + Key = "PlayerCaps.SkillCap", + Label = "Starting skill cap", + Kind = LeaseKind.Config, + Type = LeaseType.Float, + Min = 1000.0, + Max = 1500.0, + Default = "1000", + }, + }; + + /// + /// Every key this shard offers, config plane first. + /// + /// Built per call rather than cached, because the targeted planes drop keys their boot + /// self-check failed and `[bridge reload` re-runs it — a cached array would keep serving + /// a capability the shard has just decided it does not have. + /// + private static IEnumerable Keys() + { + for (int i = 0; i < ConfigKeys.Length; i++) + { + if (!_droppedConfig.Contains(ConfigKeys[i].Key)) + yield return ConfigKeys[i]; + } + + foreach (var entry in BridgeLeaseTargets.Catalog()) + yield return entry; + } + + /// A lease this shard is holding, or has finished holding and not yet been asked about. + private sealed class Held + { + public string Key; + /// What the key applies to, or null for a single-value key. + public string Target; + public string Baseline; // canonical text, as read before the lease applied + public string Applied; // canonical text, as written + public long UntilMs; + public string RunId; + public Timer Deadline; + + // Set once the deadline has fired. The entry stays listed through the grace window so + // that teardown gets a definite verdict rather than finding nothing and having to guess + // whether the value came back or was never held. + public bool Expired; + public bool Restored; + public bool Drifted; + public string Current; // what was there instead, when drifted + public long ExpiredAtMs; + } + + /// + /// Held leases, keyed by — the key and its target together. + /// + /// Keyed by the pair rather than by the key alone, because `Spawner.MaxCount` is one + /// capability over thousands of spawners: keyed by the key, one run turning up one + /// spawner would have refused every other run every other spawner. The website's own + /// two-events-one-target index composes its ref exactly the same way and for exactly + /// the same reason. + /// + private static readonly Dictionary _held = + new Dictionary(StringComparer.Ordinal); + + /// Config keys the boot self-check dropped. See . + private static readonly HashSet _droppedConfig = new HashSet(StringComparer.Ordinal); + + private static Timer _prune; + + private static long _applied, _released, _drifted, _expired, _refused; + + /// + /// Attaches persistence. Runs before `World.Load()`, which is when `EventSink.WorldLoad` + /// fires, so this cannot be deferred to Initialize — 11b's rule, unchanged. + /// + [CallPriority(900)] + public static void Configure() + { + EventSink.WorldSave += OnWorldSave; + EventSink.WorldLoad += OnWorldLoad; + } + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("lease.apply", OnApply); + BridgeBoot.RegisterHandler("lease.release", OnRelease); + BridgeBoot.RegisterHandler("lease.list", OnList); + + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + SelfCheck(); + RearmDeadlines(); + Rearm(); + } + + /// The pair a lease is held under: the key, and what it applies to. + private static string Slot(string key, string target) + { + return String.IsNullOrEmpty(target) ? key : key + "#" + target; + } + + /// Stops and recreates the prune timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + if (_prune != null) + { + _prune.Stop(); + _prune = null; + } + + _prune = Timer.DelayCall(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0), Prune); + } + + public static string Status() + { + return String.Format( + "leases(held={0} applied={1} released={2} drifted={3} expired={4} refused={5})", + _held.Count, _applied, _released, _drifted, _expired, _refused); + } + + // ---- lease.apply ---- + + /// + /// Takes a lease. `holdMs` is authoritative and `untilMs` is carried for display only. + /// + /// That split is deliberate. An absolute deadline computed on the website and honoured + /// on the shard is a deadline measured against two clocks; a shard whose clock is ten + /// minutes fast would restore a ten-minute lease the instant it took it. A duration is + /// immune, and the absolute time is still worth carrying so that `lease.list` and the + /// run console can say when the hold ends in terms the operator's own clock agrees with. + /// + private static void OnApply(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "apply")) + return; + + var entry = Lookup(BridgeJson.GetString(o, "key")); + + if (entry == null) + { + Err(reqId, "apply", "no lease is offered for key '" + BridgeJson.GetString(o, "key") + "'"); + return; + } + + string target; + string why; + + if (!Target(entry, o, out target, out why)) + { + Err(reqId, "apply", why); + return; + } + + string canonical; + + if (!Coerce(entry, BridgeJson.GetString(o, "value"), out canonical, out why)) + { + Err(reqId, "apply", why); + return; + } + + var holdMs = BridgeJson.GetLong(o, "holdMs", 0L); + var maxMs = (long)BridgeConfig.LeaseMaxDurationSec * 1000L; + + if (holdMs < 1L) + { + Err(reqId, "apply", "a lease needs a positive holdMs"); + return; + } + + // **Refused, never clamped.** A clamp would silently give the website a shorter lease + // than it believes it has, and the website is the half that schedules the restore; the + // two would then disagree about when the world comes back. The shard's ceiling exists + // precisely for the case where the website is wrong, and being loud about it is the + // whole value. + if (holdMs > maxMs) + { + Err(reqId, "apply", + String.Format(CultureInfo.InvariantCulture, + "this shard holds a lease for at most {0} seconds, and {1} were asked for", + BridgeConfig.LeaseMaxDurationSec, holdMs / 1000L)); + return; + } + + var slot = Slot(entry.Key, target); + + Held existing; + + if (_held.TryGetValue(slot, out existing) && !existing.Expired) + { + Err(reqId, "apply", + "'" + slot + "' is already leased" + + (existing.RunId == null ? "" : " by run " + existing.RunId)); + return; + } + + // A key whose previous lease expired is re-leasable, and the baseline is read fresh + // rather than inherited: whatever is true now is what this lease undertakes to restore. + var baseline = Read(entry, target, out why); + + // **A target that cannot be resolved refuses the lease rather than defaulting.** For a + // config key this cannot happen; for a spawner it happens the moment a serial names + // something that was deleted, and taking the lease anyway would record a fictional + // baseline and later write it onto whatever next held that serial. + if (baseline == null) + { + Err(reqId, "apply", why ?? "that target could not be read"); + return; + } + + var held = new Held + { + Key = entry.Key, + Target = target, + Baseline = baseline, + Applied = canonical, + UntilMs = BridgeJson.GetLong(o, "untilMs", BridgeJson.NowMs() + holdMs), + RunId = BridgeJson.GetString(o, "runId"), + }; + + if (!Write(entry, target, canonical, out why)) + { + Err(reqId, "apply", why ?? "that value could not be applied"); + return; + } + + held.Deadline = Timer.DelayCall(TimeSpan.FromMilliseconds(holdMs), () => OnDeadline(slot)); + _held[slot] = held; + _applied++; + + Console.WriteLine("[Bridge] lease {0}: {1} -> {2} for {3}s (run {4})", + slot, baseline, canonical, holdMs / 1000L, held.RunId ?? "-"); + + BridgeLink.Emit(BridgeJson.Begin("lease.applied") + .Str("key", entry.Key) + .Str("target", target) + .Str("label", entry.Label) + .Str("baseline", baseline) + .Str("applied", canonical) + .Num("untilMs", held.UntilMs) + .Str("runId", held.RunId) + .End()); + + var sb = BridgeJson.Begin("lease.ok"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", "apply") + .Str("key", entry.Key) + .Str("target", target) + .Str("baseline", baseline) + .Str("applied", canonical) + .Num("untilMs", held.UntilMs); + BridgeLink.Emit(sb.End()); + } + + /// + /// Reads and checks the target for a key, in both directions. + /// + /// A targeted key with no target and an untargeted key with one are both refusals, and + /// both are the caller's mistake rather than the world's — the same pair core refuses at + /// authoring time, checked again here because this is the half that is true when the + /// website is wrong. + /// + private static bool Target(Catalog entry, Dictionary o, out string target, out string why) + { + why = null; + target = BridgeJson.GetString(o, "target"); + + if (target != null) + target = target.Trim(); + + if (String.IsNullOrEmpty(target)) + target = null; + + if (entry.TargetLabel != null && target == null) + { + why = "'" + entry.Key + "' needs a target (" + entry.TargetLabel + ")"; + return false; + } + + if (entry.TargetLabel == null && target != null) + { + why = "'" + entry.Key + "' is a single value and takes no target"; + return false; + } + + if (target != null && target.Length > MaxTargetLength) + { + why = "that target is longer than this shard records (" + MaxTargetLength + " characters)"; + return false; + } + + return true; + } + + // ---- lease.release ---- + + /// + /// Gives a lease back, compare-and-set. + /// + /// `expected` is what the event applied and `baseline` is what to put back. Both come + /// from the website's ledger rather than from this shard's memory, so a release still + /// works across a sidecar reconnect — and so that a shard which has forgotten the lease + /// entirely (a restart) can answer honestly instead of refusing. + /// + private static void OnRelease(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "release")) + return; + + var entry = Lookup(BridgeJson.GetString(o, "key")); + + if (entry == null) + { + Err(reqId, "release", "no lease is offered for key '" + BridgeJson.GetString(o, "key") + "'"); + return; + } + + string target; + string why; + + if (!Target(entry, o, out target, out why)) + { + Err(reqId, "release", why); + return; + } + + var slot = Slot(entry.Key, target); + + Held held; + _held.TryGetValue(slot, out held); + + // The deadline already dealt with it, and it drifted. That verdict is the one thing + // teardown must not lose, so it is held here through the grace window and handed over + // now rather than being reported as an ordinary restore. + if (held != null && held.Expired && held.Drifted) + { + Drop(slot); + Drifted(reqId, entry.Key, target, held.Current); + return; + } + + // Either the deadline restored it, or this shard restarted and never had it. Both are + // "the value is back and nothing more is owed", which is a successful release: the + // fail-safe firing is not a failure. + // + // **On the targeted planes the second half of that is no longer true, which is why the + // hold is persisted.** A restart does not put a spawner back — it is in the world save. + // So a restarted shard reaches here only when the persisted hold was ALSO lost (an + // unsaved run), and it answers with what is actually there rather than asserting the + // baseline is back. + if (held == null || held.Expired) + { + Drop(slot); + _released++; + + var already = BridgeJson.Begin("lease.ok"); + if (reqId != null) already.Str("reqId", reqId); + already.Str("action", "release") + .Str("key", entry.Key) + .Str("target", target) + .Bool("released", true) + .Bool("alreadyRestored", true) + .Str("current", Read(entry, target, out why)); + BridgeLink.Emit(already.End()); + return; + } + + var expected = BridgeJson.GetString(o, "expected"); + var current = Read(entry, target, out why); + + // The target is gone — a spawner somebody deleted mid-run. Nothing to restore and + // nothing wrong: this is 12a's `gone` in the lease plane's vocabulary, and reporting it + // as a failure would leave a row unresolved forever over an object that no longer + // exists. + if (current == null) + { + Drop(slot); + _released++; + + var vanished = BridgeJson.Begin("lease.ok"); + if (reqId != null) vanished.Str("reqId", reqId); + vanished.Str("action", "release") + .Str("key", entry.Key) + .Str("target", target) + .Bool("released", true) + .Bool("alreadyRestored", true) + .Bool("targetGone", true) + .Str("reason", why); + BridgeLink.Emit(vanished.End()); + return; + } + + if (expected != null && !Same(entry, current, expected)) + { + // Somebody moved it. Stop honouring the deadline too: the value is no longer this + // lease's to restore, and a timer that fired later would revert the change that was + // just reported as somebody else's. + Drop(slot); + Drifted(reqId, entry.Key, target, current); + return; + } + + var baseline = BridgeJson.GetString(o, "baseline"); + + if (baseline == null) + baseline = held.Baseline; + + string canonical; + + if (!Coerce(entry, baseline, out canonical, out why)) + { + // The website handed back a baseline this key cannot hold. Refusing is right: the + // alternative is writing a value nothing has ever verified into a live shard. + Err(reqId, "release", "the baseline offered is not valid for this key: " + why); + return; + } + + if (!Write(entry, target, canonical, out why)) + { + Err(reqId, "release", why ?? "the baseline could not be written back"); + return; + } + + Drop(slot); + _released++; + + Console.WriteLine("[Bridge] lease {0}: restored to {1}", slot, canonical); + + var sb = BridgeJson.Begin("lease.ok"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", "release") + .Str("key", entry.Key) + .Str("target", target) + .Bool("released", true) + .Bool("alreadyRestored", false) + .Str("current", canonical); + BridgeLink.Emit(sb.End()); + } + + // ---- lease.list ---- + + /// + /// Every key this shard offers, with what it is worth right now and what is holding it. + /// + /// It answers two different questions with one frame on purpose. The website's lease + /// `read()` needs the current value before it applies anything; its `inForce()` needs to + /// know whether the shard still has a record of the hold. Splitting them into two verbs + /// would mean two round trips to answer one question about one key. + /// + /// **`held` means "this shard still has a record of the lease", not "the value is still + /// overridden".** A lease whose deadline has fired is `held` with `expired: true` until + /// teardown collects its verdict, precisely so that reconcile does not report it gone + /// and have core write it off as orphaned when what actually happened was the backstop + /// working correctly. + /// + private static void OnList(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "list")) + return; + + // **One frame answers the catalog AND one entry**, because a targeted key has no + // single "current". `Spawner.MaxCount` is worth something different on every spawner, + // so a catalog walk cannot fill it in and the website's `read()` — which needs exactly + // one value, for exactly one target, before it applies anything — would have nothing to + // read. Naming a key and a target narrows the answer to that one row and fills it. + var onlyKey = BridgeJson.GetString(o, "key"); + var onlyTarget = BridgeJson.GetString(o, "target"); + + var sb = BridgeJson.Begin("lease.list.ok"); + if (reqId != null) sb.Str("reqId", reqId); + + sb.Append(",\"leases\":["); + + var first = true; + + foreach (var entry in Keys()) + { + if (onlyKey != null && !String.Equals(entry.Key, onlyKey, StringComparison.Ordinal)) + continue; + + if (!first) + sb.Append(','); + + first = false; + + var target = entry.TargetLabel == null ? null : onlyTarget; + + sb.Append("{\"key\":"); + BridgeJson.Text(sb, entry.Key); + sb.Append(",\"label\":"); + BridgeJson.Text(sb, entry.Label); + sb.Append(",\"kind\":\"").Append(KindName(entry.Kind)).Append('"'); + sb.Append(",\"type\":\"").Append(TypeName(entry.Type)).Append('"'); + sb.Append(",\"default\":"); + BridgeJson.Text(sb, entry.Default); + + if (entry.TargetLabel != null) + { + sb.Append(",\"targetLabel\":"); + BridgeJson.Text(sb, entry.TargetLabel); + } + + if (entry.Values != null) + { + sb.Append(",\"values\":["); + for (int v = 0; v < entry.Values.Length; v++) + { + if (v > 0) sb.Append(','); + BridgeJson.Text(sb, entry.Values[v]); + } + sb.Append(']'); + } + + // **`current` is present only when it MEANS something**, rather than defaulted to + // an empty string. A targeted key listed with no target has no current value, and + // sending `""` would make the website's `read()` record an empty baseline and later + // try to restore it. + if (entry.TargetLabel == null || target != null) + { + string why; + var current = Read(entry, target, out why); + + if (current != null) + { + sb.Append(",\"current\":"); + BridgeJson.Text(sb, current); + } + else if (why != null) + { + sb.Append(",\"unreadable\":"); + BridgeJson.Text(sb, why); + } + } + + if (target != null) + { + sb.Append(",\"target\":"); + BridgeJson.Text(sb, target); + } + + if (entry.Type == LeaseType.Float || entry.Type == LeaseType.Int) + { + sb.Append(",\"min\":").Append(entry.Min.ToString("R", CultureInfo.InvariantCulture)); + sb.Append(",\"max\":").Append(entry.Max.ToString("R", CultureInfo.InvariantCulture)); + } + + Held held; + + if (_held.TryGetValue(Slot(entry.Key, target), out held)) + { + sb.Append(",\"held\":true"); + sb.Append(",\"baseline\":"); + BridgeJson.Text(sb, held.Baseline); + sb.Append(",\"applied\":"); + BridgeJson.Text(sb, held.Applied); + sb.Append(",\"untilMs\":").Append(held.UntilMs); + sb.Append(",\"runId\":"); + BridgeJson.Text(sb, held.RunId); + sb.Append(",\"expired\":").Append(held.Expired ? "true" : "false"); + + if (held.Expired) + { + sb.Append(",\"restored\":").Append(held.Restored ? "true" : "false"); + sb.Append(",\"drifted\":").Append(held.Drifted ? "true" : "false"); + } + } + else + { + sb.Append(",\"held\":false"); + } + + sb.Append('}'); + } + + sb.Append(']'); + + // Every hold this shard is carrying, whatever key or target it is on. `lease.list` with + // no arguments could enumerate the catalog but never the HOLDS on a targeted key — + // there is no list of spawners to walk — so a reconcile after an outage would have no + // way to ask "what are you still holding?". This is that list. + sb.Append(",\"holds\":["); + + var firstHold = true; + + foreach (var kv in _held) + { + if (!firstHold) + sb.Append(','); + + firstHold = false; + + var h = kv.Value; + + sb.Append("{\"key\":"); + BridgeJson.Text(sb, h.Key); + sb.Append(",\"target\":"); + BridgeJson.Text(sb, h.Target); + sb.Append(",\"runId\":"); + BridgeJson.Text(sb, h.RunId); + sb.Append(",\"baseline\":"); + BridgeJson.Text(sb, h.Baseline); + sb.Append(",\"applied\":"); + BridgeJson.Text(sb, h.Applied); + sb.Append(",\"untilMs\":").Append(h.UntilMs); + sb.Append(",\"expired\":").Append(h.Expired ? "true" : "false"); + sb.Append(",\"restored\":").Append(h.Restored ? "true" : "false"); + sb.Append(",\"drifted\":").Append(h.Drifted ? "true" : "false"); + sb.Append('}'); + } + + sb.Append(']'); + BridgeLink.Emit(sb.End()); + } + + // ---- the deadline ---- + + /// + /// The backstop. Runs on the Core thread whether or not the website still exists, which + /// is the entire point of the lease framing: the undo is the default and holding is the + /// exception, so nothing has to be alive for the world to come back. + /// + private static void OnDeadline(string slot) + { + Held held; + + if (!_held.TryGetValue(slot, out held) || held.Expired) + return; + + var entry = Lookup(held.Key); + + if (entry == null) + return; + + held.Deadline = null; + held.Expired = true; + held.ExpiredAtMs = BridgeJson.NowMs(); + _expired++; + + string why; + var current = Read(entry, held.Target, out why); + + if (current == null) + { + // The target is gone. Nothing to restore and nothing drifted: the object this lease + // was borrowing no longer exists, which is a clean end rather than a failure. + held.Restored = true; + + Console.WriteLine("[Bridge] lease {0}: deadline passed and the target is gone -- {1}", slot, why); + } + else if (!Same(entry, current, held.Applied)) + { + held.Drifted = true; + held.Current = current; + _drifted++; + + Console.WriteLine("[Bridge] lease {0}: deadline passed but the value is now {1}, not {2}; NOT restoring", + slot, current, held.Applied); + } + else if (Write(entry, held.Target, held.Baseline, out why)) + { + held.Restored = true; + + Console.WriteLine("[Bridge] lease {0}: deadline passed, restored to {1} without being asked", + slot, held.Baseline); + } + else + { + // The backstop could not write. Not drift — nobody moved it — so it is reported as + // neither restored nor drifted, and the row stays for teardown to collect. Silence + // here would be the one thing worse than the failure. + Console.WriteLine("[Bridge] lease {0}: deadline passed and the restore FAILED -- {1}", slot, why); + } + + BridgeLink.Emit(BridgeJson.Begin("lease.expired") + .Str("key", held.Key) + .Str("target", held.Target) + .Str("runId", held.RunId) + .Str("baseline", held.Baseline) + .Str("applied", held.Applied) + .Bool("restored", held.Restored) + .Bool("drifted", held.Drifted) + .Str("current", held.Drifted ? held.Current : held.Baseline) + .End()); + } + + /// + /// Re-arms every persisted hold's deadline after a world load. + /// + /// **A deadline that passed while the shard was down fires at once.** The promise the + /// website was given is "back at baseline by then", and a shard that was off for the + /// whole hold has not kept it; restoring immediately is the only reading of that promise + /// still available. Extending it would silently turn a two-hour lease into however long + /// the outage was. + /// + private static void RearmDeadlines() + { + if (_held.Count == 0) + return; + + var now = BridgeJson.NowMs(); + var slots = new List(_held.Keys); + + for (int i = 0; i < slots.Count; i++) + { + var slot = slots[i]; + var held = _held[slot]; + + if (held.Expired || held.Deadline != null) + continue; + + var remaining = held.UntilMs - now; + var captured = slot; + + held.Deadline = remaining <= 0L + ? Timer.DelayCall(TimeSpan.Zero, () => OnDeadline(captured)) + : Timer.DelayCall(TimeSpan.FromMilliseconds(remaining), () => OnDeadline(captured)); + } + + Console.WriteLine("[Bridge] leases: {0} hold(s) restored from the world save", _held.Count); + } + + /// + /// Drops expired entries once the grace window has passed. + /// + /// The window exists so teardown can still collect a verdict; the prune exists because a + /// run that is never torn down must not leave a row here for the life of the process. + /// Dropping a DRIFTED entry is worth a line in the console: it is the one case where the + /// shard is quietly forgetting something an operator was meant to look at. + /// + private static void Prune() + { + if (_held.Count == 0) + return; + + var cutoff = BridgeJson.NowMs() - (long)BridgeConfig.LeaseGraceSec * 1000L; + List drop = null; + + foreach (var kv in _held) + { + if (!kv.Value.Expired || kv.Value.ExpiredAtMs > cutoff) + continue; + + if (drop == null) + drop = new List(); + + drop.Add(kv.Key); + } + + if (drop == null) + return; + + for (int i = 0; i < drop.Count; i++) + { + Held held; + + if (_held.TryGetValue(drop[i], out held) && held.Drifted) + { + Console.WriteLine( + "[Bridge] lease {0}: dropping a DRIFTED record nobody collected; the world is still at {1}", + drop[i], held.Current); + } + + Drop(drop[i]); + } + } + + // ---- the config plane ---- + + private static Catalog Lookup(string key) + { + if (key == null) + return null; + + foreach (var entry in Keys()) + { + if (String.Equals(entry.Key, key, StringComparison.Ordinal)) + return entry; + } + + return null; + } + + /// + /// Reads a key through the same typed accessor the game does, and renders the answer as + /// canonical text. + /// + /// Text is the transport for every lease value in both directions, whatever the declared + /// type. JSON would otherwise decide for us: `1200` and `1200.0` are one number to a + /// parser and two strings to a diff, and a compare-and-set that compared formatted + /// numbers would report drift on a value nobody touched. Comparison is done by + /// , on parsed values, for exactly that reason. + /// + private static string Read(Catalog entry, string target, out string why) + { + why = null; + + if (entry.Kind != LeaseKind.Config) + return BridgeLeaseTargets.Read(entry, target, out why); + + return ReadConfig(entry); + } + + private static string ReadConfig(Catalog entry) + { + switch (entry.Type) + { + case LeaseType.Float: + return Config.Get(entry.Key, ParseDouble(entry.Default)) + .ToString("R", CultureInfo.InvariantCulture); + + case LeaseType.Int: + return Config.Get(entry.Key, (int)ParseDouble(entry.Default)) + .ToString(CultureInfo.InvariantCulture); + + case LeaseType.Bool: + return Config.Get(entry.Key, ParseBool(entry.Default)) ? "true" : "false"; + + default: + return Config.Get(entry.Key, entry.Default); + } + } + + private static bool Write(Catalog entry, string target, string canonical, out string why) + { + why = null; + + if (entry.Kind != LeaseKind.Config) + return BridgeLeaseTargets.Write(entry, target, canonical, out why); + + WriteConfig(entry, canonical); + return true; + } + + private static void WriteConfig(Catalog entry, string canonical) + { + switch (entry.Type) + { + case LeaseType.Float: + Config.Set(entry.Key, ParseDouble(canonical)); + break; + + case LeaseType.Int: + Config.Set(entry.Key, (int)ParseDouble(canonical)); + break; + + case LeaseType.Bool: + Config.Set(entry.Key, ParseBool(canonical)); + break; + + default: + Config.Set(entry.Key, canonical); + break; + } + + // Deliberately no Config.Save(). See the class header: a lease that never reaches disk + // makes a shard restart a free restore. + } + + /// Parses and range-checks a wire value, answering the canonical text for it. + private static bool Coerce(Catalog entry, string raw, out string canonical, out string why) + { + canonical = null; + why = null; + + if (raw == null) + { + why = "no value was given"; + return false; + } + + if (entry.Type == LeaseType.Bool) + { + var t = raw.Trim(); + + if (String.Equals(t, "true", StringComparison.OrdinalIgnoreCase) || t == "1") + canonical = "true"; + else if (String.Equals(t, "false", StringComparison.OrdinalIgnoreCase) || t == "0") + canonical = "false"; + else + { + why = "'" + raw + "' is not a yes or no value"; + return false; + } + + return true; + } + + if (entry.Type == LeaseType.Text) + { + // A closed set is checked case-insensitively and answered in the catalog's own + // spelling, so `active` and `Active` both work and what is stored as the baseline + // is always a value `Enum.Parse` will accept back. + if (entry.Values != null) + { + for (int i = 0; i < entry.Values.Length; i++) + { + if (!String.Equals(entry.Values[i], raw.Trim(), StringComparison.OrdinalIgnoreCase)) + continue; + + canonical = entry.Values[i]; + return true; + } + + why = "'" + raw + "' is not one of " + String.Join(", ", entry.Values); + return false; + } + + canonical = raw; + return true; + } + + double n; + + if (!Double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out n)) + { + why = "'" + raw + "' is not a number"; + return false; + } + + if (entry.Type == LeaseType.Int && n != Math.Floor(n)) + { + why = "'" + raw + "' is not a whole number"; + return false; + } + + // The shard's own range, checked even though core checks the module's declaration + // first. The two are the same numbers today and that is not the point: this one is the + // one that is true when the website is wrong. + if (n < entry.Min || n > entry.Max) + { + why = String.Format(CultureInfo.InvariantCulture, + "{0} accepts {1} to {2}, and '{3}' is outside that", + entry.Label, entry.Min, entry.Max, raw); + return false; + } + + canonical = entry.Type == LeaseType.Int + ? ((long)n).ToString(CultureInfo.InvariantCulture) + : n.ToString("R", CultureInfo.InvariantCulture); + + return true; + } + + /// + /// Compare-and-set's comparison, done on parsed values rather than on text. + /// + /// The two sides are formatted by two different runtimes — one of them a JavaScript + /// engine — and `1200` against `1200.0` is a difference only a string comparison can + /// see. Reporting that as drift would refuse to restore a value nobody had touched, + /// which is the failure mode of a safety check that is too eager: it leaves the world + /// changed and blames an innocent operator. + /// + private static bool Same(Catalog entry, string a, string b) + { + if (a == null || b == null) + return a == b; + + if (entry.Type == LeaseType.Text) + return String.Equals(a, b, StringComparison.Ordinal); + + if (entry.Type == LeaseType.Bool) + return ParseBool(a) == ParseBool(b); + + double x, y; + + if (!Double.TryParse(a, NumberStyles.Float, CultureInfo.InvariantCulture, out x) || + !Double.TryParse(b, NumberStyles.Float, CultureInfo.InvariantCulture, out y)) + return String.Equals(a, b, StringComparison.Ordinal); + + return x == y; + } + + private static double ParseDouble(string s) + { + double n; + return Double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out n) ? n : 0.0; + } + + private static bool ParseBool(string s) + { + return String.Equals(s, "true", StringComparison.OrdinalIgnoreCase) || s == "1"; + } + + private static string KindName(LeaseKind k) + { + switch (k) + { + case LeaseKind.ObjectProperty: return "property"; + case LeaseKind.Seasonal: return "seasonal"; + default: return "config"; + } + } + + private static string TypeName(LeaseType t) + { + switch (t) + { + case LeaseType.Float: return "float"; + case LeaseType.Int: return "int"; + case LeaseType.Bool: return "bool"; + default: return "string"; + } + } + + // ---- replies ---- + + 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) + { + _refused++; + + var sb = BridgeJson.Begin("lease.error"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", action).Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + + private static void Drifted(string reqId, string key, string target, string current) + { + _drifted++; + + Console.WriteLine("[Bridge] lease {0}: DRIFTED, the world is at {1} and was left alone", + Slot(key, target), current); + + var sb = BridgeJson.Begin("lease.drifted"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("key", key).Str("target", target).Str("current", current); + BridgeLink.Emit(sb.End()); + } + + private static void Drop(string slot) + { + Held held; + + if (_held.TryGetValue(slot, out held) && held.Deadline != null) + held.Deadline.Stop(); + + _held.Remove(slot); + } + + // ---- the boot self-check (EVENTS.md N10) ---- + + /// + /// Probes every config key, and drops the ones that do not take. + /// + /// §N10: *"a key that is live-read today can become `static readonly` in a later ServUO + /// release, and the failure is silent — the lease applies and nothing changes."* So each + /// key is written, read back and restored, all inside one synchronous call on the Core + /// thread. A key that does not read back what was just written is dropped from the + /// advertised catalog with a line on the console: better a capability that disappears + /// loudly than one that lies. + /// + /// **The probe value is inside the key's own declared range**, so a shard that somehow + /// observed the intermediate value would see a legal one — and the restore is the same + /// `Config.Set` the lease plane uses, so a key that cannot be restored fails the probe + /// rather than being left probed. + /// + /// The targeted planes cannot be probed this way and say so themselves; see + /// . + /// + public static void SelfCheck() + { + _droppedConfig.Clear(); + + for (int i = 0; i < ConfigKeys.Length; i++) + { + var entry = ConfigKeys[i]; + var before = ReadConfig(entry); + var probe = Probe(entry, before); + + if (probe == null) + { + // Nothing legal to write that differs from what is there. Not a failure: it + // means the range is a single value, and a key like that is leasable in the + // trivial sense and worth nothing. Left in the catalog rather than dropped, + // because the failure this check exists for is a key that does not TAKE. + continue; + } + + WriteConfig(entry, probe); + var readBack = ReadConfig(entry); + WriteConfig(entry, before); + + if (Same(entry, readBack, probe) && Same(entry, ReadConfig(entry), before)) + continue; + + _droppedConfig.Add(entry.Key); + Console.WriteLine( + "[Bridge] lease {0}: DROPPED from the catalog -- wrote {1}, read back {2}", + entry.Key, probe, readBack); + } + + BridgeLeaseTargets.SelfCheck(); + } + + /// A legal value that differs from the current one, or null when there is none. + private static string Probe(Catalog entry, string current) + { + if (entry.Type == LeaseType.Bool) + return ParseBool(current) ? "false" : "true"; + + if (entry.Type == LeaseType.Text) + { + if (entry.Values == null) + return current == null ? "probe" : current + "-probe"; + + for (int i = 0; i < entry.Values.Length; i++) + { + if (!Same(entry, entry.Values[i], current)) + return entry.Values[i]; + } + + return null; + } + + if (entry.Min >= entry.Max) + return null; + + var low = entry.Type == LeaseType.Int + ? ((long)entry.Min).ToString(CultureInfo.InvariantCulture) + : entry.Min.ToString("R", CultureInfo.InvariantCulture); + var high = entry.Type == LeaseType.Int + ? ((long)entry.Max).ToString(CultureInfo.InvariantCulture) + : entry.Max.ToString("R", CultureInfo.InvariantCulture); + + return Same(entry, current, low) ? high : low; + } + + // ---- persistence ---- + + private static readonly string SavePath = Path.Combine("Saves/Bridge", "Leases.bin"); + + private const int SaveVersion = 1; + + /// The longest target this shard will record. Bounded so a save file cannot be grown by a caller. + private const int MaxTargetLength = 120; + + /// + /// Writes the holds whose VALUE survives a restart, and only those. + /// + /// A config lease is deliberately absent: it is memory-only, so a restart already + /// restores it and writing the hold down would replace a free, guaranteed restore with a + /// record of a lease over a value that is already back. 11b's header makes that + /// argument; this is the same argument, applied to the planes where its premise is + /// false. + /// + private static void OnWorldSave(WorldSaveEventArgs e) + { + Persistence.Serialize( + SavePath, + writer => + { + writer.Write(SaveVersion); + + var keep = new List(); + + foreach (var held in _held.Values) + { + var entry = Lookup(held.Key); + + if (entry == null || entry.Kind == LeaseKind.Config || held.Expired) + continue; + + keep.Add(held); + } + + writer.Write(keep.Count); + + for (int i = 0; i < keep.Count; i++) + { + var held = keep[i]; + + writer.Write(held.Key ?? ""); + writer.Write(held.Target ?? ""); + writer.Write(held.Baseline ?? ""); + writer.Write(held.Applied ?? ""); + writer.Write(held.UntilMs); + writer.Write(held.RunId ?? ""); + } + }); + } + + 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 held = new Held + { + Key = reader.ReadString(), + Target = reader.ReadString(), + Baseline = reader.ReadString(), + Applied = reader.ReadString(), + UntilMs = reader.ReadLong(), + RunId = reader.ReadString(), + }; + + if (String.IsNullOrEmpty(held.Target)) + held.Target = null; + + if (String.IsNullOrEmpty(held.RunId)) + held.RunId = null; + + if (!String.IsNullOrEmpty(held.Key)) + _held[Slot(held.Key, held.Target)] = held; + } + + // The deadlines are armed by `OnServerStarted`, not here: `Timer` is not running + // yet at world load, and a timer created now would never fire. + }); + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeLink.cs b/overlay/Scripts/Custom/Bridge/BridgeLink.cs index fe1f193..ec5161a 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeLink.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeLink.cs @@ -107,7 +107,17 @@ namespace Server.Custom.Bridge /// public static void Emit(string line) { - if (!_running || line == null) + if (line == null) + return; + + // Protocol 6. While a keyed command's handler runs — Core thread, one at a time — every + // line it emits is offered to the recent-key store so the correlated reply can be + // replayed to a retry later. Deliberately BEFORE the `_running` check: a reply the link + // was too dead to deliver is precisely the one a retry will come back for. + if (BridgeIdempotency.Capturing) + BridgeIdempotency.Observe(line); + + if (!_running) return; // Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over diff --git a/overlay/Scripts/Custom/Bridge/BridgeOneShots.cs b/overlay/Scripts/Custom/Bridge/BridgeOneShots.cs new file mode 100644 index 0000000..49e3a42 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeOneShots.cs @@ -0,0 +1,459 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +using Server.Items; +using Server.Misc; +using Server.Mobiles; + +namespace Server.Custom.Bridge +{ + /// + /// Protocol 7, part b. The two verbs that are neither owned nor borrowed: an item put into + /// someone's hands, and a world save. + /// + /// EVENTS_PLAN.md Phase 12b. Everything else the event plane does is a thing this shard can + /// take back — a creature it deletes, a value it restores. These two are not, and they are + /// in the same file because that is what they have in common: done is done. + /// + /// ── The grant, and why §8's exclusion of it was reopened separately ──────────────────── + /// + /// `ADMIN_CONTROLS.md` §8 cut item grants along with world creation, and §N1 reopened both — + /// deliberately as two reversals rather than one, because permitting an event to create a + /// creature says nothing about permitting it to hand out loot. What makes this grant a + /// different proposition from the one §8 refused is four properties it did not have then, + /// and all four are visible in this file: + /// + /// - **Declared, not typed.** The allowlist below is the shard's, and an item not on it + /// cannot be granted however the request is spelled. There is no free-text type name + /// reaching `Activator.CreateInstance` — that is `[add`, which §G excludes. + /// - **Bounded.** `EventsMaxGrantPerRun` bounds the whole run and + /// `EventsMaxGrantStack` bounds one hand; both refuse rather than clamp. + /// - **Attributable.** The run id rides on every grant and is logged with it. + /// - **Idempotent.** Protocol 6's key means a lost acknowledgement cannot double a + /// reward, which is the failure that made §G call the grant un-retryable when it was + /// written. It is retryable now, and 11a is the whole reason. + /// + /// ── Who receives it is answered HERE, and that is the interesting decision ───────────── + /// + /// A grant needs a list of people, and the website has one — `event_run_participants`. It + /// would have had to reach through core to get it, because a module cannot read core's + /// tables, so the alternative was a new core surface handing participants to a module's + /// `perform()`. + /// + /// It is not needed: **this shard already has the list**, in 11b's run-scoped participation + /// ledger, keyed by the same character serials the website's `member_key` holds. So the + /// grant names a run and the recipients are resolved from the ledger the run has been + /// keeping all along — no new core surface, no participant list crossing the wire twice, + /// and no window in which the two disagree. + /// + /// A run with no open ledger grants to nobody and says so, rather than granting to + /// everybody online. "Everyone present" is not a thing this file will guess at. + /// + /// ── The save ────────────────────────────────────────────────────────────────────────── + /// + /// `ADMIN_CONTROLS.md` §3.6 catalogued it Tier B and it was never built. It is useful as a + /// phase boundary — the point in an event after which what has happened is safe from a + /// crash — and `world.save.before` / `world.save.after` are already on the wire, so the + /// acknowledgement it needs exists. + /// + /// **A save stops the world**, so unlike every other verb here it is rate-limited by the + /// shard rather than only capped: `EventsMinSaveIntervalSec` refuses a save that comes too + /// soon after the last one, whether the last one was an event's or ServUO's own autosave. + /// Refuses, never queues — a queued save would arrive at a moment nobody chose. + /// + public static class BridgeOneShots + { + // ---- the grant allowlist ---- + + /// + /// One grantable item: what an author names it, and what this shard builds. + /// + /// **The list is short and boring on purpose.** Every entry is a thing an event + /// plausibly hands out and nothing here is equipment with rolled properties — an + /// artifact generator behind an unattended schedule is a different proposition and one + /// nobody has asked for. An operator who wants more edits this array, which is a + /// deployment they control rather than a field on a web form. + /// + private sealed class GrantEntry + { + public string Key; + public string Label; + public string Type; + } + + private static readonly GrantEntry[] Grants = + { + new GrantEntry { Key = "gold", Label = "Gold", Type = "Server.Items.Gold" }, + new GrantEntry { Key = "cloak", Label = "Cloak", Type = "Server.Items.Cloak" }, + new GrantEntry { Key = "sandals", Label = "Sandals", Type = "Server.Items.Sandals" }, + new GrantEntry { Key = "candle", Label = "Candle", Type = "Server.Items.Candle" }, + new GrantEntry { Key = "earrings", Label = "Silver earrings", Type = "Server.Items.SilverEarrings" }, + new GrantEntry { Key = "fireworks", Label = "Fireworks wand", Type = "Server.Items.FireworksWand" }, + new GrantEntry { Key = "bottle", Label = "Message in a bottle", Type = "Server.Items.MessageInABottle" }, + }; + + private static long _granted, _saves, _refused; + + private static long _lastSaveMs; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("item.grant", OnGrant); + BridgeBoot.RegisterHandler("item.catalog", OnCatalog); + BridgeBoot.RegisterHandler("world.save", OnSave); + + // Counted whoever asked for it, so the interval below also covers ServUO's own + // autosave. An event save landing thirty seconds after the hourly one is the same + // freeze twice, and the shard is the only half that can see both. + EventSink.WorldSave += (e) => { _lastSaveMs = BridgeJson.NowMs(); }; + } + + public static string Status() + { + return String.Format("oneshots(granted={0} saves={1} refused={2})", _granted, _saves, _refused); + } + + // ---- item.catalog ---- + + /// + /// What this shard is willing to grant. + /// + /// A read, so the website's option source can offer real choices — and the module holds + /// the same list, so the dropdown still works with the shard down. Two copies of a + /// short allowlist, exactly like the lease bounds: the module's is what makes a bad + /// value a refusal on a form, and this one is what is true when the website is wrong. + /// + private static void OnCatalog(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "catalog")) + return; + + var sb = BridgeJson.Begin("item.catalog.ok"); + if (reqId != null) sb.Str("reqId", reqId); + + sb.Append(",\"items\":["); + + for (int i = 0; i < Grants.Length; i++) + { + if (i > 0) + sb.Append(','); + + sb.Append("{\"key\":"); + BridgeJson.Text(sb, Grants[i].Key); + sb.Append(",\"label\":"); + BridgeJson.Text(sb, Grants[i].Label); + sb.Append(",\"stackable\":").Append(Stackable(Grants[i]) ? "true" : "false"); + sb.Append('}'); + } + + sb.Append(']'); + sb.Append(",\"maxPerRun\":").Append(BridgeConfig.EventsMaxGrantPerRun); + sb.Append(",\"maxStack\":").Append(BridgeConfig.EventsMaxGrantStack); + BridgeLink.Emit(sb.End()); + } + + // ---- item.grant ---- + + private static void OnGrant(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "grant")) + return; + + var runId = BridgeJson.GetString(o, "runId"); + + if (String.IsNullOrEmpty(runId)) + { + Err(reqId, "grant", "a grant needs a runId"); + return; + } + + var entry = LookupGrant(BridgeJson.GetString(o, "item")); + + if (entry == null) + { + Err(reqId, "grant", "this shard does not grant '" + BridgeJson.GetString(o, "item") + "'"); + return; + } + + var amount = (int)BridgeJson.GetLong(o, "amount", 1L); + + if (amount < 1) + { + Err(reqId, "grant", "a grant needs a positive amount"); + return; + } + + // Refused rather than clamped, on `LeaseMaxDurationSec`'s argument from 11b: the + // website is the half that records what was handed out, and a silent clamp would make + // its ledger a description of a grant that did not happen. + if (amount > BridgeConfig.EventsMaxGrantStack) + { + Err(reqId, "grant", String.Format(CultureInfo.InvariantCulture, + "this shard grants at most {0} at a time, and {1} were asked for", + BridgeConfig.EventsMaxGrantStack, amount)); + return; + } + + var toBank = String.Equals(BridgeJson.GetString(o, "where"), "bank", StringComparison.OrdinalIgnoreCase); + + var serials = BridgeParticipation.MemberSerials(runId); + + if (serials == null) + { + Err(reqId, "grant", "run " + runId + " has no participation ledger open on this shard"); + return; + } + + if (serials.Count == 0) + { + // Not a refusal: a run whose event nobody attended is a real outcome, and the + // website needs to record a grant that reached nobody rather than a failed step it + // will retry against the same empty ledger. + var none = BridgeJson.Begin("item.grant.ok"); + if (reqId != null) none.Str("reqId", reqId); + none.Str("runId", runId).Str("item", entry.Key); + none.Append(",\"granted\":0,\"missed\":[]"); + BridgeLink.Emit(none.End()); + return; + } + + if (serials.Count > BridgeConfig.EventsMaxGrantPerRun) + { + Err(reqId, "grant", String.Format(CultureInfo.InvariantCulture, + "that run has {0} participants and this shard grants to at most {1}", + serials.Count, BridgeConfig.EventsMaxGrantPerRun)); + return; + } + + var hue = (int)BridgeJson.GetLong(o, "hue", 0L); + var name = BridgeJson.GetString(o, "name"); + + if (name != null && name.Length > 40) + name = name.Substring(0, 40); + + var granted = 0; + var missed = new List(); + + for (int i = 0; i < serials.Count; i++) + { + var mobile = World.FindMobile((Serial)serials[i]) as PlayerMobile; + + if (mobile == null || mobile.Deleted) + { + missed.Add(Hex(serials[i]) + ": no such character"); + continue; + } + + string why; + + if (Give(mobile, entry, amount, hue, name, toBank, out why)) + granted++; + else + missed.Add(Hex(serials[i]) + ": " + why); + } + + _granted += granted; + + Console.WriteLine("[Bridge] grant {0} x{1} to run {2}: {3} of {4}", + entry.Key, amount, runId, granted, serials.Count); + + var sb = BridgeJson.Begin("item.grant.ok"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("runId", runId).Str("item", entry.Key); + sb.Append(",\"granted\":").Append(granted); + sb.Append(",\"missed\":["); + + for (int i = 0; i < missed.Count; i++) + { + if (i > 0) sb.Append(','); + BridgeJson.Text(sb, missed[i]); + } + + sb.Append(']'); + BridgeLink.Emit(sb.End()); + } + + /// + /// Builds and hands over one grant, or says why it could not. + /// + /// **A grant that cannot be delivered is deleted rather than dropped on the floor.** + /// `AddItem` failing on a full backpack would otherwise leave the item in the world at + /// (0,0) — a real ServUO trap — and an event that quietly littered the map with + /// undeliverable rewards would be worse than one that reported a miss. + /// + private static bool Give(PlayerMobile mobile, GrantEntry entry, int amount, int hue, string name, bool toBank, out string why) + { + why = null; + + var container = toBank ? (Container)mobile.BankBox : mobile.Backpack; + + if (container == null || container.Deleted) + { + why = toBank ? "no bank box" : "no backpack"; + return false; + } + + Item item; + + try + { + item = Build(entry); + } + catch (Exception e) + { + why = "could not be created (" + e.Message + ")"; + return false; + } + + if (item == null) + { + why = "could not be created"; + return false; + } + + if (item.Stackable) + { + item.Amount = amount; + } + else if (amount > 1) + { + // A non-stackable granted in quantity would be N items, and N items is N chances to + // overflow a backpack halfway through with no way to report which half landed. + // One is what an event means by "a commemorative cloak" anyway. + item.Delete(); + why = "is not stackable, so it can only be granted one at a time"; + return false; + } + + if (hue > 0) + item.Hue = hue; + + if (!String.IsNullOrEmpty(name)) + item.Name = name; + + if (!container.TryDropItem(mobile, item, false)) + { + item.Delete(); + why = toBank ? "bank box is full" : "backpack is full"; + return false; + } + + return true; + } + + private static Item Build(GrantEntry entry) + { + var type = ScriptCompiler.FindTypeByFullName(entry.Type); + + if (type == null) + return null; + + return Activator.CreateInstance(type) as Item; + } + + private static bool Stackable(GrantEntry entry) + { + Item probe = null; + + try + { + probe = Build(entry); + return probe != null && probe.Stackable; + } + catch + { + return false; + } + finally + { + if (probe != null) + probe.Delete(); + } + } + + // ---- world.save ---- + + private static void OnSave(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "save")) + return; + + var since = BridgeJson.NowMs() - _lastSaveMs; + var minimum = (long)BridgeConfig.EventsMinSaveIntervalSec * 1000L; + + if (_lastSaveMs > 0L && since < minimum) + { + // **Refused, not queued.** A queued save would land at a moment nobody chose, in the + // middle of whatever the next step is doing. Refusing tells the website exactly what + // happened, and a save skipped because one just happened has cost nothing. + Err(reqId, "save", String.Format(CultureInfo.InvariantCulture, + "this shard saves at most every {0} seconds, and the last save was {1} seconds ago", + BridgeConfig.EventsMinSaveIntervalSec, since / 1000L)); + return; + } + + // `world.save.before` and `world.save.after` are emitted by `BridgeEvents` from ServUO's + // own hooks, so the acknowledgement of what actually happened rides those rather than + // being asserted here. This reply says only that the save was STARTED. + _saves++; + AutoSave.Save(); + + var sb = BridgeJson.Begin("world.save.ok"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Bool("started", true); + BridgeLink.Emit(sb.End()); + } + + // ---- helpers ---- + + private static GrantEntry LookupGrant(string key) + { + if (key == null) + return null; + + for (int i = 0; i < Grants.Length; i++) + { + if (String.Equals(Grants[i].Key, key, StringComparison.OrdinalIgnoreCase)) + return Grants[i]; + } + + return null; + } + + private static string Hex(int serial) + { + return "0x" + serial.ToString("X", CultureInfo.InvariantCulture); + } + + 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) + { + _refused++; + + var sb = BridgeJson.Begin("oneshot.error"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", action).Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + } +} 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/BridgeParticipation.cs b/overlay/Scripts/Custom/Bridge/BridgeParticipation.cs new file mode 100644 index 0000000..f6a3dac --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeParticipation.cs @@ -0,0 +1,968 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +using Server.Mobiles; + +namespace Server.Custom.Bridge +{ + /// + /// Protocol 6, part b. The run-scoped participation ledger: who took part in an event, and + /// how much. + /// + /// EVENTS.md §G rates participation attribution as the largest remaining piece of new UO + /// work, and says why nothing composed out of the existing streams can stand in for it: + /// `region.enter` plus `mob.killed` is loosely composable and **not trustworthy enough to + /// publish results on**. Nothing scopes a kill or an arrival to a run, nothing separates a + /// passer-by from an attendee, and nothing survives a relog. Results and a leaderboard on + /// top of that would be a table of confident numbers that were not true. + /// + /// So participation is measured here, where the world is, and reported as one opaque number + /// per member. **The plugin computes the score; core stores a decimal it never interprets.** + /// That split is what keeps the event engine game-agnostic: "one minute present plus five a + /// kill" is a sentence about Ultima Online, and the sentence has to live on the Ultima + /// Online side of the seam. + /// + /// ── Keyed by character serial ────────────────────────────────────────────────────────── + /// + /// Which matches `module-uo`'s existing Teams `memberKey` (`teamProvider.model.js`), so one + /// module speaks one member vocabulary and a participant can be joined to a roster without a + /// translation table. A player who attends on two characters is two members, and that is the + /// same answer Teams already gives. + /// + /// ── Persisted in the world save, which is a first ────────────────────────────────────── + /// + /// Nothing in this bridge has ever persisted anything. A ledger has to, because a run spans + /// hours and a restart mid-event is an ordinary Tuesday: an in-memory tally would silently + /// regress every attendee's score to whatever they earned after the restart. The only ways + /// to paper over that from the other side are a high-water rule in core — which must stay + /// game-agnostic and cannot have one — or a per-run offset in the module, which is the same + /// bug with more moving parts. + /// + /// `Server.Persistence` plus `EventSink.WorldSave` writes a companion file beside the world + /// save rather than a persistence ITEM. No world object, no serial, nothing for a GM to find + /// and delete by accident, and a wipe of custom items leaves the ledger intact. + /// + /// **The save/load hooks are attached unconditionally**, before the enabled gate is + /// consulted. An operator who switches the plane off for an afternoon must not come back to + /// a truncated file where a run's tally used to be. + /// + /// ── The first handler that defers ────────────────────────────────────────────────────── + /// + /// `participation.snapshot` resolves every member serial to a mobile and an account, so a + /// well-attended run is hundreds of world lookups in one inbound call — exactly the kind of + /// work the Core thread must not be handed in one piece. Above + /// `Bridge.ParticipationSnapshotChunk` members it walks in chunks across ticks. + /// + /// That makes it the first handler in the bridge to complete AFTER its inbound call returns, + /// and therefore the first that can genuinely answer `bridge.busy` — protocol 6 built the + /// door in 11a with `BridgeIdempotency.Hold`/`Complete` and had nothing to walk through it. + /// + public static class BridgeParticipation + { + private static readonly string SavePath = Path.Combine("Saves", "Bridge", "Participation.bin"); + + private const int SaveVersion = 1; + + /// One character's part in one run. + private sealed class Member + { + public int Serial; + + /// + /// Last seen name, kept only so the console and the snapshot can say something + /// useful about a character that has since been deleted. The website resolves its + /// own names from the serial and never reads this. + /// + public string Name; + + /// + /// Accrued presence in SECONDS, not in sample counts. + /// + /// A sample count would have to be multiplied by the sweep interval to mean + /// anything, and the interval is a config key an operator may change halfway + /// through a five-hour run — which would silently rewrite the first half of the + /// tally. Accruing the interval as it is actually used makes history immutable. + /// + public long Seconds; + + public int Kills; + public long FirstMs; + public long LastMs; + } + + /// One run's declared area and its members. + private sealed class Run + { + public string RunId; + public string MapName; + public int MapIndex; + public int X; + public int Y; + public int Radius; + + public long OpenedMs; + public long UntilMs; + public long ClosedMs; + public bool Closed; + + /// + /// Frozen at open, for the same reason presence is accrued in seconds: a weight the + /// operator retunes mid-run must not retroactively re-score the kills that already + /// happened under the old one. + /// + public double KillWeight; + + /// Members the cap turned away. Reported, because a truncated tally that says so is usable and one that does not is a lie. + public long Refused; + + public Dictionary Members = new Dictionary(); + } + + private static readonly Dictionary _runs = new Dictionary(StringComparer.Ordinal); + + private static Timer _timer; + + private static long _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused; + + /// + /// Attaches persistence. Runs before `World.Load()`, which is when `EventSink.WorldLoad` + /// fires, so this cannot be deferred to Initialize. + /// + [CallPriority(900)] + public static void Configure() + { + EventSink.WorldSave += OnWorldSave; + EventSink.WorldLoad += OnWorldLoad; + } + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("participation.open", OnOpen); + BridgeBoot.RegisterHandler("participation.snapshot", OnSnapshot); + BridgeBoot.RegisterHandler("participation.close", OnClose); + + EventSink.CreatureDeath += OnCreatureDeath; + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + Rearm(); + } + + /// Stops and recreates the sweep timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + Stop(); + + _timer = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.ParticipationSweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.ParticipationSweepSeconds), + Sweep); + } + + public static void Stop() + { + if (_timer != null) + { + _timer.Stop(); + _timer = null; + } + } + + public static string Status() + { + int members = 0; + + foreach (var run in _runs.Values) + members += run.Members.Count; + + return String.Format( + "participation(runs={0} members={1} sweeps={2} opened={3} closed={4} snapshots={5} kills={6} deferred={7} refused={8})", + _runs.Count, members, _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused); + } + + /// + /// Every character serial this run has recorded, or null when the run is unknown here. + /// + /// Added by Phase 12b for the item grant, which needs a list of people and would + /// otherwise have had to reach through core for one — the website's + /// `event_run_participants` holds the same serials, but a module cannot read core's + /// tables and adding a core surface to hand them over would have been a second copy of + /// a list this shard has been keeping all along. + /// + /// **Null and empty are different answers.** Null is "no ledger is open for that run", + /// which is a refusal; empty is "the ledger is open and nobody came", which is a real + /// outcome a grant has to be able to report rather than retry. + /// + /// A closed run still answers: closing stops the counting, and a reward handed out + /// after the event has ended is the ordinary case rather than an edge one. + /// + public static List MemberSerials(string runId) + { + Run run; + + if (runId == null || !_runs.TryGetValue(runId, out run)) + return null; + + var serials = new List(run.Members.Count); + + foreach (var member in run.Members.Values) + serials.Add(member.Serial); + + return serials; + } + + // ---- participation.open ---- + + /// + /// Declares a run's area and starts counting. + /// + /// The area is a map, a point and a radius (org lead, 2026-09-04). Not a region name: + /// protocol 6's own live walk established that the most specific region containing an + /// event is routinely anonymous, so a region-named area would be undeclarable for + /// exactly the venues events use. Not a rectangle either — an author picks the spot the + /// event happens at, not two opposite corners of it. + /// + private static void OnOpen(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "open")) + return; + + var runId = BridgeJson.GetString(o, "runId"); + + if (String.IsNullOrEmpty(runId)) + { + Err(reqId, "open", "a run id is required"); + return; + } + + var mapName = BridgeJson.GetString(o, "map"); + var map = MapByName(mapName); + + if (map == null) + { + Err(reqId, "open", "unknown map '" + (mapName ?? "") + "'"); + return; + } + + var radius = BridgeJson.GetInt(o, "radius", 0); + + if (radius < 1 || radius > BridgeConfig.ParticipationMaxRadius) + { + Err(reqId, "open", + String.Format(CultureInfo.InvariantCulture, + "radius must be 1 to {0} tiles, and {1} was asked for", + BridgeConfig.ParticipationMaxRadius, radius)); + return; + } + + var x = BridgeJson.GetInt(o, "x", -1); + var y = BridgeJson.GetInt(o, "y", -1); + + if (x < 0 || y < 0) + { + Err(reqId, "open", "an area needs an x and a y"); + return; + } + + Run existing; + + if (_runs.TryGetValue(runId, out existing)) + { + // Re-opening the same area is the ordinary consequence of a step being re-authored + // or a run being resumed, and answering it as an error would fail a run for doing + // nothing. Re-opening a DIFFERENT area is an authoring mistake, and silently + // moving the venue mid-run would make the tally describe two places at once. + if (existing.MapIndex != map.MapIndex || existing.X != x || existing.Y != y || + existing.Radius != radius) + { + Err(reqId, "open", "run " + runId + " is already counting a different area"); + return; + } + + existing.Closed = false; + Ok(reqId, "open", existing); + return; + } + + if (_runs.Count >= BridgeConfig.ParticipationMaxRuns) + { + Err(reqId, "open", + String.Format(CultureInfo.InvariantCulture, + "this shard counts at most {0} runs at once", BridgeConfig.ParticipationMaxRuns)); + return; + } + + var holdMs = BridgeJson.GetLong(o, "holdMs", 0L); + var now = BridgeJson.NowMs(); + + var run = new Run + { + RunId = runId, + MapName = map.Name, + MapIndex = map.MapIndex, + X = x, + Y = y, + Radius = radius, + OpenedMs = now, + UntilMs = holdMs > 0L ? now + holdMs : 0L, + KillWeight = BridgeConfig.ParticipationKillWeight, + }; + + _runs[runId] = run; + _opened++; + + Console.WriteLine("[Bridge] participation: run {0} counting {1} tiles around {2} ({3}, {4})", + runId, radius, map.Name, x, y); + + Ok(reqId, "open", run); + } + + // ---- participation.close ---- + + /// + /// Stops counting. The tally stays readable through the grace window, because the run + /// that closes an event and the step that collects its results are two different steps + /// and either can be retried. + /// + private static void OnClose(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "close")) + return; + + var runId = BridgeJson.GetString(o, "runId"); + + Run run; + + if (runId == null || !_runs.TryGetValue(runId, out run)) + { + // Not an error. A close of a run this shard has already forgotten — a restart, a + // second teardown attempt — has the same meaning as one it honoured: nothing is + // being counted for that run any more. + var gone = BridgeJson.Begin("participation.ok"); + if (reqId != null) gone.Str("reqId", reqId); + gone.Str("action", "close").Str("runId", runId).Bool("closed", true).Bool("known", false); + BridgeLink.Emit(gone.End()); + return; + } + + if (!run.Closed) + { + // One last sweep before the books shut, so the people standing there when the event + // ended are credited for the interval they were standing there in. + SweepRun(run, BridgeConfig.ParticipationSweepSeconds); + + run.Closed = true; + run.ClosedMs = BridgeJson.NowMs(); + _closed++; + + Console.WriteLine("[Bridge] participation: run {0} closed with {1} member(s)", + run.RunId, run.Members.Count); + } + + Ok(reqId, "close", run); + } + + // ---- participation.snapshot ---- + + /// One snapshot in progress. See the class header for why this exists at all. + private sealed class Job + { + public string ReqId; + public string IdempotencyKey; + public Run Run; + public List Members; + public int Index; + public StringBuilder Sb; + + /// + /// Whether this job took the key out of the inbound call's hands. + /// + /// Recorded rather than re-derived from the chunk size, because the chunk size is a + /// config key an operator may change between the Hold and the Complete — and a + /// Complete that did not happen leaves every retry answered `bridge.busy` until the + /// store evicts the key an hour later. + /// + public bool Held; + } + + private static void OnSnapshot(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "snapshot")) + return; + + var runId = BridgeJson.GetString(o, "runId"); + + Run run; + + if (runId == null || !_runs.TryGetValue(runId, out run)) + { + Err(reqId, "snapshot", "this shard is not counting run '" + (runId ?? "") + "'"); + return; + } + + // **Copied, not iterated in place.** A sweep or a kill landing between two chunks would + // otherwise mutate the dictionary the walk is enumerating, and a snapshot is a + // point-in-time answer in any case: the run it describes is the run as it was when the + // question was asked. + var members = new List(run.Members.Values); + + var job = new Job + { + ReqId = reqId, + IdempotencyKey = BridgeJson.GetString(o, "idempotencyKey"), + Run = run, + Members = members, + Index = 0, + Sb = OpenSnapshot(reqId, run, members.Count), + }; + + _snapshots++; + + if (members.Count <= BridgeConfig.ParticipationSnapshotChunk) + { + // Small enough to answer in the inbound call. Deliberately NOT deferred anyway: the + // idempotency store captures a reply emitted inside the handler for free, and + // holding a key we did not need to hold would put an ordinary command through the + // in-flight path for no reason. + Step(job); + return; + } + + // Deferring. The key must be HELD before this call returns, or a repeat arriving while + // the walk is still running would be executed a second time rather than answered + // `bridge.busy` — which is the entire failure protocol 6 exists to prevent, and it is + // reachable for the first time right here. + if (job.IdempotencyKey != null) + { + BridgeIdempotency.Hold(job.IdempotencyKey); + job.Held = true; + } + + _deferred++; + Timer.DelayCall(TimeSpan.Zero, () => Step(job)); + } + + /// One chunk of a snapshot. Re-arms itself until the walk is done. + private static void Step(Job job) + { + try + { + var end = Math.Min(job.Index + BridgeConfig.ParticipationSnapshotChunk, job.Members.Count); + + for (; job.Index < end; job.Index++) + WriteMember(job.Sb, job.Run, job.Members[job.Index], job.Index > 0); + + if (job.Index < job.Members.Count) + { + Timer.DelayCall(TimeSpan.Zero, () => Step(job)); + return; + } + + job.Sb.Append(']'); + var line = job.Sb.End(); + + BridgeLink.Emit(line); + + // Only a HELD key needs completing. An inline snapshot was captured by the + // idempotency store on its way through Emit, and completing it twice would replace + // a correlated reply with one this method has no correlation information for. + if (job.Held) + BridgeIdempotency.Complete(job.IdempotencyKey, line); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] participation snapshot threw: {0}", ex.Message); + + // A held key whose walk died must still be closed out, or every retry of this step + // gets `bridge.busy` until the store's TTL evicts it an hour later. + if (job.Held) + { + var sb = BridgeJson.Begin("participation.error"); + if (job.ReqId != null) sb.Str("reqId", job.ReqId); + sb.Str("action", "snapshot").Str("reason", "the snapshot failed: " + ex.Message); + var line = sb.End(); + + BridgeLink.Emit(line); + BridgeIdempotency.Complete(job.IdempotencyKey, line); + } + } + } + + private static StringBuilder OpenSnapshot(string reqId, Run run, int count) + { + var sb = BridgeJson.Begin("participation.snapshot.ok"); + + if (reqId != null) + sb.Str("reqId", reqId); + + sb.Str("runId", run.RunId) + .Str("map", run.MapName) + .Num("x", run.X) + .Num("y", run.Y) + .Num("radius", run.Radius) + .Bool("closed", run.Closed) + .Num("openedMs", run.OpenedMs) + .Num("killWeight", run.KillWeight) + .Num("members", count) + .Num("refused", run.Refused); + + sb.Append(",\"participants\":["); + return sb; + } + + /// + /// One member, with the score this shard computed and the two components it came from. + /// + /// The components ride along because core stores the score opaquely and could never + /// explain it: a results table that can say "forty minutes and three kills" beside a + /// number is a table an operator can argue with, and one that shows only the number is + /// one they can only believe or not. + /// + private static void WriteMember(StringBuilder sb, Run run, Member member, bool comma) + { + if (comma) + sb.Append(','); + + var minutes = member.Seconds / 60.0; + var score = minutes + run.KillWeight * member.Kills; + + sb.Append("{\"serial\":\"0x").Append(((uint)member.Serial).ToString("X")).Append('"'); + + // Resolved now rather than at sweep time, and the mobile is looked up whether or not + // its owner is online: a character that took part and logged out is still in the world, + // so its account — and the linked website user with it — is still readable. + var mobile = World.FindMobile((Serial)member.Serial); + + sb.Append(",\"name\":"); + BridgeJson.Text(sb, mobile != null && !String.IsNullOrEmpty(mobile.Name) ? mobile.Name : member.Name); + + var acct = mobile == null ? null : mobile.Account as Accounting.Account; + + if (acct != null) + { + sb.Append(",\"acct\":"); + BridgeJson.Text(sb, acct.Username); + + var webId = BridgeAccountLink.WebIdFor(acct); + + if (webId != null) + { + sb.Append(",\"webId\":"); + BridgeJson.Text(sb, webId); + } + } + + sb.Append(",\"seconds\":").Append(member.Seconds); + sb.Append(",\"minutes\":").Append(minutes.ToString("F2", CultureInfo.InvariantCulture)); + sb.Append(",\"kills\":").Append(member.Kills); + sb.Append(",\"score\":").Append(score.ToString("F4", CultureInfo.InvariantCulture)); + sb.Append(",\"firstMs\":").Append(member.FirstMs); + sb.Append(",\"lastMs\":").Append(member.LastMs); + sb.Append('}'); + } + + // ---- counting ---- + + /// Runs one sweep now. Wired into `[bridge sweepnow`. + public static void SweepOnce() + { + Sweep(); + } + + private static void Sweep() + { + try + { + _sweeps++; + + if (_runs.Count == 0) + return; + + var seconds = BridgeConfig.ParticipationSweepSeconds; + var now = BridgeJson.NowMs(); + List expired = null; + + foreach (var run in _runs.Values) + { + if (run.Closed) + continue; + + // The run's own deadline, honoured here for the reason a lease's is honoured on + // the shard: a website that stopped talking must not leave this shard counting + // an event that ended days ago. + if (run.UntilMs > 0L && now >= run.UntilMs) + { + SweepRun(run, seconds); + run.Closed = true; + run.ClosedMs = now; + _closed++; + + Console.WriteLine("[Bridge] participation: run {0} passed its deadline and stopped counting", + run.RunId); + continue; + } + + SweepRun(run, seconds); + } + + var cutoff = now - (long)BridgeConfig.ParticipationGraceSec * 1000L; + + foreach (var run in _runs.Values) + { + if (!run.Closed || run.ClosedMs > cutoff) + continue; + + if (expired == null) + expired = new List(); + + expired.Add(run.RunId); + } + + if (expired == null) + return; + + for (int i = 0; i < expired.Count; i++) + { + Console.WriteLine("[Bridge] participation: forgetting run {0}, closed longer than the grace window", + expired[i]); + + _runs.Remove(expired[i]); + } + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] participation sweep threw: {0}", ex.Message); + } + } + + /// Credits every online player standing in one run's area with one interval. + private static void SweepRun(Run run, int seconds) + { + var map = Map.Maps[run.MapIndex]; + + if (map == null) + return; + + var now = BridgeJson.NowMs(); + + foreach (var m in World.Mobiles.Values) + { + var pm = m as PlayerMobile; + + if (pm == null || pm.NetState == null || pm.Deleted) + continue; + + if (!Inside(run, pm)) + continue; + + var member = Touch(run, pm, now); + + if (member == null) + continue; + + member.Seconds += seconds; + } + } + + /// + /// Kill credit, and it goes to every damager standing in the area rather than to the + /// killer alone. + /// + /// A last hit is a poor description of who fought something: the player who held it for + /// four minutes and died to it took part more than the one who happened to land the blow + /// that finished it. `Mobile.DamageEntries` is already populated and is readable here + /// because a `CreatureDeath` handler runs before the creature is disposed of — the same + /// fact protocol 6's damage table rests on. + /// + /// The presence check is applied to the DAMAGER, not only to the corpse. Someone + /// shooting into the venue from outside it is not attending the event, and someone who + /// fought there and has since walked away is no longer accruing anything either. + /// + private static void OnCreatureDeath(CreatureDeathEventArgs e) + { + try + { + if (_runs.Count == 0 || e == null || e.Creature == null) + return; + + var creature = e.Creature; + + if (creature.Player) + return; // a player death is not a kill anybody is credited for + + var now = BridgeJson.NowMs(); + + foreach (var run in _runs.Values) + { + if (run.Closed || !Inside(run, creature)) + continue; + + var entries = creature.DamageEntries; + + if (entries == null) + continue; + + // Summed into a set first: ServUO folds repeat damage into an existing entry, + // but an entry that expired and was re-created leaves two, and crediting per + // entry would pay a long fight twice. Expiry governs looting rights, not + // whether somebody was there. + var credited = new HashSet(); + + for (int i = 0; i < entries.Count; i++) + { + var de = entries[i]; + + if (de == null || de.Damager == null || de.Damager.Deleted || !de.Damager.Player) + continue; + + if (!credited.Add(de.Damager)) + continue; + + if (!Inside(run, de.Damager)) + continue; + + var member = Touch(run, de.Damager, now); + + if (member == null) + continue; + + member.Kills++; + _kills++; + } + } + } + catch (Exception ex) + { + // A death handler must never be the thing that breaks a death. + Console.WriteLine("[Bridge] participation kill credit threw: {0}", ex.Message); + } + } + + private static bool Inside(Run run, Mobile m) + { + if (m == null || m.Map == null || m.Map.MapIndex != run.MapIndex) + return false; + + // A circle, and squared so the check costs no square root. `Radius` is in tiles and the + // z axis is deliberately ignored: a venue is a place on the map, and a player one floor + // up in a tower over the square is at the event. + var dx = m.X - run.X; + var dy = m.Y - run.Y; + + return (dx * dx) + (dy * dy) <= run.Radius * run.Radius; + } + + /// + /// Finds or creates a member row, or answers null when the cap turned it away. + /// + /// The cap counts a refusal rather than swallowing it, and the count rides on every + /// snapshot: a truncated tally that says it is truncated is usable, and one that does + /// not is a leaderboard with people missing from it for no stated reason. + /// + private static Member Touch(Run run, Mobile m, long now) + { + var serial = m.Serial.Value; + + Member member; + + if (run.Members.TryGetValue((int)serial, out member)) + { + member.LastMs = now; + member.Name = m.Name ?? member.Name; + return member; + } + + if (run.Members.Count >= BridgeConfig.ParticipationMaxMembers) + { + run.Refused++; + _refused++; + return null; + } + + member = new Member + { + Serial = (int)serial, + Name = m.Name ?? "", + FirstMs = now, + LastMs = now, + }; + + run.Members[member.Serial] = member; + return member; + } + + // ---- persistence ---- + + private static void OnWorldSave(WorldSaveEventArgs e) + { + Persistence.Serialize( + SavePath, + writer => + { + writer.Write(SaveVersion); + writer.Write(_runs.Count); + + foreach (var run in _runs.Values) + { + writer.Write(run.RunId ?? ""); + writer.Write(run.MapName ?? ""); + writer.Write(run.MapIndex); + writer.Write(run.X); + writer.Write(run.Y); + writer.Write(run.Radius); + writer.Write(run.OpenedMs); + writer.Write(run.UntilMs); + writer.Write(run.ClosedMs); + writer.Write(run.Closed); + writer.Write(run.KillWeight); + writer.Write(run.Refused); + + writer.Write(run.Members.Count); + + foreach (var member in run.Members.Values) + { + writer.Write(member.Serial); + writer.Write(member.Name ?? ""); + writer.Write(member.Seconds); + writer.Write(member.Kills); + writer.Write(member.FirstMs); + writer.Write(member.LastMs); + } + } + }); + } + + private static void OnWorldLoad() + { + Persistence.Deserialize( + SavePath, + reader => + { + var version = reader.ReadInt(); + + if (version < 1) + return; + + var runs = reader.ReadInt(); + + for (int i = 0; i < runs; i++) + { + var run = new Run + { + RunId = reader.ReadString(), + MapName = reader.ReadString(), + MapIndex = reader.ReadInt(), + X = reader.ReadInt(), + Y = reader.ReadInt(), + Radius = reader.ReadInt(), + OpenedMs = reader.ReadLong(), + UntilMs = reader.ReadLong(), + ClosedMs = reader.ReadLong(), + Closed = reader.ReadBool(), + KillWeight = reader.ReadDouble(), + Refused = reader.ReadLong(), + }; + + var members = reader.ReadInt(); + + for (int j = 0; j < members; j++) + { + var member = new Member + { + Serial = reader.ReadInt(), + Name = reader.ReadString(), + Seconds = reader.ReadLong(), + Kills = reader.ReadInt(), + FirstMs = reader.ReadLong(), + LastMs = reader.ReadLong(), + }; + + run.Members[member.Serial] = member; + } + + if (!String.IsNullOrEmpty(run.RunId)) + _runs[run.RunId] = run; + } + + if (_runs.Count > 0) + Console.WriteLine("[Bridge] participation: {0} run(s) restored from the world save", _runs.Count); + }); + } + + // ---- helpers ---- + + 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 Ok(string reqId, string action, Run run) + { + var sb = BridgeJson.Begin("participation.ok"); + + if (reqId != null) + sb.Str("reqId", reqId); + + sb.Str("action", action) + .Str("runId", run.RunId) + .Str("map", run.MapName) + .Num("x", run.X) + .Num("y", run.Y) + .Num("radius", run.Radius) + .Bool("closed", run.Closed) + .Bool("known", true) + .Num("members", run.Members.Count) + .Num("refused", run.Refused) + .Num("untilMs", run.UntilMs); + + BridgeLink.Emit(sb.End()); + } + + private static void Err(string reqId, string action, string reason) + { + var sb = BridgeJson.Begin("participation.error"); + + if (reqId != null) + sb.Str("reqId", reqId); + + sb.Str("action", action).Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + } +} 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/BridgeParticipationProbe.cs b/tools/scaffolding/BridgeParticipationProbe.cs new file mode 100644 index 0000000..101d9b8 --- /dev/null +++ b/tools/scaffolding/BridgeParticipationProbe.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +using Server.Commands; +using Server.Mobiles; + +namespace Server.Custom +{ + /// + /// Produces real kill credit inside a participation area, without a game client. + /// + /// ── What this can drive, and what it cannot ─────────────────────────────────────────── + /// + /// The participation ledger counts two things: presence, and kill credit. Only one of them + /// is reachable from a headless rig, and the split is worth stating rather than discovering. + /// + /// **Presence needs a connected client.** The sweep credits online players — `NetState != + /// null` — which is the correct test and not one a probe should loosen: a character parked + /// in Britain and logged out for eight hours did not attend anything, and a ledger that said + /// otherwise would put people at the top of a leaderboard for being AFK. There is no way to + /// produce a NetState here short of writing a client, so presence accrual is exercised by a + /// real login and not by this file. + /// + /// **Kill credit needs none.** `EventSink.CreatureDeath` fires for a creature killed by any + /// means, `Mobile.DamageEntries` is populated by real damage, and the area test is a + /// coordinate comparison. So the whole of the credit path — the damager filter, the + /// per-damager fold, the area test applied to the DAMAGER rather than only the corpse, the + /// member cap — runs exactly as it would in a fight. + /// + /// What it does, in order: moves two real player mobiles to the venue, spawns a creature + /// there, damages it unequally from both, and kills it. + /// + /// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`. + /// In game: `[partprobe `. From a headless rig, through + /// `BridgeRigDriver`'s `partprobe` verb — the two ship together for that reason. + /// **Moves players and spawns and kills a creature. Rig only.** + /// + public static class BridgeParticipationProbe + { + public static void Initialize() + { + CommandSystem.Register("partprobe", AccessLevel.Administrator, Probe_OnCommand); + } + + [Usage("partprobe ")] + [Description("Moves two players to a point, spawns a creature there and kills it.")] + private static void Probe_OnCommand(CommandEventArgs e) + { + if (e.Length < 3) + { + Say(e.Mobile, "partprobe "); + return; + } + + Run(e.Mobile, e.GetString(0), e.GetInt32(1), e.GetInt32(2)); + } + + public static void Run(Mobile from, string mapName, int x, int y) + { + var map = MapByName(mapName); + + if (map == null) + { + Say(from, "partprobe: unknown map " + mapName); + return; + } + + var players = FindPlayers(2); + + if (players.Count < 2) + { + Say(from, "partprobe: need two player mobiles in the world; found " + players.Count); + return; + } + + var z = map.GetAverageZ(x, y); + + for (int i = 0; i < players.Count; i++) + { + // Spread them a tile apart so neither lands inside the other, and so the area test + // is answering about two distinct points rather than one. + players[i].MoveToWorld(new Point3D(x + i, y, z), map); + Say(from, String.Format(CultureInfo.InvariantCulture, + "partprobe: {0} moved to {1} ({2}, {3})", players[i].Name, map.Name, x + i, y)); + } + + var victim = new Mongbat(); + victim.MoveToWorld(new Point3D(x, y + 1, z), map); + + // Real damage through the real path, unequal so the fold is doing something: the + // ledger credits one kill per damager regardless of how much they did, and a table + // where both did the same amount could not show that. + // + // **Both amounts are small on purpose, and the first run of this probe is why.** A + // Mongbat has around thirty hit points, and an opening blow of 40 killed it where it + // stood -- so the SECOND damager never landed a hit, `DamageEntries` held one name, + // and the ledger correctly credited one player. The frame looked like a plugin bug + // crediting only the killer and was a rig artefact. A probe that means to produce two + // damagers has to leave the creature alive to receive the second one. + var hit = Math.Max(1, victim.HitsMax / 10); + victim.Damage(hit * 2, players[0]); + victim.Damage(hit, players[1]); + + Say(from, String.Format(CultureInfo.InvariantCulture, + "partprobe: {0} spawned at ({1}, {2}) and damaged by {3} and {4}", + victim.Name, x, y + 1, players[0].Name, players[1].Name)); + + // Killed on the next tick rather than inline, so the damage above has actually been + // registered against the creature before CreatureDeath reads the entries. + Timer.DelayCall(TimeSpan.FromSeconds(1.0), () => + { + victim.Kill(); + Say(from, "partprobe: killed; the credit should now be on the ledger"); + }); + } + + private static List FindPlayers(int count) + { + var found = new List(); + + foreach (var m in World.Mobiles.Values) + { + var pm = m as PlayerMobile; + + if (pm == null || pm.Deleted || pm.AccessLevel > AccessLevel.Player) + continue; + + found.Add(pm); + + if (found.Count >= count) + break; + } + + return found; + } + + private static Map MapByName(string name) + { + 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 void Say(Mobile to, string text) + { + if (to != null) + to.SendMessage(text); + else + Console.WriteLine("[PartProbe] " + text); + } + } +} diff --git a/tools/scaffolding/BridgeProtocol6Probe.cs b/tools/scaffolding/BridgeProtocol6Probe.cs new file mode 100644 index 0000000..b449d85 --- /dev/null +++ b/tools/scaffolding/BridgeProtocol6Probe.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Server.Commands; +using Server.Custom.Bridge; +using Server.Engines.CannedEvil; +using Server.Mobiles; + +namespace Server.Custom +{ + /// + /// Exercises the two halves of Protocol 6 on a live shard, without a game client. + /// + /// **Idempotency needs no probe.** It is driven from the OTHER end — two identical POSTs to + /// the sidecar, the second of which must come back `replayed: true` under its own reqId — so + /// a curl and the shard's own audit trail are the whole test. Nothing here would make that + /// more convincing. + /// + /// `champ.boss.killed` is the opposite case. It cannot be produced from outside the game at + /// all: a champion boss appears only when a spawn is driven to its final level, and the + /// damage table the frame carries is assembled by real combat against a real creature. A + /// fixture can assert the shape of the JSON; only this proves that + /// `EventSink.CreatureDeath` fires for a `BaseChampion`, that `DamageEntries` still holds + /// anything by the time it does, and that the sweep's spawn attribution is there to name the + /// altar. + /// + /// What it does, in order: + /// + /// 1. Places a real `ChampionSpawn`, activates it and calls `SpawnChampion()` — the + /// shard's own code path, not a hand-constructed creature. + /// 2. Waits for the champ sweep to see it, so the boss is attributed to its altar exactly + /// the way a real one would be. **This wait is the assertion**: run without it and the + /// kill still emits, but with no `serial`, `type` or `level` — which is the phase's own + /// documented fallback rather than the case being tested. + /// 3. Damages it from two real player mobiles found in the world, so the damage table has + /// two ranked entries rather than none. + /// 4. Kills it and cleans up the altar. + /// + /// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`. + /// In game / at the console: `[p6probe`. Flag: `Protocol6ProbeOnStart`. + /// **Spawns and kills a champion boss.** Use on a rig, never on a live shard. + /// + public static class BridgeProtocol6Probe + { + private static ChampionSpawn _spawn; + + public static void Initialize() + { + CommandSystem.Register("p6probe", AccessLevel.Administrator, Probe_OnCommand); + + if (Config.Get("Bridge.Protocol6ProbeOnStart", false)) + EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(10.0), () => Run(null)); + } + + [Usage("p6probe")] + [Description("Spawns a champion boss, damages it from two players and kills it.")] + private static void Probe_OnCommand(CommandEventArgs e) + { + Run(e == null ? null : e.Mobile); + } + + private static void Say(Mobile to, string text) + { + Console.WriteLine("[p6probe] {0}", text); + + if (to != null) + to.SendMessage(text); + } + + private static void Run(Mobile from) + { + try + { + // Inside a NAMED region, deliberately. A champion altar really lives in a dungeon + // and the first version of this probe put one there — but the dungeon floor at + // Destard belongs to the map's default region, whose Name is empty, so the emitted + // frame carried no `region` at all and the one field a phase condition is most + // likely to match on ("the boss in Yew") went unproven. Britain has a named region, + // so this exercises the field rather than the guard that omits it. + var where = new Point3D(1496, 1628, 10); + var map = Map.Felucca; + + Cleanup(); + + _spawn = new ChampionSpawn(); + _spawn.MoveToWorld(where, map); + _spawn.Type = ChampionSpawnType.Abyss; + _spawn.AutoRestart = false; + _spawn.Active = true; + + Say(from, "altar placed; spawning its champion"); + + _spawn.SpawnChampion(); + + var boss = _spawn.Champion; + + if (boss == null) + { + Say(from, "FAILED: the spawn produced no champion"); + Cleanup(); + return; + } + + Say(from, String.Format("champion up: {0} ({1}) serial {2} region {3}", + boss.Name, boss.GetType().Name, boss.Serial, + boss.Region == null ? "(none)" : ("\"" + boss.Region.Name + "\""))); + + // Give the sweep time to attribute the boss to its altar. Two intervals, because a + // single one races the timer that is already part-way through its period. + var wait = TimeSpan.FromSeconds(Math.Max(2.0, BridgeConfig.ChampSweepSeconds * 2.0)); + + Say(from, String.Format("waiting {0:0}s for the champ sweep to see it", wait.TotalSeconds)); + + Timer.DelayCall(wait, () => Finish(from, boss)); + } + catch (Exception ex) + { + Say(from, "threw: " + ex); + Cleanup(); + } + } + + private static void Finish(Mobile from, Mobile boss) + { + try + { + if (boss == null || boss.Deleted) + { + Say(from, "FAILED: the champion vanished before it could be killed"); + Cleanup(); + return; + } + + // Two real players, so the damage table has two ranked entries and the ranking is + // testable rather than trivially one row. Registered through Mobile.RegisterDamage, + // which is the same call combat makes. + var players = World.Mobiles.Values + .OfType() + .Where(p => !p.Deleted && p.Account != null) + .Take(2) + .ToList(); + + if (players.Count < 2) + { + Say(from, "note: fewer than two player mobiles in the world; the table will be short"); + } + + for (int i = 0; i < players.Count; i++) + { + // Deliberately unequal and deliberately in ascending order, so a frame that + // reported them in arrival order rather than by damage would be visibly wrong. + int amount = 120 * (i + 1); + boss.RegisterDamage(amount, players[i]); + Say(from, String.Format("registered {0} damage from {1}", amount, players[i].Name)); + } + + var killer = players.Count > 0 ? players[players.Count - 1] : null; + + Say(from, "killing the champion"); + + boss.Damage(boss.HitsMax * 10, killer); + + if (!boss.Deleted && boss.Alive) + { + Say(from, "note: it survived the blow; killing it outright"); + boss.Kill(); + } + + Timer.DelayCall(TimeSpan.FromSeconds(2.0), () => + { + Cleanup(); + Say(from, "done — check the sidecar feed for champ.boss.killed"); + }); + } + catch (Exception ex) + { + Say(from, "threw: " + ex); + Cleanup(); + } + } + + private static void Cleanup() + { + if (_spawn == null) + return; + + try + { + _spawn.Active = false; + _spawn.Delete(); + } + catch + { + // The altar is scaffolding; failing to tidy it is not worth an exception. + } + + _spawn = null; + } + } +} diff --git a/tools/scaffolding/BridgeRigDriver.cs b/tools/scaffolding/BridgeRigDriver.cs index 871456b..8bb61c1 100644 --- a/tools/scaffolding/BridgeRigDriver.cs +++ b/tools/scaffolding/BridgeRigDriver.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Reflection; using Server.Accounting; using Server.Commands; @@ -39,6 +40,14 @@ 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 + /// spawnerlist [n] name a few XmlSpawners, serial AND UniqueId -- + /// the two ways a property lease names its target + /// propset <target> <prop> <v> set a property BEHIND the lease plane's back, + /// which is the only way to reach `drifted` here + /// propread <target> <prop> read one back, to assert a restore landed + /// seasonlist every seasonal event and its status /// save a world save /// shutdown a CLEAN shutdown, so the bridge emits server.shutdown /// @@ -134,6 +143,28 @@ namespace Server.Custom case "election": Election(Arg(parts, 1)); break; case "activate": Activate(Arg(parts, 1)); break; case "password": Password(Arg(parts, 1), Arg(parts, 2)); break; + // Phase 11b. Plays the interfering GM a config lease's compare-and-set exists to + // catch, and reads a key back the way the game reads it. Both halves are here + // rather than only in `[leaseprobe` because a headless rig has no client to type + // a command at, and ServUO's own console takes a fixed verb set. + case "configset": ConfigSet(Arg(parts, 1), Arg(parts, 2)); break; + case "configread": ConfigRead(Arg(parts, 1)); break; + // Kill credit inside a participation area. Lives in BridgeParticipationProbe + // because it moves mobiles and spawns a creature; reachable from here because a + // headless rig has no client to type `[partprobe` at. The two files ship together. + 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 "spawnerlist": SpawnerList(Arg(parts, 1)); break; + case "propset": PropSet(Arg(parts, 1), Arg(parts, 2), Arg(parts, 3)); break; + case "propread": PropRead(Arg(parts, 1), Arg(parts, 2)); break; + case "seasonlist": SeasonList(); 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 @@ -144,11 +175,288 @@ 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"); + } + /// + /// Names a few spawners, with both ways of addressing one. + /// + /// A property lease is targeted by a serial or by an `XmlSpawner.UniqueId`, and the rig + /// has no other way to learn either — the website's dropdown comes from the atlas, and + /// the rig does not have one. + /// + private static void SpawnerList(string raw) + { + var want = 5; + + if (!String.IsNullOrEmpty(raw)) + Int32.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out want); + + if (want < 1) + want = 1; + + var shown = 0; + + foreach (var item in World.Items.Values) + { + if (shown >= want) + break; + + var xml = item as Mobiles.XmlSpawner; + + if (xml == null || xml.Deleted) + continue; + + Say(String.Format(CultureInfo.InvariantCulture, + "spawner 0x{0:X} uid={1} maxCount={2} running={3} name={4}", + item.Serial.Value, xml.UniqueId, xml.MaxCount, xml.Running, xml.Name ?? "-")); + + shown++; + } + + if (shown == 0) + Say("spawnerlist: this world has no XmlSpawners"); + } + + /// + /// Sets a property on an object BEHIND the lease plane's back. + /// + /// 11b's `configset` exists because `Config.Set` has one caller in the whole tree, so + /// nothing on a stock shard could drift a config lease. A spawner is the opposite — a GM + /// drifts one with `[props` in about four seconds — but the rig has no client, so it + /// needs the same door. This is the only way to reach `drifted` on a property lease + /// without one, and it is exactly what a staff member's `[set` does. + /// + private static void PropSet(string target, string property, string value) + { + if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property) || value == null) + { + Say("propset "); + return; + } + + Item item = null; + uint parsed; + var text = target.Trim(); + var isSerial = 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 (isSerial) + { + item = World.FindItem((Serial)unchecked((int)parsed)); + } + else + { + foreach (var candidate in World.Items.Values) + { + var xml = candidate as Mobiles.XmlSpawner; + + if (xml == null || !String.Equals(xml.UniqueId, text, StringComparison.OrdinalIgnoreCase)) + continue; + + item = xml; + break; + } + } + + if (item == null || item.Deleted) + { + Say("propset: nothing at " + text); + return; + } + + var info = item.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.Instance); + + if (info == null || !info.CanWrite) + { + Say("propset: " + item.GetType().Name + " has no writable " + property); + return; + } + + try + { + object typed; + + if (info.PropertyType == typeof(TimeSpan)) + typed = TimeSpan.FromSeconds(Double.Parse(value, CultureInfo.InvariantCulture)); + else if (info.PropertyType == typeof(bool)) + typed = String.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || value == "1"; + else + typed = Convert.ChangeType(value, info.PropertyType, CultureInfo.InvariantCulture); + + info.SetValue(item, typed, null); + Say("propset: " + property + " on " + text + " is now " + value + ", and nobody was told"); + } + catch (Exception e) + { + Say("propset: " + e.Message); + } + } + + /// Reads a property back, so the rig can assert a restore actually landed. + private static void PropRead(string target, string property) + { + if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property)) + { + Say("propread "); + return; + } + + Item item = null; + uint parsed; + var text = target.Trim(); + var isSerial = 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 (isSerial) + { + item = World.FindItem((Serial)unchecked((int)parsed)); + } + else + { + foreach (var candidate in World.Items.Values) + { + var xml = candidate as Mobiles.XmlSpawner; + + if (xml == null || !String.Equals(xml.UniqueId, text, StringComparison.OrdinalIgnoreCase)) + continue; + + item = xml; + break; + } + } + + if (item == null || item.Deleted) + { + Say("propread: nothing at " + text); + return; + } + + var info = item.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.Instance); + + if (info == null) + { + Say("propread: " + item.GetType().Name + " has no " + property); + return; + } + + var raw = info.GetValue(item, null); + Say("propread: " + property + " = " + Convert.ToString(raw, CultureInfo.InvariantCulture)); + } + + /// Says what the seasonal system holds, which is the seasonal lease's target list. + private static void SeasonList() + { + foreach (Engines.SeasonalEvents.EventType type in Enum.GetValues(typeof(Engines.SeasonalEvents.EventType))) + { + var entry = Engines.SeasonalEvents.SeasonalEventSystem.GetEntry(type); + + Say(entry == null + ? "season " + type + " = (no entry)" + : "season " + type + " = " + entry.Status); + } + } + + private static string Arg(string[] parts, int i) { return i < parts.Length ? parts[i] : null; } + private static int Int(string raw) + { + int n; + return Int32.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out n) ? n : 0; + } + + /// + /// Writes a live config key, so a lease's `drifted` verdict can be produced at all. + /// + /// **`Config.Set` has exactly ONE caller in the whole of ServUO 57.4** + /// (`Server/ScriptCompiler.cs`, for `Compiler.Dynamic`). No in-game command, gump or + /// console verb writes a config key, so on a stock shard a GM cannot drift a + /// configuration lease even deliberately -- and the one safety property a lease has + /// that nothing else does would go untested. Written through the same typed setter a + /// float lease uses, so what it produces is indistinguishable to the compare-and-set + /// from a real interfering write. + /// + /// Deliberately no `Config.Save()`, matching BridgeLeases: nothing about a rig should + /// leave a modified .cfg behind for the next boot to inherit. + /// + private static void ConfigSet(string key, string raw) + { + if (key == null || raw == null) + { + Say("configset "); + return; + } + + double n; + + if (Double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out n)) + Config.Set(key, n); + else + Config.Set(key, raw); + + Say("configset " + key + " = " + raw + " (in memory only)"); + } + + /// + /// Reads a key back through `Config.Get`, at a moment long after every type + /// initialiser has run. + /// + /// This is the check that tells a key which TOOK from one that only appeared to: a + /// lease on one of ServUO's ~150 cached call sites applies cleanly and does nothing, + /// which is the worst failure this feature has. + /// + private static void ConfigRead(string key) + { + if (key == null) + { + Say("configread "); + return; + } + + Say("configread " + key + " = " + Config.Get(key, Double.NaN).ToString("R", CultureInfo.InvariantCulture) + + " (double), \"" + Config.Get(key, "") + "\" (string)"); + } + // ---- houses ---- /// diff --git a/tools/scaffolding/README.md b/tools/scaffolding/README.md index e72b00c..ed1d281 100644 --- a/tools/scaffolding/README.md +++ b/tools/scaffolding/README.md @@ -14,8 +14,10 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo | `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. | | `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. | | `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. | -| `BridgeRigDriver.cs` | `Scripts/Custom/BridgeRigDriver.cs` | Drives the shard from OUTSIDE the game, one verb per line in `Config/rigcmd.txt`, which the driver polls and truncates. Written for the engagement Phase 11b acceptance walk, where each step's assertion is what happened BETWEEN two steps, so the steps have to be separated by the observer rather than by a hard-coded delay -- and ServUO's console takes a fixed verb set (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard at all. Verbs: `decaylist`, `decay`, `vendorlist`, `vendorfunds`, `citylist`, `governor`, `election`, `activate`, `password`, `save`, `shutdown`. Flag: `RigDriverEnabled`. **Sets passwords and mutates the world.** | +| `BridgeRigDriver.cs` | `Scripts/Custom/BridgeRigDriver.cs` | Drives the shard from OUTSIDE the game, one verb per line in `Config/rigcmd.txt`, which the driver polls and truncates. Written for the engagement Phase 11b acceptance walk, where each step's assertion is what happened BETWEEN two steps, so the steps have to be separated by the observer rather than by a hard-coded delay -- and ServUO's console takes a fixed verb set (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard at all. Verbs: `decaylist`, `decay`, `vendorlist`, `vendorfunds`, `citylist`, `governor`, `election`, `activate`, `password`, `configset`, `configread`, `partprobe`, `save`, `shutdown`. Flag: `RigDriverEnabled`. `configset` exists because **`Config.Get` is written by exactly ONE caller in the whole of ServUO 57.4** (`Server/ScriptCompiler.cs`): no in-game command, gump or console verb writes a config key, so on a stock shard a GM cannot drift a configuration lease even deliberately, and a lease's compare-and-set restore would have no way to be proved. `configread` reads a key back through `Config.Get` long after every type initialiser has run, which is how a key that TOOK is told from one that only appeared to. **Sets passwords, writes live config and mutates the world.** | | `BridgeProtocol5Probe.cs` | `Scripts/Custom/BridgeProtocol5Probe.cs` | Drives all three Protocol 5 enrichments so their frames can be observed: walks one house Fairly -> Greatly -> IDOC (the PAIR is the assertion -- `estimatedCollapse` must appear only on the IDOC frame), reports each player vendor's fee state straight off the `PlayerVendor` so the emitted `fees` block can be checked against the shard's own numbers, and fires `EventSink.AccountLogin`. Flags: `Protocol5ProbeOnStart`, `Protocol5ProbeAccount`, `Protocol5ProbePassword`. In game: `[p5probe`. **Sets a password on the named account.** | +| `BridgeProtocol6Probe.cs` | `Scripts/Custom/BridgeProtocol6Probe.cs` | Spawns a real champion boss through the shard's own `SpawnChampion()`, waits two champ sweeps so the boss is attributed to its altar, registers unequal damage from two seeded players and kills it -- so `champ.boss.killed` can be observed with a real damage table. **The wait is the assertion**: without it the kill still emits, but with no `serial`/`type`/`level`, which is the documented fallback rather than the case being tested. The altar is placed inside a NAMED region on purpose (see below). Flag: `Protocol6ProbeOnStart`. In game: `[p6probe`. **Spawns and kills a champion boss; rig only.** Protocol 6's other half, the idempotency key, needs no probe -- it is driven from outside with two identical POSTs to the sidecar. | +| `BridgeParticipationProbe.cs` | `Scripts/Custom/BridgeParticipationProbe.cs` | Produces real kill credit inside a participation area with no game client: moves two player mobiles to the venue, spawns a creature there, damages it unequally from both and kills it. **Presence is the half it cannot drive** -- the sweep credits players with a live `NetState`, which is the correct test and not one a probe should loosen, so presence accrual needs a real login. In game: `[partprobe `; from a headless rig, through `BridgeRigDriver`'s `partprobe` verb (the two ship together for that reason). **Moves players and spawns and kills a creature; rig only.** | ## Deploy overwrites Bridge.cfg @@ -170,3 +172,20 @@ what `BridgeRigDriver` and its `rigcmd.txt` are for. Also: only a CLEAN shutdown emits. `Stop-Process` drops the socket and the shard says nothing, so a killed shard is indistinguishable from a wedged one and `server.shutdown` never reaches the sidecar — use the driver's `shutdown` verb (`Core.Kill`) when the shutdown itself is what is being tested. + +## The innermost region has no name + +`BridgeProtocol6Probe` places its altar in the middle of **Britain** rather than at a dungeon altar, +and that is not cosmetic. An active `ChampionSpawn` registers a `ChampionSpawnRegion` over its own +spawn area, constructed with a **null name** and with the town region as its parent -- so the most +specific region containing a champion boss is the one region on the map guaranteed to be nameless. +`Mobile.Region` then hides that by falling back to the map's unnamed default region rather than to +null, and the emitted frame simply has no `region`. + +Region registration is also **deferred**, which is what makes this survive a first look: a lookup +taken immediately after the altar is placed answers `"Britain"`, and one taken at the kill twenty +seconds later does not. The probe prints the spawn-time read for exactly this reason -- it is the +value that lies, printed next to a frame that disagrees with it. + +Emitting from a named region is therefore the test. At a dungeon altar the field is legitimately +absent and the probe proves nothing about it.