Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeOneShots.cs
wtclaude f6a86ff8c2 feat(bridge): what an event borrows, and the two one-shots (Phase 12b)
The shard half of protocol 7 part b. Two lease planes whose value lives on
something already in the world, and two verbs that cannot be taken back.

A LEASE HERE MUST BE PERSISTED, AND THE CONFIG PLANE'S MUST NOT

11b's fail-safe is stated plainly in its own header: a lease that never reaches
disk means a shard restart is 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
and 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 back -- it puts the CHANGE back and throws away the
deadline timer that was going to undo it, leaving the world at the leased value
with nothing here remembering it is borrowed.

So the Bridge gains its THIRD save file, `Saves/Bridge/Leases.bin`, written by
the same `EventSink.WorldSave` that writes what it describes, with deadlines
re-armed at load. A deadline that passed while the shard was down fires AT ONCE:
the promise was "back at baseline by then", and extending it would silently turn
a two-hour lease into however long the outage was. Config holds are still not
written down -- the same argument, applied to planes where its premise is false.

A TARGET IS A SERIAL OR A UniqueId, AND BOTH ARE NEEDED

A serial is what `[props` shows a GM. An `XmlSpawner.UniqueId` is what the
shard's own `Spawns/*.xml` carry -- and it is not a convenience: a dropdown built
from serials is IMPOSSIBLE, because serials are assigned when the world is built
and nothing off-shard knows them. A lease addressable only by serial could have
no authoring list at all.

`Spawner` and `XmlSpawner` share all four property names, which is a fact about
this tree rather than a convenience: the spawn files load as XmlSpawners while
`[add spawner` makes the native one. And it is `MaxCount`, not the `Amount`
EVENTS_PLAN.md named -- there is no such property. `MinDelay`/`MaxDelay` are
TimeSpans, so the wire carries seconds.

The allowlist is checked against the object's OWN type, which is the sentence the
whole plane rests on: a serial is a number a caller chooses, so that check is all
that stands between `Spawner.MaxCount` and any item on the shard. Reflection is
bounded three ways -- the pair must be in the catalog, the property must carry
`CommandProperty` (so this can never reach further than `[set` could), and its
CLR type must be one this file renders.

THE SELF-CHECK, AND THE ONE FAILURE NO PROBE CAN CATCH

§N10 in full: a config key is probed live (write, read back, restore) because
there is exactly one of it. A property CANNOT be -- thousands of instances and no
canonical one, so probing would mean writing to somebody's spawner at boot. What
is verified instead is everything verifiable without touching the world.

And `TreasuresOfTokuno` is excluded by name, because `IsActive()` reads its own
`DropEra` rather than `Status`: the write succeeds, the value reads back, a
compare-and-set restore passes, and the capability does nothing at all. That is
N10's "capability that lies" in its purest form and the only way to find it is to
read the source. §G also called this toggle "small and safe" -- it is safe, but
`OnStatusChange()` generates or removes world content for six of the eight.

THE ONE-SHOTS

Who receives a grant is answered HERE. The website has the list too, but a module
cannot read core's tables, so the alternative was a new core surface handing
participants to a module. Not needed: 11b's participation ledger already holds
them, keyed by the same serials. A run with no ledger is a 404; a run whose
ledger is open and empty is a 200 with `granted: 0`, because an event nobody
attended still happened. An undeliverable grant is DELETED rather than dropped --
`AddItem` failing on a full backpack would otherwise leave it at (0,0).

A save stops the world, so it is rate-limited rather than capped, counting
ServUO's own autosave as the last one. Refused, never queued: a queued save would
land at a moment nobody chose.

VERIFIED

Compiles clean (0 warnings, 0 errors). Then a full walk on the real local ServUO
57.4 world against the release sidecar:

- all six catalog keys survive the boot self-check; `current` is correctly absent
  on the targeted rows and filled when a target is named;
- a spawner reads the same by UniqueId and by serial;
- TWO RUNS ON TWO SPAWNERS BOTH SUCCEED while a second run on the SAME spawner is
  refused -- the whole reason for the targeted ref;
- a GM edit behind the plane's back yields `lease.drifted` and the world is left
  at 55, not reverted; a clean release restores to baseline;
- ToT refused with its own reason, a bad status refused, Fellowship toggled;
- grant: no ledger 404, empty ledger 200 `granted: 0`, unknown item 400, over the
  stack bound 400; save 200 then 429 inside the interval;
- BOTH HOLDS AND BOTH LEASED VALUES SURVIVE save + clean shutdown + restart, the
  deadlines re-arm, and a release across the restart still compare-and-sets;
- with only a CONFIG lease held, `Leases.bin` is 8 bytes and names nothing;
- refusals: targeted-with-no-target, untargeted-with-a-target, out of range, over
  30 days, a target that is not there, and a `ChainChest` refused as a spawner;
- 90 seconds becomes `00:01:30` and the baseline reads back as 18000;
- a deleted target reads `unreadable` and releases `targetGone: true`.

The test world was never saved after the deliberate deletion, so it is intact.

Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:07:12 -05:00

460 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using Server.Items;
using Server.Misc;
using Server.Mobiles;
namespace Server.Custom.Bridge
{
/// <summary>
/// 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: <b>done is done</b>.
///
/// ── 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.
/// </summary>
public static class BridgeOneShots
{
// ---- the grant allowlist ----
/// <summary>
/// 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.
/// </summary>
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 ----
/// <summary>
/// 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.
/// </summary>
private static void OnCatalog(Dictionary<string, object> 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<string, object> 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<string>();
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());
}
/// <summary>
/// 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.
/// </summary>
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<string, object> 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());
}
}
}