docs/link/v8.md §16 phase 0. §4 chose to CALL ServUO's vendored `Ultima`
rather than reimplement it, on the evidence of a PowerShell probe against a
stock client — neither the process nor the client the extractor will run in.
This runs the same decoders from inside a running ServUO 57.4 against a
client broken in 21 catalogued ways, and it found more than a crash.
Adds, all under tools/ and therefore never deployed:
* BridgeAssetProbe.cs — the sweep, plus BridgeAssetValidator, a prototype
of the validate-before-calling response chosen for §4.2's residual risk.
Runs off the Core thread, snapshots Race.AllRaces on it, and writes the
id it is ABOUT to touch to a checkpoint file before every call.
* BridgeMythicCliloc.cs — the §9 Mythic cliloc reader in net48 C#, ported
from UOFiddler (Beerware) with every file-derived index bounds-checked.
Phase 2 promotes this into overlay/.
* patch_client.ps1 — builds the patched client in five tiers. Hashes every
file it touches in the SOURCE before and after and aborts on a change.
* an `assetprobe` verb on BridgeRigDriver, so stock and patched can be run
against one boot rather than two shard processes.
The findings are written up in tools/scaffolding/README.md. The four that
change what phase 1 has to build:
* FileIndex's UOP constructor ends `MulPath = uopPath`, so artLegacyMUL.uop
wins outright and art.mul/artidx.mul are never opened on a current
client. A validator bounding offsets against art.mul is not approximate,
it is nonsense — the first run refused 34,299 good statics on that
mistake, and every refusal looked like a real finding.
* 22,102 WRONG PICTURES on a stock, unmodified client. Empty UOP index
slots read `lookup 0, length 0`; Seek treats that as a hit, and
LoadStatic decodes zero bytes into a shared buffer it reuses, only ever
grows, and fills from a Read whose return value is discarded — so the id
renders the previously-decoded asset. The mul path does not do this
(artidx stores -1), which is why the earlier probe counted 32,766 of
them as "ok". A bulk import that trusted the library would have written
22,102 duplicate images under ids that have no art.
* The validator caught all 8 record-level defects — 7 of which the library
rendered without raising anything, including a verdata lookup past
verdata.mul's own end (Verdata.Seek is bounds-checked nowhere) and an
8000x8000 bitmap allocated from two bytes in a file. It refused NOTHING
across 49,151 statics and 16,384 land tiles on the stock client, which
is the number that makes the boundary defensible.
* §4.1's crash reproduces in-process: one Ultima.Gumps.GetGump(2) and the
ServUO process disappeared — no catch reached, no console line, the
checkpoint file the only record. "Nothing calls Ultima.Gumps" is now an
earned safety rule.
§9 is proven: 123,490 entries in 218 ms, byte-identical to UOFiddler's own
output, with no UOFiddler installed and nothing copied to a server.
Not covered, and named as phase 1 work: the animation path has no validator
at all, and the patched wolf decoded something else in silence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
803 lines
32 KiB
C#
803 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 <serial|any> <stage> force a decay stage (LikeNew|Slightly|Somewhat|
|
|
/// Fairly|Greatly|IDOC|Collapsed)
|
|
/// vendorlist player vendors, with owner account and next pay time
|
|
/// vendorfunds <serial> <gold> set a vendor's held gold (drives periodsRemaining)
|
|
/// citylist cities, governors and election phases
|
|
/// governor <city> <mobile|none> seat a governor (a mobile serial, or a player's name)
|
|
/// election <city> force a new election into its nomination window
|
|
/// 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
|
|
/// spawnerlist [n] name a few XmlSpawners, serial AND UniqueId --
|
|
/// the two ways a property lease names its target
|
|
/// propset <target> <prop> <v> set a property BEHIND the lease plane's back,
|
|
/// which is the only way to reach `drifted` here
|
|
/// propread <target> <prop> 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;
|
|
// Asset Bridge phase 0. Here for the same reason as partprobe, and for one more:
|
|
// the point of that spike is comparing the STOCK client's answers with a patched
|
|
// client's, and `AssetProbeOnStart` can only ever run whichever one the config
|
|
// names. Driving it from here runs both against a single boot, so a difference
|
|
// between them cannot be a difference between two shard processes.
|
|
case "assetprobe":
|
|
BridgeAssetProbe.Begin(null, Arg(parts, 1) ?? "all", Arg(parts, 2));
|
|
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()));
|
|
}
|
|
}
|
|
}
|