Files
servuo-plugins/tools/scaffolding/BridgeRigDriver.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

603 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Server.Accounting;
using Server.Commands;
using Server.Engines.CityLoyalty;
using Server.Mobiles;
using Server.Multis;
namespace Server.Custom
{
/// <summary>
/// Drives the shard from OUTSIDE the game, one verb per line in a file the driver polls.
///
/// Every other probe here runs a fixed script at boot or from `[command`, and both are the
/// wrong shape for an acceptance walk: a walk asserts what happened BETWEEN two steps
/// ("one mail, then nothing for a day"), so the steps have to be separated by the observer
/// rather than by a hard-coded delay -- and ServUO's console reads a fixed verb set
/// (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard
/// at all. A file is the one channel a headless shard already has.
///
/// Write one or more lines to `Config/rigcmd.txt`; the driver runs them on the Core thread
/// within a second, prints `[RigDriver]` lines, and TRUNCATES the file so the next write is
/// the next command. Output is console-only: nothing here emits, and everything observed
/// travels the real bridge.
///
/// Verbs:
/// decaylist houses that CAN decay, with owner account and stage
/// decay &lt;serial|any&gt; &lt;stage&gt; force a decay stage (LikeNew|Slightly|Somewhat|
/// Fairly|Greatly|IDOC|Collapsed)
/// vendorlist player vendors, with owner account and next pay time
/// vendorfunds &lt;serial&gt; &lt;gold&gt; set a vendor's held gold (drives periodsRemaining)
/// citylist cities, governors and election phases
/// governor &lt;city&gt; &lt;mobile|none&gt; seat a governor (a mobile serial, or a player's name)
/// election &lt;city&gt; force a new election into its nomination window
/// activate &lt;account&gt; clear an account's inactivity, so its houses stop
/// being Condemned and CAN be refreshed
/// password &lt;account&gt; &lt;pw&gt; set a game account's password (for a login probe)
/// worldgone &lt;serial&gt; 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
///
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
/// </summary>
public static class BridgeRigDriver
{
private static string _path;
private static DateTime _lastWrite = DateTime.MinValue;
public static void Initialize()
{
if (!Config.Get("Bridge.RigDriverEnabled", false))
return;
_path = Path.Combine(Core.BaseDirectory, "Config", "rigcmd.txt");
CommandSystem.Register("rigdriver", AccessLevel.Administrator, e => Poll());
Console.WriteLine("[RigDriver] watching {0}", _path);
Timer.DelayCall(TimeSpan.FromSeconds(2.0), TimeSpan.FromSeconds(1.0), Poll);
}
// ---- the poll ----
private static void Poll()
{
try
{
if (!File.Exists(_path))
return;
// Written-and-not-finished is a real case: the observer writes with a shell
// redirect while this timer fires. An empty file is nothing to do, and the
// timestamp guard keeps a slow write from being run twice.
var stamp = File.GetLastWriteTimeUtc(_path);
if (stamp <= _lastWrite)
return;
var lines = File.ReadAllLines(_path);
if (lines.Length == 0)
return;
_lastWrite = stamp;
File.WriteAllText(_path, String.Empty);
foreach (var line in lines)
{
var trimmed = (line ?? String.Empty).Trim();
if (trimmed.Length == 0 || trimmed.StartsWith("#"))
continue;
try
{
Run(trimmed);
}
catch (Exception ex)
{
Say("\"" + trimmed + "\" threw: " + ex.Message);
}
}
Say("done");
}
catch (IOException)
{
// The writer still holds it. Next tick.
}
catch (Exception ex)
{
Say("poll threw: " + ex.Message);
}
}
private static void Say(string line)
{
Console.WriteLine("[RigDriver] " + line);
}
private static void Run(string line)
{
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
var verb = parts[0].ToLowerInvariant();
switch (verb)
{
case "decaylist": DecayList(); break;
case "decay": Decay(Arg(parts, 1), Arg(parts, 2)); break;
case "vendorlist": VendorList(); break;
case "vendorfunds": VendorFunds(Arg(parts, 1), Arg(parts, 2)); break;
case "citylist": CityList(); break;
case "governor": Governor(Arg(parts, 1), Arg(parts, 2)); break;
case "election": Election(Arg(parts, 1)); break;
case "activate": Activate(Arg(parts, 1)); break;
case "password": Password(Arg(parts, 1), Arg(parts, 2)); break;
// Phase 11b. Plays the interfering GM a config lease's compare-and-set exists to
// catch, and reads a key back the way the game reads it. Both halves are here
// rather than only in `[leaseprobe` because a headless rig has no client to type
// a command at, and ServUO's own console takes a fixed verb set.
case "configset": ConfigSet(Arg(parts, 1), Arg(parts, 2)); break;
case "configread": ConfigRead(Arg(parts, 1)); break;
// Kill credit inside a participation area. Lives in BridgeParticipationProbe
// because it moves mobiles and spawns a creature; reachable from here because a
// headless rig has no client to type `[partprobe` at. The two files ship together.
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
// a wedged one -- and `uo.server.down` never fires. Core.Kill runs
// EventSink.Shutdown, which is what BridgeBoot listens on.
case "shutdown": Say("shutting down"); Timer.DelayCall(TimeSpan.Zero, () => Core.Kill(false)); break;
default: Say("unknown verb \"" + verb + "\""); break;
}
}
/// <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;
}
private static int Int(string raw)
{
int n;
return Int32.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out n) ? n : 0;
}
/// <summary>
/// Writes a live config key, so a lease's `drifted` verdict can be produced at all.
///
/// **`Config.Set` has exactly ONE caller in the whole of ServUO 57.4**
/// (`Server/ScriptCompiler.cs`, for `Compiler.Dynamic`). No in-game command, gump or
/// console verb writes a config key, so on a stock shard a GM cannot drift a
/// configuration lease even deliberately -- and the one safety property a lease has
/// that nothing else does would go untested. Written through the same typed setter a
/// float lease uses, so what it produces is indistinguishable to the compare-and-set
/// from a real interfering write.
///
/// Deliberately no `Config.Save()`, matching BridgeLeases: nothing about a rig should
/// leave a modified .cfg behind for the next boot to inherit.
/// </summary>
private static void ConfigSet(string key, string raw)
{
if (key == null || raw == null)
{
Say("configset <key> <value>");
return;
}
double n;
if (Double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out n))
Config.Set(key, n);
else
Config.Set(key, raw);
Say("configset " + key + " = " + raw + " (in memory only)");
}
/// <summary>
/// Reads a key back through `Config.Get`, at a moment long after every type
/// initialiser has run.
///
/// This is the check that tells a key which TOOK from one that only appeared to: a
/// lease on one of ServUO's ~150 cached call sites applies cleanly and does nothing,
/// which is the worst failure this feature has.
/// </summary>
private static void ConfigRead(string key)
{
if (key == null)
{
Say("configread <key>");
return;
}
Say("configread " + key + " = " + Config.Get(key, Double.NaN).ToString("R", CultureInfo.InvariantCulture)
+ " (double), \"" + Config.Get(key, "<unset>") + "\" (string)");
}
// ---- houses ----
/// <summary>
/// `CanDecay` is the filter, and getting it wrong is silent: an AutoRefresh house --
/// and the owner's newest house is always AutoRefresh -- has a DecayLevel getter that
/// calls ResetDynamicDecay(), so a forced stage is wiped before the sweep reads it and
/// NOTHING is emitted. That looks exactly like a broken emitter.
/// </summary>
private static IEnumerable<BaseHouse> Decayable()
{
return BaseHouse.AllHouses
.Where(h => h != null && !h.Deleted && h.Owner != null && h.CanDecay);
}
private static void DecayList()
{
foreach (var h in Decayable())
{
var acct = h.Owner.Account == null ? "-" : h.Owner.Account.Username;
Say(String.Format(
"house 0x{0:X} owner={1} acct={2} name=\"{3}\" region={4} type={5} level={6}",
h.Serial.Value, h.Owner.Name, acct, HouseName(h), RegionName(h),
h.DecayType, h.DecayLevel));
}
Say("decayable=" + Decayable().Count());
}
private static string HouseName(BaseHouse h)
{
return h.Sign != null && h.Sign.Name != null ? h.Sign.Name : String.Empty;
}
private static string RegionName(BaseHouse h)
{
var r = Region.Find(h.Location, h.Map);
return r == null ? "-" : r.Name ?? "-";
}
private static void Decay(string which, string stage)
{
DecayLevel level;
if (!TryParseStage(stage, out level))
{
Say("unknown stage \"" + stage + "\"");
return;
}
BaseHouse house = null;
if (String.IsNullOrEmpty(which) || which == "any")
house = Decayable().FirstOrDefault();
else
{
var serial = ParseSerial(which);
house = Decayable().FirstOrDefault(h => h.Serial.Value == serial);
}
if (house == null)
{
Say("no decayable house matched \"" + which + "\"");
return;
}
var from = house.DecayLevel;
// A refresh is what a player does at the sign, and it is NOT SetDynamicDecay: the
// level is derived from LastRefreshed, so a "LikeNew" that only rewrote the dynamic
// stage would be undone by the next read.
if (level == DecayLevel.LikeNew)
house.RefreshDecay();
else
house.SetDynamicDecay(level);
Say(String.Format(
"house 0x{0:X} {1} -> {2} (now {3})",
house.Serial.Value, from, level, house.DecayLevel));
}
private static bool TryParseStage(string s, out DecayLevel level)
{
level = DecayLevel.Ageless;
if (String.IsNullOrEmpty(s))
return false;
foreach (DecayLevel candidate in Enum.GetValues(typeof(DecayLevel)))
{
if (String.Equals(candidate.ToString(), s, StringComparison.OrdinalIgnoreCase))
{
level = candidate;
return true;
}
}
return false;
}
private static int ParseSerial(string s)
{
var text = s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? s.Substring(2) : s;
int parsed;
if (Int32.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed))
return parsed;
return Int32.TryParse(s, out parsed) ? parsed : 0;
}
// ---- vendors ----
private static IEnumerable<PlayerVendor> Vendors()
{
return World.Mobiles.Values.OfType<PlayerVendor>().Where(v => !v.Deleted);
}
private static void VendorList()
{
Say("NewVendorSystem=" + BaseHouse.NewVendorSystem);
foreach (var v in Vendors())
{
var owner = v.Owner;
var acct = owner == null || owner.Account == null ? "-" : owner.Account.Username;
Say(String.Format(
"vendor 0x{0:X} shop=\"{1}\" owner={2} acct={3} hold={4} charge={5} nextPay={6}",
v.Serial.Value, v.ShopName, owner == null ? "-" : owner.Name, acct,
v.HoldGold, v.ChargePerDay, v.NextPayTime.ToUniversalTime().ToString("o")));
}
Say("vendors=" + Vendors().Count());
}
/// <summary>
/// Set a vendor's held gold, which is the only knob that walks it toward dismissal
/// without waiting a pay period -- `NextPayTime` has a private setter, and a period is
/// a real day on the new vendor system and a UO day (~2 real hours) on the old one.
/// The emitter computes `periodsRemaining` as funds / chargePerPeriod, so this moves
/// exactly the field the threshold tracker watches.
/// </summary>
private static void VendorFunds(string which, string gold)
{
var serial = ParseSerial(which ?? String.Empty);
var vendor = Vendors().FirstOrDefault(v => v.Serial.Value == serial);
if (vendor == null)
{
Say("no vendor matched \"" + which + "\"");
return;
}
int funds;
if (!Int32.TryParse(gold, out funds))
{
Say("bad gold \"" + gold + "\"");
return;
}
// Both, because the old vendor system spends BankAccount + HoldGold and the new one
// spends HoldGold alone -- setting one would leave the other paying the charge.
vendor.HoldGold = funds;
vendor.BankAccount = 0;
var charge = BaseHouse.NewVendorSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
Say(String.Format(
"vendor 0x{0:X} hold={1} bank=0 charge={2} periodsRemaining={3}",
vendor.Serial.Value, vendor.HoldGold, charge, charge > 0 ? funds / charge : -1));
}
// ---- accounts ----
/// <summary>
/// Mark an account as having just logged in.
///
/// This is the ONLY way to walk a decaying house back out of danger on a seeded
/// world, and the reason is ServUO's, not the rig's: every house that CAN decay here
/// is `DecayType.Condemned` (the seeder backdates accounts past
/// `Account.InactiveDuration` precisely to make them decay), and
/// `BaseHouse.RefreshDecay()` returns false immediately for a Condemned house. A
/// condemned house is not refreshable by anyone; it is rescued by its OWNER LOGGING
/// IN, which is what this reproduces.
///
/// What the shard then reports depends on how many houses the owner has:
/// `AutoRefresh` (their newest) stops decaying and reads **Ageless**, while an older
/// `ManualRefresh` one is back on the clock and reads **LikeNew**. Both are "out of
/// danger", and a mapper that reads only one of them misses most rescues.
/// </summary>
private static void Activate(string username)
{
var acct = Accounts.GetAccount(username) as Account;
if (acct == null)
{
Say("no account \"" + username + "\"");
return;
}
acct.LastLogin = DateTime.UtcNow;
Say(String.Format("account {0} lastLogin=now inactive={1}", acct.Username, acct.Inactive));
foreach (var h in BaseHouse.AllHouses)
{
if (h == null || h.Deleted || h.Owner == null || h.Owner.Account != acct)
continue;
Say(String.Format(
" house 0x{0:X} type={1} level={2}", h.Serial.Value, h.DecayType, h.DecayLevel));
}
}
/// <summary>
/// Set a game account's password, so a login can be driven over a real socket.
///
/// The socket is not optional for the ACCEPTED half: ServUO's own AccountHandler calls
/// `acct.HasAccess(e.State)` before it ever checks the password, and a null NetState
/// fails that -- so an in-process probe reports "access denied" for a correct password
/// and can never produce `accepted:true`.
/// </summary>
private static void Password(string username, string pw)
{
var acct = Accounts.GetAccount(username) as Account;
if (acct == null)
{
Say("no account \"" + username + "\"");
return;
}
if (String.IsNullOrEmpty(pw))
{
Say("refusing to set an empty password");
return;
}
acct.SetPassword(pw);
Say("account " + acct.Username + " password set");
}
// ---- cities ----
private static void CityList()
{
Say("CityLoyaltySystem.Enabled=" + CityLoyaltySystem.Enabled);
foreach (var city in CityLoyaltySystem.Cities)
{
if (city == null)
continue;
var e = city.Election;
Say(String.Format(
"city={0} governor={1} elect={2} election={3} candidates={4} autoPick={5}",
city.City,
city.Governor == null ? "-" : city.Governor.Name + "/0x" + city.Governor.Serial.Value.ToString("X"),
city.GovernorElect == null ? "-" : city.GovernorElect.Name,
e == null ? "-" : (e.CanNominate() ? "nominate" : e.CanVote() ? "vote" : e.Ongoing ? "pending" : "none"),
e == null || e.Candidates == null ? 0 : e.Candidates.Count,
e == null ? "-" : e.AutoPickGovernor.ToUniversalTime().ToString("o")));
}
}
private static CityLoyaltySystem FindCity(string name)
{
return CityLoyaltySystem.Cities.FirstOrDefault(
c => c != null && String.Equals(c.City.ToString(), name, StringComparison.OrdinalIgnoreCase));
}
private static void Governor(string cityName, string who)
{
var city = FindCity(cityName);
if (city == null)
{
Say("no city \"" + cityName + "\"");
return;
}
if (String.Equals(who, "none", StringComparison.OrdinalIgnoreCase))
{
city.Governor = null;
Say("city=" + city.City + " governor cleared");
return;
}
var mob = FindMobile(who);
if (mob == null)
{
Say("no player matched \"" + who + "\"");
return;
}
city.Governor = mob;
var acct = mob.Account == null ? "-" : mob.Account.Username;
Say(String.Format(
"city={0} governor={1} 0x{2:X} acct={3}",
city.City, mob.Name, mob.Serial.Value, acct));
}
private static Mobile FindMobile(string who)
{
var serial = ParseSerial(who);
if (serial != 0)
{
var bySerial = World.FindMobile(serial);
if (bySerial != null)
return bySerial;
}
return World.Mobiles.Values.OfType<PlayerMobile>()
.FirstOrDefault(m => !m.Deleted && String.Equals(m.Name, who, StringComparison.OrdinalIgnoreCase));
}
private static void Election(string cityName)
{
var city = FindCity(cityName);
if (city == null)
{
Say("no city \"" + cityName + "\"");
return;
}
if (city.Election == null)
{
Say("city=" + city.City + " has no election object");
return;
}
city.Election.StartNewElection();
Say(String.Format(
"city={0} election restarted; autoPick={1} nominate={2}",
city.City,
city.Election.AutoPickGovernor.ToUniversalTime().ToString("o"),
city.Election.CanNominate()));
}
}
}