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
294 lines
11 KiB
C#
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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|