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