Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeOracle.cs
wtclaude 0182732d63 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
2026-09-07 01:51:35 -05:00

294 lines
11 KiB
C#

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