feat(bridge): the world verbs an event owns (protocol 7, Phase 12a)

Five verbs an author sees -- creatures, an enhanced "boss", an oracle NPC, a
temporary gate, decoration -- and ONE command family underneath them, because
every one of them ends in the same sentence: an object exists, and this run
owns it. `world.spawn` / `world.despawn` / `world.owned` carry a `what`
discriminator, and the per-verb differences are fields rather than kinds.

The ownership registry is PERSISTED, and that is forced rather than chosen. A
spawned creature is in the world save, so it survives the restart that proves a
town-crier line gone -- which already rules out reconcile-by-boot-stamp. But the
record of which run owns which serial has nowhere else to live: in memory it is
lost in the restart the creatures survive, and only in the website's ledger it
is not held here at all, so `world.despawn` would delete whatever serial it was
handed and "never touches a creature it did not create" would have no mechanism
behind it. So the Bridge gains its second persisted file beside
`Participation.bin` -- written by the same world save as the objects it
describes, so the two cannot get out of step.

The oracle is ours rather than `XmlSpawner2.XmlDialog`'s, and that engine is the
reason for both halves of the decision. Its `SpeechEntry` is the evidence the
shape is right -- `Text` plus comma-separated `Keywords`, a keyword-less entry
as the greeting, a proximity range, a conversation lock. It is also why not to
build on it: `SpeechEntry` carries an `Action` string, XmlSpawner's
command-scripting language, which would leave an arbitrary-command field one
step from an event author. `Mobile.OnMovement` (delivered to every mobile in
range -- the `HandlesOnMovement` filter applies only to Items) and
`Mobile.HandlesOnSpeech`/`OnSpeech` are native virtuals and are all it needs.

Every `Bridge.EventsMax*` REFUSES rather than clamps, on `LeaseMaxDurationSec`'s
argument from 11b: the shard's bound exists for the case where the website is
wrong. `Bridge.EventsEnabled` gates all of it -- spawning is the same consent
11b introduced that switch for, not a third one.

Decoration carries an `itemId`, because `Static` accounts for 5031 of the tree's
decoration placements under 1992 different graphics: for that class the graphic
IS the identity. Never applied to a `BaseAddon`, whose own ItemID is not what a
player sees. Containers are refused outright -- teardown would delete whatever a
player had left inside.

`tools/scaffolding` gains `worldgone <serial>`, which deletes an object behind
the registry's back. It is the one outcome the rig cannot reach by asking the
bridge -- every bridge verb that removes an object also drops its row -- and it
is what a player's sword does every time they kill an event creature.

Verified on a real ServUO 57.4 world (206k items, 42k mobiles) against the
release sidecar: all five verbs place; every ceiling refuses; a container and an
unknown type refuse; one run cannot despawn another's object; the registry and
its objects both survive a save and a clean restart (`pruned: 0`); a creature
deleted behind the registry's back comes back `gone` rather than `removed`; and
a five-second gate is collected by the shard's own deadline with `world.expired`
on the wire.

Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-07 01:51:35 -05:00
parent b9a27a2de5
commit 0182732d63
8 changed files with 1651 additions and 1 deletions

View File

@@ -98,6 +98,26 @@ namespace Server.Custom.Bridge
public static int ParticipationGraceSec { get; private set; }
public static int ParticipationSnapshotChunk { get; private set; }
// The world verbs (protocol 7, EVENTS_PLAN.md Phase 12a). Each of these is the shard's
// OWN ceiling rather than a mirror of the module's budget dimension, and each REFUSES
// rather than clamps -- BridgeLeases' argument for LeaseMaxDurationSec, unchanged: the
// bound exists for the case where the website is wrong, and a quiet clamp would leave the
// two halves disagreeing about what was actually placed.
public static int EventsMaxCreatures { get; private set; }
public static int EventsMaxBosses { get; private set; }
public static int EventsMaxNpcs { get; private set; }
public static int EventsMaxDecor { get; private set; }
public static int EventsMaxGateMinutes { get; private set; }
public static int EventsMaxOwnedPerRun { get; private set; }
public static int EventsMaxSpread { get; private set; }
public static double EventsMaxBossMultiplier { get; private set; }
public static int EventsOracleMaxLines { get; private set; }
public static int EventsOracleGreetRange { get; private set; }
public static int EventsOracleSpeechRange { get; private set; }
public static int EventsOracleGreetCooldownSec { get; private set; }
public static int EventsOracleAnswerCooldownSec { get; private set; }
public static int EventsSweepSeconds { get; private set; }
// ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ----
public static SignupMode Signup { get; private set; }
public static bool AccountCreateEnabled { get; private set; }
@@ -310,6 +330,72 @@ namespace Server.Custom.Bridge
if (ParticipationSnapshotChunk < 1)
ParticipationSnapshotChunk = 1;
// The world verbs. PEC's published quotas are the defaults, because they are the only
// numbers anyone has ever defended in public: 30 creatures, a handful of bosses, five
// NPCs of five lines each, a four-hour gate. See EVENTS.md's PEC section.
EventsMaxCreatures = Config.Get("Bridge.EventsMaxCreatures", 30);
if (EventsMaxCreatures < 1)
EventsMaxCreatures = 1;
EventsMaxBosses = Config.Get("Bridge.EventsMaxBosses", 4);
if (EventsMaxBosses < 1)
EventsMaxBosses = 1;
EventsMaxNpcs = Config.Get("Bridge.EventsMaxNpcs", 5);
if (EventsMaxNpcs < 1)
EventsMaxNpcs = 1;
EventsMaxDecor = Config.Get("Bridge.EventsMaxDecor", 60);
if (EventsMaxDecor < 1)
EventsMaxDecor = 1;
EventsMaxGateMinutes = Config.Get("Bridge.EventsMaxGateMinutes", 240);
if (EventsMaxGateMinutes < 1)
EventsMaxGateMinutes = 1;
// The whole run, across every verb. The per-verb ceilings above bound one CALL; this
// bounds a run that calls a verb in a loop, which is the shape a runaway schedule
// actually takes.
EventsMaxOwnedPerRun = Config.Get("Bridge.EventsMaxOwnedPerRun", 200);
if (EventsMaxOwnedPerRun < 1)
EventsMaxOwnedPerRun = 1;
EventsMaxSpread = Config.Get("Bridge.EventsMaxSpread", 40);
if (EventsMaxSpread < 0)
EventsMaxSpread = 0;
// "An enhanced regular mob", per EVENTS.md's boss row -- so a ceiling low enough that
// the result is still recognisably the creature the author picked.
EventsMaxBossMultiplier = Config.Get("Bridge.EventsMaxBossMultiplier", 10.0);
if (EventsMaxBossMultiplier < 1.0)
EventsMaxBossMultiplier = 1.0;
EventsOracleMaxLines = Config.Get("Bridge.EventsOracleMaxLines", 5);
if (EventsOracleMaxLines < 1)
EventsOracleMaxLines = 1;
EventsOracleGreetRange = Config.Get("Bridge.EventsOracleGreetRange", 4);
if (EventsOracleGreetRange < 1)
EventsOracleGreetRange = 1;
EventsOracleSpeechRange = Config.Get("Bridge.EventsOracleSpeechRange", 8);
if (EventsOracleSpeechRange < 1)
EventsOracleSpeechRange = 1;
EventsOracleGreetCooldownSec = Config.Get("Bridge.EventsOracleGreetCooldownSec", 60);
if (EventsOracleGreetCooldownSec < 0)
EventsOracleGreetCooldownSec = 0;
EventsOracleAnswerCooldownSec = Config.Get("Bridge.EventsOracleAnswerCooldownSec", 5);
if (EventsOracleAnswerCooldownSec < 0)
EventsOracleAnswerCooldownSec = 0;
// How often expired gates are collected and dead ownership rows pruned. Gates are a
// minutes-scale deadline, so one slow sweep beats a timer per object.
EventsSweepSeconds = Config.Get("Bridge.EventsSweepSeconds", 30);
if (EventsSweepSeconds < 1)
EventsSweepSeconds = 1;
// Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
// unrecognized* value falls back to Game (the safest — no website creation), so a
// typo can never accidentally open provisioning.