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 { /// /// 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/`. /// 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; } } /// /// 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. /// private static void WorldGone(string raw) { if (String.IsNullOrEmpty(raw)) { Say("worldgone "); 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"); } /// /// 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. /// 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"); } /// /// 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. /// private static void PropSet(string target, string property, string value) { if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property) || value == null) { Say("propset "); 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); } } /// Reads a property back, so the rig can assert a restore actually landed. private static void PropRead(string target, string property) { if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property)) { Say("propread "); 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)); } /// Says what the seasonal system holds, which is the seasonal lease's target list. 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; } /// /// 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. /// private static void ConfigSet(string key, string raw) { if (key == null || raw == null) { Say("configset "); 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)"); } /// /// 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. /// private static void ConfigRead(string key) { if (key == null) { Say("configread "); return; } Say("configread " + key + " = " + Config.Get(key, Double.NaN).ToString("R", CultureInfo.InvariantCulture) + " (double), \"" + Config.Get(key, "") + "\" (string)"); } // ---- houses ---- /// /// `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. /// private static IEnumerable 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 Vendors() { return World.Mobiles.Values.OfType().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()); } /// /// 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. /// 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 ---- /// /// 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. /// 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)); } } /// /// 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`. /// 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() .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())); } } }