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