Files
servuo-plugins/tools/scaffolding/BridgeRigDriver.cs
wtclaude f6a86ff8c2 feat(bridge): what an event borrows, and the two one-shots (Phase 12b)
The shard half of protocol 7 part b. Two lease planes whose value lives on
something already in the world, and two verbs that cannot be taken back.

A LEASE HERE MUST BE PERSISTED, AND THE CONFIG PLANE'S MUST NOT

11b's fail-safe is stated plainly in its own header: a lease that never reaches
disk means a shard restart is a FREE restore. That argument depends entirely on
the leased value being memory-only too, and here it is not. A spawner is an Item
and is in the world save; a seasonal entry is written to
`Saves/Misc/SeasonalEvents.bin` by ServUO's own `EventSink.WorldSave`. So a
restart does not put either back -- it puts the CHANGE back and throws away the
deadline timer that was going to undo it, leaving the world at the leased value
with nothing here remembering it is borrowed.

So the Bridge gains its THIRD save file, `Saves/Bridge/Leases.bin`, written by
the same `EventSink.WorldSave` that writes what it describes, with deadlines
re-armed at load. A deadline that passed while the shard was down fires AT ONCE:
the promise was "back at baseline by then", and extending it would silently turn
a two-hour lease into however long the outage was. Config holds are still not
written down -- the same argument, applied to planes where its premise is false.

A TARGET IS A SERIAL OR A UniqueId, AND BOTH ARE NEEDED

A serial is what `[props` shows a GM. An `XmlSpawner.UniqueId` is what the
shard's own `Spawns/*.xml` carry -- and it is not a convenience: a dropdown built
from serials is IMPOSSIBLE, because serials are assigned when the world is built
and nothing off-shard knows them. A lease addressable only by serial could have
no authoring list at all.

`Spawner` and `XmlSpawner` share all four property names, which is a fact about
this tree rather than a convenience: the spawn files load as XmlSpawners while
`[add spawner` makes the native one. And it is `MaxCount`, not the `Amount`
EVENTS_PLAN.md named -- there is no such property. `MinDelay`/`MaxDelay` are
TimeSpans, so the wire carries seconds.

The allowlist is checked against the object's OWN type, which is the sentence the
whole plane rests on: a serial is a number a caller chooses, so that check is all
that stands between `Spawner.MaxCount` and any item on the shard. Reflection is
bounded three ways -- the pair must be in the catalog, the property must carry
`CommandProperty` (so this can never reach further than `[set` could), and its
CLR type must be one this file renders.

THE SELF-CHECK, AND THE ONE FAILURE NO PROBE CAN CATCH

§N10 in full: a config key is probed live (write, read back, restore) because
there is exactly one of it. A property CANNOT be -- thousands of instances and no
canonical one, so probing would mean writing to somebody's spawner at boot. What
is verified instead is everything verifiable without touching the world.

And `TreasuresOfTokuno` is excluded by name, because `IsActive()` reads its own
`DropEra` rather than `Status`: the write succeeds, the value reads back, a
compare-and-set restore passes, and the capability does nothing at all. That is
N10's "capability that lies" in its purest form and the only way to find it is to
read the source. §G also called this toggle "small and safe" -- it is safe, but
`OnStatusChange()` generates or removes world content for six of the eight.

THE ONE-SHOTS

Who receives a grant is answered HERE. The website has the list too, but a module
cannot read core's tables, so the alternative was a new core surface handing
participants to a module. Not needed: 11b's participation ledger already holds
them, keyed by the same serials. A run with no ledger is a 404; a run whose
ledger is open and empty is a 200 with `granted: 0`, because an event nobody
attended still happened. An undeliverable grant is DELETED rather than dropped --
`AddItem` failing on a full backpack would otherwise leave it at (0,0).

A save stops the world, so it is rate-limited rather than capped, counting
ServUO's own autosave as the last one. Refused, never queued: a queued save would
land at a moment nobody chose.

VERIFIED

Compiles clean (0 warnings, 0 errors). Then a full walk on the real local ServUO
57.4 world against the release sidecar:

- all six catalog keys survive the boot self-check; `current` is correctly absent
  on the targeted rows and filled when a target is named;
- a spawner reads the same by UniqueId and by serial;
- TWO RUNS ON TWO SPAWNERS BOTH SUCCEED while a second run on the SAME spawner is
  refused -- the whole reason for the targeted ref;
- a GM edit behind the plane's back yields `lease.drifted` and the world is left
  at 55, not reverted; a clean release restores to baseline;
- ToT refused with its own reason, a bad status refused, Fellowship toggled;
- grant: no ledger 404, empty ledger 200 `granted: 0`, unknown item 400, over the
  stack bound 400; save 200 then 429 inside the interval;
- BOTH HOLDS AND BOTH LEASED VALUES SURVIVE save + clean shutdown + restart, the
  deadlines re-arm, and a release across the restart still compare-and-sets;
- with only a CONFIG lease held, `Leases.bin` is 8 bytes and names nothing;
- refusals: targeted-with-no-target, untargeted-with-a-target, out of range, over
  30 days, a target that is not there, and a `ChainChest` refused as a spawner;
- 90 seconds becomes `00:01:30` and the baseline reads back as 18000;
- a deleted target reads `unreadable` and releases `targetGone: true`.

The test world was never saved after the deliberate deletion, so it is intact.

Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:07:12 -05:00

795 lines
32 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
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
/// spawnerlist [n] name a few XmlSpawners, serial AND UniqueId --
/// the two ways a property lease names its target
/// propset &lt;target&gt; &lt;prop&gt; &lt;v&gt; set a property BEHIND the lease plane's back,
/// which is the only way to reach `drifted` here
/// propread &lt;target&gt; &lt;prop&gt; read one back, to assert a restore landed
/// seasonlist every seasonal event and its status
/// 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 "spawnerlist": SpawnerList(Arg(parts, 1)); break;
case "propset": PropSet(Arg(parts, 1), Arg(parts, 2), Arg(parts, 3)); break;
case "propread": PropRead(Arg(parts, 1), Arg(parts, 2)); break;
case "seasonlist": SeasonList(); 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");
}
/// <summary>
/// Names a few spawners, with both ways of addressing one.
///
/// A property lease is targeted by a serial or by an `XmlSpawner.UniqueId`, and the rig
/// has no other way to learn either — the website's dropdown comes from the atlas, and
/// the rig does not have one.
/// </summary>
private static void SpawnerList(string raw)
{
var want = 5;
if (!String.IsNullOrEmpty(raw))
Int32.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out want);
if (want < 1)
want = 1;
var shown = 0;
foreach (var item in World.Items.Values)
{
if (shown >= want)
break;
var xml = item as Mobiles.XmlSpawner;
if (xml == null || xml.Deleted)
continue;
Say(String.Format(CultureInfo.InvariantCulture,
"spawner 0x{0:X} uid={1} maxCount={2} running={3} name={4}",
item.Serial.Value, xml.UniqueId, xml.MaxCount, xml.Running, xml.Name ?? "-"));
shown++;
}
if (shown == 0)
Say("spawnerlist: this world has no XmlSpawners");
}
/// <summary>
/// Sets a property on an object BEHIND the lease plane's back.
///
/// 11b's `configset` exists because `Config.Set` has one caller in the whole tree, so
/// nothing on a stock shard could drift a config lease. A spawner is the opposite — a GM
/// drifts one with `[props` in about four seconds — but the rig has no client, so it
/// needs the same door. This is the only way to reach `drifted` on a property lease
/// without one, and it is exactly what a staff member's `[set` does.
/// </summary>
private static void PropSet(string target, string property, string value)
{
if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property) || value == null)
{
Say("propset <serial|uniqueId> <property> <value>");
return;
}
Item item = null;
uint parsed;
var text = target.Trim();
var isSerial = 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 (isSerial)
{
item = World.FindItem((Serial)unchecked((int)parsed));
}
else
{
foreach (var candidate in World.Items.Values)
{
var xml = candidate as Mobiles.XmlSpawner;
if (xml == null || !String.Equals(xml.UniqueId, text, StringComparison.OrdinalIgnoreCase))
continue;
item = xml;
break;
}
}
if (item == null || item.Deleted)
{
Say("propset: nothing at " + text);
return;
}
var info = item.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.Instance);
if (info == null || !info.CanWrite)
{
Say("propset: " + item.GetType().Name + " has no writable " + property);
return;
}
try
{
object typed;
if (info.PropertyType == typeof(TimeSpan))
typed = TimeSpan.FromSeconds(Double.Parse(value, CultureInfo.InvariantCulture));
else if (info.PropertyType == typeof(bool))
typed = String.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || value == "1";
else
typed = Convert.ChangeType(value, info.PropertyType, CultureInfo.InvariantCulture);
info.SetValue(item, typed, null);
Say("propset: " + property + " on " + text + " is now " + value + ", and nobody was told");
}
catch (Exception e)
{
Say("propset: " + e.Message);
}
}
/// <summary>Reads a property back, so the rig can assert a restore actually landed.</summary>
private static void PropRead(string target, string property)
{
if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property))
{
Say("propread <serial|uniqueId> <property>");
return;
}
Item item = null;
uint parsed;
var text = target.Trim();
var isSerial = 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 (isSerial)
{
item = World.FindItem((Serial)unchecked((int)parsed));
}
else
{
foreach (var candidate in World.Items.Values)
{
var xml = candidate as Mobiles.XmlSpawner;
if (xml == null || !String.Equals(xml.UniqueId, text, StringComparison.OrdinalIgnoreCase))
continue;
item = xml;
break;
}
}
if (item == null || item.Deleted)
{
Say("propread: nothing at " + text);
return;
}
var info = item.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.Instance);
if (info == null)
{
Say("propread: " + item.GetType().Name + " has no " + property);
return;
}
var raw = info.GetValue(item, null);
Say("propread: " + property + " = " + Convert.ToString(raw, CultureInfo.InvariantCulture));
}
/// <summary>Says what the seasonal system holds, which is the seasonal lease's target list.</summary>
private static void SeasonList()
{
foreach (Engines.SeasonalEvents.EventType type in Enum.GetValues(typeof(Engines.SeasonalEvents.EventType)))
{
var entry = Engines.SeasonalEvents.SeasonalEventSystem.GetEntry(type);
Say(entry == null
? "season " + type + " = (no entry)"
: "season " + type + " = " + entry.Status);
}
}
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()));
}
}
}