Merge pull request 'feat(bridge): the world verbs an event owns (protocol 7, Phase 12a)' (#23) from feature/events-p12a-world-verbs into edge
Reviewed-on: #23
This commit is contained in:
@@ -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 ─────────────────────────────────────────────────────
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, object>` 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;
|
||||
|
||||
293
overlay/Scripts/Custom/Bridge/BridgeOracle.cs
Normal file
293
overlay/Scripts/Custom/Bridge/BridgeOracle.cs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1144
overlay/Scripts/Custom/Bridge/BridgeWorld.cs
Normal file
1144
overlay/Scripts/Custom/Bridge/BridgeWorld.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,8 @@ namespace Server.Custom
|
||||
/// activate <account> clear an account's inactivity, so its houses stop
|
||||
/// being Condemned and CAN be refreshed
|
||||
/// password <account> <pw> set a game account's password (for a login probe)
|
||||
/// worldgone <serial> 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;
|
||||
|
||||
Reference in New Issue
Block a user