feat(bridge): protocol 7 — the Event System's shard half (Phase 16b cutover, 1 of 6) #26

Merged
whitlocktech merged 10 commits from edge into main 2026-09-09 19:53:49 +00:00
8 changed files with 1651 additions and 1 deletions
Showing only changes of commit 54149fb481 - Show all commits

View File

@@ -24,7 +24,7 @@
# changes the emitters, exactly as link bumps PROTOCOL_VERSION.
#
# Current: 6 — see docs/link/v6.md (idempotency keys on inbound commands, champ.boss.killed).
protocol = 6
protocol = 7
# ── ServUO compatibility ─────────────────────────────────────────────────────
#

View File

@@ -239,6 +239,49 @@ ParticipationMaxRadius=300
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
# 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

View File

@@ -204,6 +204,7 @@ namespace Server.Custom.Bridge
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();
@@ -257,6 +258,7 @@ namespace Server.Custom.Bridge
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());
break;
}
}

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.

View File

@@ -484,6 +484,42 @@ namespace Server.Custom.Bridge
return result;
}
/// <summary>
/// Extracts a JSON array of OBJECTS, as a list of dictionaries.
///
/// `JavaScriptSerializer` already materializes a nested object as another
/// `Dictionary&lt;string, object&gt;` 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.
/// </summary>
public static List<Dictionary<string, object>> GetObjectList(
Dictionary<string, object> o, string key)
{
var result = new List<Dictionary<string, object>>();
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<string, object>;
if (row != null)
result.Add(row);
}
return result;
}
public static int GetInt(Dictionary<string, object> o, string key, int fallback)
{
object v;

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class BridgeOracle : Mobile
{
/// <summary>One row of the form: what a player has to say, and what it answers.</summary>
public sealed class Line
{
/// <summary>Lower-cased, already trimmed. Matched as substrings of what was said.</summary>
public string[] Keywords;
public string Text;
}
private string m_Greeting;
private List<Line> m_Lines;
/// <summary>
/// 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.
/// </summary>
private readonly Dictionary<Mobile, DateTime> m_Greeted = new Dictionary<Mobile, DateTime>();
private readonly Dictionary<Mobile, DateTime> m_Answered = new Dictionary<Mobile, DateTime>();
[CommandProperty(AccessLevel.GameMaster, true)]
public string Greeting { get { return m_Greeting; } set { m_Greeting = value; } }
public List<Line> Lines
{
get { return m_Lines ?? (m_Lines = new List<Line>()); }
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 ----
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private static bool Recent(Dictionary<Mobile, DateTime> seen, Mobile m, int cooldownSec)
{
var now = DateTime.UtcNow;
var cooldown = TimeSpan.FromSeconds(cooldownSec);
if (seen.Count > 64)
{
List<Mobile> stale = null;
foreach (var pair in seen)
{
if (pair.Key == null || pair.Key.Deleted || now - pair.Value > cooldown)
(stale ?? (stale = new List<Mobile>())).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<Line>();
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<Line>(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,
});
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,8 @@ namespace Server.Custom
/// activate &lt;account&gt; clear an account's inactivity, so its houses stop
/// being Condemned and CAN be refreshed
/// password &lt;account&gt; &lt;pw&gt; set a game account's password (for a login probe)
/// worldgone &lt;serial&gt; delete an object BEHIND the ownership registry's
/// back, playing the player who killed it
/// save a world save
/// shutdown a CLEAN shutdown, so the bridge emits server.shutdown
///
@@ -146,6 +148,12 @@ namespace Server.Custom
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 "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
@@ -156,6 +164,44 @@ namespace Server.Custom
}
}
/// <summary>
/// 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.
/// </summary>
private static void WorldGone(string raw)
{
if (String.IsNullOrEmpty(raw))
{
Say("worldgone <serial>");
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");
}
private static string Arg(string[] parts, int i)
{
return i < parts.Length ? parts[i] : null;