From 158c0596d8f1e459eea5b889ee14483c751c6f21 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:22:46 -0500 Subject: [PATCH] chore(tools): dress a seeded world so it can be screenshotted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BridgeSeeder builds a world at realistic scale, which is what the bridge needed; it never needed the world to look like anything. So a vendor is `seed vendor` trading as `Seed Shop 810`, a character is `Seed004A` and a house sign reads `Seed House 12` — and every one of those strings travels the whole bridge and lands on the marketplace, the guild roster and the housing pages of the website. That is fine for a protocol test and wrong for the marketing site's screenshots (runicgateway.com PLAN.md §13 phase 9, D42/D46). BridgeDemoDress renames them in place and seeds nothing: prices, listing counts, decay stages, fame and skills stay exactly as the seeder left them, so the data keeps its provenance and only the strings a human reads change. Names come from fixed tables hashed off each object's serial, so a re-run reproduces the same world and screenshots can be retaken later and still match. It also does two things the screenshots needed and nothing else provides: - stages a few condemned houses back into the last decay levels, because decay is a live process and "Houses in danger" is empty by the time anyone looks - sets a known password on seed_000, because logging a character in is the only way to make the online roster non-empty and the seeder assigns a random GUID. The password is read from Bridge.cfg, never compiled in. Test scaffolding, in tools/, never deployed — deploy.ps1 copies overlay/ only. Co-Authored-By: Claude --- tools/scaffolding/BridgeDemoDress.cs | 402 +++++++++++++++++++++++++++ tools/scaffolding/README.md | 36 +++ 2 files changed, 438 insertions(+) create mode 100644 tools/scaffolding/BridgeDemoDress.cs diff --git a/tools/scaffolding/BridgeDemoDress.cs b/tools/scaffolding/BridgeDemoDress.cs new file mode 100644 index 0000000..d991752 --- /dev/null +++ b/tools/scaffolding/BridgeDemoDress.cs @@ -0,0 +1,402 @@ +using System; +using System.Collections.Generic; + +using Server.Accounting; +using Server.Commands; +using Server.Mobiles; +using Server.Multis; + +namespace Server.Custom +{ + /// + /// Gives a BridgeSeeder world presentable names, so a shard standing behind a public + /// screenshot does not read as test data. + /// + /// Test scaffolding. Not part of the bridge. Never deployed — see tools/README.md. + /// + /// WHY THIS EXISTS + /// --------------- + /// BridgeSeeder builds a world at realistic SCALE, which is what the bridge needed: + /// 50 accounts, 150 characters, 30 houses, 30 vendors, 1,200 listings. It never needed + /// the world to look like anything, so a vendor is "seed vendor" trading as + /// "Seed Shop 810" and a character is "Seed004A". Every one of those names travels the + /// whole bridge — plugin, sidecar, website — and lands on the marketplace, the guild + /// roster and the housing pages, which are exactly the pages a screenshot wants. + /// + /// This pass renames what is already there rather than seeding anything new. That + /// matters: the data keeps its provenance. The prices, the listing counts, the decay + /// stages, the fame and the skill sheets are all still whatever BridgeSeeder produced + /// and whatever the shard has done to them since — only the strings a human reads are + /// replaced. Nothing here invents shard state that the game did not produce. + /// + /// IDEMPOTENT, AND DETERMINISTIC + /// ----------------------------- + /// Names come from fixed tables indexed by the object's own serial, so the same vendor + /// draws the same shop name on every run against the same save — screenshots retaken + /// later still match. A second run is therefore a no-op, and a world half-dressed by an + /// interrupted run finishes cleanly. + /// + /// Shop and house names are also re-dressed when they are names THIS pass produced, so + /// a change to the tables or to the hash can be applied to a world that has already been + /// through here once. Character names are not: a person's name is an ordinary string + /// with no closed set to recognise it by, so once dressed it is left alone. + /// + /// WHAT IT ALSO DOES, AND WHY EACH IS HERE + /// --------------------------------------- + /// - Stages houses back into IDOC. The seeder condemned eighteen of them, but decay is + /// a live process: by the time anybody looks, the stages have moved on and the + /// "Houses in danger" page is empty. Empty is a true state and a poor screenshot, so + /// this puts a handful back. + /// - Sets a known password on one seeded account. Logging a character in is the only + /// way to make the online roster non-empty, and it needs a client, and a client needs + /// a password. The seeder gives every account a random GUID nobody kept. + /// + /// Flag: `DemoDressOnStart=True` in Config/Bridge.cfg. In game: `[demodress`. + /// + public static class BridgeDemoDress + { + private const string Prefix = "seed_"; + + /// The account whose password is set, so a character can be logged in. + private const string LoginAccount = "seed_000"; + + /// + /// Read from Config/Bridge.cfg (`DemoDressPassword`) so a password never lands in + /// source control. Absent means the account is left alone. + /// + private static string LoginPassword + { + get { return Config.Get("Bridge.DemoDressPassword", default(string)); } + } + + /// How many condemned houses to put back into the last two decay stages. + private const int IdocHouses = 4; + + // ── Name tables ──────────────────────────────────────────────────────────────── + // + // Ordinary fantasy given names and English trade-sign nouns. Deliberately dull: the + // point is that a reader's eye passes over them, which is what a real roster does. + + private static readonly string[] Given = + { + "Alaric", "Bess", "Corwin", "Dagna", "Edric", "Fenna", "Garrick", "Halle", + "Ivo", "Jessa", "Kellen", "Lira", "Marek", "Nessa", "Orrin", "Perrin", + "Quill", "Rowan", "Sera", "Tamsin", "Ulric", "Vera", "Wendel", "Xanthe", + "Yorick", "Zara", "Bram", "Caitrin", "Doran", "Elspeth" + }; + + private static readonly string[] Family = + { + "Ashdown", "Bellweather", "Crowe", "Dunmore", "Eastgate", "Fairbourne", + "Grimsby", "Hollowell", "Ironwood", "Larkspur", "Mosswick", "Thornbury" + }; + + private static readonly string[] ShopFirst = + { + "The Copper", "The Silver", "The Gilded", "The Iron", "The Rusted", "The Amber", + "The Quiet", "The Crooked", "The Old", "The Wandering", "The Salted", "The Ember" + }; + + private static readonly string[] ShopSecond = + { + "Anvil", "Kettle", "Lantern", "Compass", "Bellows", "Flask", "Ledger", + "Wagon", "Tankard", "Whetstone", "Sextant", "Coffer" + }; + + private static readonly string[] HouseNames = + { + "Ashwood Cottage", "Bramblegate", "Candlewick House", "Dovecote", + "Eastmarch", "Fernhollow", "Greywater", "Hearthstone", + "Ivyfall", "Kestrel Lodge", "Longmeadow", "Millrace", + "Northrest", "Oakenshaw", "Pinefall", "Quarrystone", + "Riverwatch", "Stonebrook", "Thistledown", "Umberley", + "Vinesend", "Westbarrow", "Yewcross", "Almsgate", + "Brightmoor", "Coldspring", "Duskvale", "Elmshade", + "Foxhollow", "Gravensward" + }; + + public static void Initialize() + { + CommandSystem.Register("demodress", AccessLevel.Administrator, Dress_OnCommand); + + if (Config.Get("Bridge.DemoDressOnStart", false)) + EventSink.ServerStarted += () => Run(null, save: true); + } + + [Usage("demodress")] + [Description("Renames BridgeSeeder's synthetic world so it is presentable in screenshots.")] + private static void Dress_OnCommand(CommandEventArgs e) + { + Run(e.Mobile, save: false); + } + + private static void Report(Mobile to, string text) + { + Console.WriteLine("[BridgeDemoDress] " + text); + + if (to != null) + to.SendMessage(text); + } + + private static void Run(Mobile to, bool save) + { + try + { + var start = DateTime.UtcNow; + + int chars = DressCharacters(); + int vendors = DressVendors(); + int houses = DressHouses(); + int idoc = StageIdoc(); + bool password = SetLoginPassword(to); + + Report(to, String.Format( + "Dressed {0} characters, {1} vendors, {2} house signs; staged {3} IDOC; " + + "login password {4}. ({5:F1}s)", + chars, vendors, houses, idoc, password ? "set" : "skipped", + (DateTime.UtcNow - start).TotalSeconds)); + + if (save) + { + Report(to, "Saving world..."); + World.Save(); + Report(to, "Save complete."); + } + } + catch (Exception ex) + { + Report(to, "FAILED: " + ex); + } + } + + /// + /// A stable index for a world object, salted so that two names drawn for the SAME + /// object land in unrelated places in their tables. + /// + /// Serial is the only identifier that survives a save and is identical on every + /// load, which is what makes the naming reproducible. But serials are dense and + /// sequential, so a weak mix hands neighbouring objects neighbouring names. The + /// first attempt derived the second word from `serial / 5`, which is constant + /// across five consecutive serials — twenty-seven vendors came out as four + /// Flasks, four Lanterns and three Anvils in a row. Salting and re-mixing per + /// draw is what fixes that: each word is an independent hash of the pair. + /// + private static int Pick(int serial, int salt, int modulus) + { + unchecked + { + uint h = (uint)serial ^ ((uint)salt * 0x9E3779B1u); + h ^= h >> 15; + h *= 2246822519u; + h ^= h >> 13; + h *= 3266489917u; + h ^= h >> 16; + return (int)(h % (uint)modulus); + } + } + + private static string PersonName(int serial) + { + return Given[Pick(serial, 1, Given.Length)] + " " + Family[Pick(serial, 2, Family.Length)]; + } + + private static string ShopSign(int serial) + { + return ShopFirst[Pick(serial, 3, ShopFirst.Length)] + " " + + ShopSecond[Pick(serial, 4, ShopSecond.Length)]; + } + + private static bool LooksSeeded(string name, string marker) + { + return name != null && name.StartsWith(marker, StringComparison.OrdinalIgnoreCase); + } + + /// + /// True when a name is one this pass could have produced. + /// + /// Dressing has to be re-runnable in both directions: a first pass renames what the + /// seeder left, and a later pass — after the tables or the hash change — has to be + /// able to rename its own earlier output. A name is recognised by MEMBERSHIP of the + /// closed tables rather than by a marker on the object, because the object is a + /// PlayerVendor whose name is a plain string with nowhere to hide a flag, and a + /// name that is not in the tables was set by a person and is left alone. + /// + private static bool IsOurs(string name, string[] first, string[] second) + { + if (String.IsNullOrEmpty(name)) + return false; + + foreach (var a in first) + { + if (!name.StartsWith(a + " ", StringComparison.Ordinal)) + continue; + + var rest = name.Substring(a.Length + 1); + + foreach (var b in second) + { + if (rest == b) + return true; + } + } + + return false; + } + + private static bool IsOurHouseName(string name) + { + foreach (var h in HouseNames) + { + if (h == name) + return true; + } + + return false; + } + + private static int DressCharacters() + { + int n = 0; + + foreach (Account acct in Accounts.GetAccounts()) + { + if (!acct.Username.StartsWith(Prefix, StringComparison.Ordinal)) + continue; + + for (int i = 0; i < acct.Length; i++) + { + var m = acct[i]; + + if (m == null || !LooksSeeded(m.Name, "Seed")) + continue; + + // Offset by the slot so an account's three characters are three people + // rather than three spellings of one. + m.Name = PersonName(m.Serial.Value + i * 101); + n++; + } + } + + return n; + } + + private static int DressVendors() + { + int n = 0; + + if (PlayerVendor.PlayerVendors == null) + return 0; + + // PlayerVendors is a live collection; the rename does not add or remove members, + // but copy anyway so an unrelated vendor placement mid-pass cannot invalidate it. + var vendors = new List(PlayerVendor.PlayerVendors); + + foreach (var vendor in vendors) + { + bool touched = false; + + if (LooksSeeded(vendor.ShopName, "Seed Shop") || + IsOurs(vendor.ShopName, ShopFirst, ShopSecond)) + { + var sign = ShopSign(vendor.Serial.Value); + + if (sign != vendor.ShopName) + { + vendor.ShopName = sign; + touched = true; + } + } + + if (LooksSeeded(vendor.Name, "seed vendor")) + { + vendor.Name = PersonName(vendor.Serial.Value + 7919); + touched = true; + } + + if (touched) + n++; + } + + return n; + } + + private static int DressHouses() + { + int n = 0; + + foreach (var house in BaseHouse.AllHouses) + { + if (house.Sign == null) + continue; + + if (!LooksSeeded(house.Sign.Name, "Seed House") && !IsOurHouseName(house.Sign.Name)) + continue; + + var name = HouseNames[Pick(house.Serial.Value, 5, HouseNames.Length)]; + + if (name == house.Sign.Name) + continue; + + house.Sign.Name = name; + n++; + } + + return n; + } + + /// + /// Puts a few condemned houses back into the final decay stages. + /// + /// Only houses that CAN decay are touched — an active owner's AutoRefresh house is + /// left alone, because forcing one into IDOC would be inventing a state the game + /// would never produce and the next refresh would undo it anyway. + /// + private static int StageIdoc() + { + int n = 0; + + foreach (var house in BaseHouse.AllHouses) + { + if (n >= IdocHouses) + break; + + if (!house.CanDecay || house.DecayLevel == DecayLevel.IDOC) + continue; + + house.SetDynamicDecay(n % 2 == 0 ? DecayLevel.IDOC : DecayLevel.Greatly); + house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6); + n++; + } + + return n; + } + + /// + /// Sets a known password on one seeded account so a character can be logged in with + /// a real client. The seeder assigns a random GUID, which nobody kept. + /// + private static bool SetLoginPassword(Mobile to) + { + var password = LoginPassword; + + if (String.IsNullOrEmpty(password)) + return false; + + var acct = Accounts.GetAccount(LoginAccount) as Account; + + if (acct == null) + { + Report(to, "No account " + LoginAccount + " — password not set."); + return false; + } + + acct.SetPassword(password); + + // The seeder backdates some accounts past InactiveDuration to condemn their + // houses. This one has to be able to log in, so bring it back to the present. + acct.LastLogin = DateTime.UtcNow; + + return true; + } + } +} diff --git a/tools/scaffolding/README.md b/tools/scaffolding/README.md index e5d0688..7eb50ff 100644 --- a/tools/scaffolding/README.md +++ b/tools/scaffolding/README.md @@ -13,6 +13,7 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo | `BridgeLinkProbe.cs` | `Scripts/Custom/BridgeLinkProbe.cs` | Triggers `[link` for seed_001 without a client, then saves so the `WebsiteUserId` tag reaches `accounts.xml`. Flag: `LinkProbeOnStart`. Pair with a sidecar that reads the code and sends `link.confirm`. | | `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. | | `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. | +| `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. | ## Deploy overwrites Bridge.cfg @@ -34,6 +35,41 @@ Because `Config.Get` returns `false` for a missing key, a server whose `Bridge.c In-game, `[seedworld` and `[unseedworld` (Administrator) do the same work on a live shard. +## Dressing a seeded world for screenshots + +`BridgeSeeder` builds a world at realistic **scale**, which is all the bridge ever needed. It does not +build one that looks like anything: a vendor is `seed vendor` trading as `Seed Shop 810`, a character +is `Seed004A`, a house sign says `Seed House 12`. Those strings travel the whole bridge and land on +the marketplace, the guild roster and the housing pages of the website — fine for a protocol test, +wrong for a screenshot. + +`BridgeDemoDress.cs` renames them in place. It seeds nothing: prices, listing counts, decay stages, +fame and skills stay exactly as the seeder left them and as the shard has moved them since, so the +data keeps its provenance and only the strings a human reads change. Names are drawn from fixed +tables by a hash of each object's serial, so a re-run reproduces the same world, and shop and house +names are re-dressed when they are names the pass itself produced — so a change to the tables can be +applied to a world that has already been through here. + +```ini +DemoDressOnStart=True +DemoDressPassword= +``` + +Boot once, then set `DemoDressOnStart=False`. The password is written to `seed_000` so a real client +can log a character in — the only way to make the website's online roster non-empty — and it is read +from the config rather than compiled in, so it never lands in source control. + +**It dresses seeded objects only, which means your own characters keep their names.** That is the +right behaviour for a test shard and a thing to remember before pointing a camera at one: a dev +world usually also holds the accounts, characters, guilds and houses of whoever built it, and those +are real identifiers on a page that may end up public. + +**The sidecar's board is cached, so the website lags a rename.** A shop name reaches the site on the +next market sweep, and a sweep advances `MarketSweepBatch` vendors per tick — 27 vendors at the +defaults is two ticks. Allow a couple of minutes before concluding that a rename failed. This cost a +debugging detour once: the shard had the new names all along and the sidecar was still serving the +previous ones. + ## Back up `Saves/` first `[seedworld` and `SeedOnStart` **write to the live world**. Copy `Saves/` somewhere outside the repo before running either. `Backups/Automatic` is rotated by `AutoSave.cs` and `Backups/Temp` is deleted outright, so neither is a safe destination. -- 2.49.1