Files
servuo-plugins/tools/scaffolding/BridgeDemoDress.cs
wtclaude fc4ebf0f5a chore(tools): build guilds, and walk houses into IDOC where the site can see it
Two things the screenshot rig needed and the dressing pass could not give it
(runicgateway.com PLAN.md §13 phase 9).

GUILDS. The world had none — the guild board on the site was two rows of week-old
cache for guilds that had been deleted, and the website's Teams reconcile from that
board, so Teams was empty too. There is nothing to rename here: a guild has to exist
before it can be called something, so this builds four of them out of characters the
seeder already made, with a leader, two officers per guild and an alliance across the
first two. Idempotent by name, and a character already in a guild is never moved.

IDOC. "Houses in danger" reads a column the ingest only writes when the plugin reports
a house CHANGING stage; the registry frame carries the stage too, but the ingest leaves
that column to the transition feed so the two cannot clobber each other. A house that is
already collapsing when the site connects is therefore invisible: the sweep baselines it
at IDOC and no transition is ever emitted. The staging is now two passes — prime a few
houses at a middle stage at boot, collapse them 150 seconds later — so the site watches
it happen. Where too few houses can decay at all, their owners' accounts are backdated,
which is the same lever the seeder pulled and the same one a real shard pulls when
somebody stops playing.

Also: "Bridge Test Shop", left over from a hand-run smoke test, now gets a name like
every other vendor.

Both of the site-side asymmetries above are recorded as product observations in the
file rather than patched from here.

Test scaffolding, in tools/, never deployed — deploy.ps1 copies overlay/ only.
Verified against the local ServUO tree: four guilds and their rosters reached the
website over a real sidecar, and two houses reached "Houses in danger".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 09:35:53 -05:00

688 lines
28 KiB
C#

using System;
using System.Collections.Generic;
using Server.Accounting;
using Server.Commands;
using Server.Guilds;
using Server.Mobiles;
using Server.Multis;
namespace Server.Custom
{
/// <summary>
/// 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
/// ---------------------------------------
/// - Walks a few houses into IDOC, in two passes with a wait between them, because the
/// website only records a collapse it watched happen. Decay is a live process: by the
/// time anybody looks the stages have moved on and "Houses in danger" is empty. Empty
/// is a true state and a poor screenshot, so this stages a handful — see PrimeIdoc.
/// - 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.
/// - BUILDS GUILDS, which is the one thing here that creates rather than renames. The
/// seeder never made any, so a shard behind these screenshots has an empty guild
/// board and — because the website's Teams are reconciled from that board — no teams
/// either. There is nothing to rename: a guild has to exist before it can be called
/// something. Members are drawn from characters the seeder already made, so the only
/// invention is the association itself.
///
/// A NOTE ON THE GUILD BOARD, FOUND WHILE BUILDING THIS
/// ---------------------------------------------------
/// `BridgeSocial.Signature()` folds name, abbreviation, leader serial, member count and
/// alliance — not member NAMES — and the roster is only re-emitted when the member SET
/// changes. So renaming a guild member never reaches the site: the board keeps the name
/// the member had when the roster was last emitted. Dressing a world that was already
/// published therefore leaves stale rosters behind, and creating the guilds after the
/// rename (as this does) is what avoids it. Raised as a product observation, not fixed
/// here — a rename is rare in a real shard, and the fix belongs in the plugin.
///
/// Flag: `DemoDressOnStart=True` in Config/Bridge.cfg. In game: `[demodress`.
/// </summary>
public static class BridgeDemoDress
{
private const string Prefix = "seed_";
/// <summary>The account whose password is set, so a character can be logged in.</summary>
private const string LoginAccount = "seed_000";
/// <summary>
/// Read from Config/Bridge.cfg (`DemoDressPassword`) so a password never lands in
/// source control. Absent means the account is left alone.
/// </summary>
private static string LoginPassword
{
get { return Config.Get("Bridge.DemoDressPassword", default(string)); }
}
/// <summary>How many condemned houses to put back into the last two decay stages.</summary>
private const int IdocHouses = 4;
/// <summary>
/// How long after boot the second IDOC pass runs. See <see cref="PrimeIdoc"/> —
/// the delay is the whole point, not a politeness.
/// </summary>
private static int IdocDelaySeconds
{
get { return Config.Get("Bridge.DemoDressIdocDelaySeconds", 150); }
}
// ── 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"
};
/// <summary>
/// One guild to build, and how many of the seeded characters to put in it.
///
/// Four rather than one, and four of different sizes, because every screen that
/// shows guilds shows a LIST: a board with one row proves nothing about sorting,
/// member counts or the online column. The sizes are the shape a small shard
/// actually has — one large guild, one middling, two small.
/// </summary>
private struct GuildPlan
{
public readonly string Name;
public readonly string Abbr;
public readonly int Size;
public GuildPlan(string name, string abbr, int size)
{
Name = name;
Abbr = abbr;
Size = size;
}
}
private static readonly GuildPlan[] GuildsToBuild =
{
new GuildPlan("The Ashen Compact", "ASH", 14),
new GuildPlan("Hollowell Rangers", "HOL", 9),
new GuildPlan("The Quiet Ledger", "QLG", 6),
new GuildPlan("Wardens of Northrest", "WRD", 4)
};
/// <summary>
/// The first two guilds are allied, because `/uo/guilds` promises "rosters,
/// alliances and who's online" and an alliance column that is empty on every row
/// reads as a feature that does not work.
/// </summary>
private const string AllianceName = "The Northern Compact";
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();
// After the rename, never before: the roster the bridge publishes is the one
// that exists when the guild's member set first changes, and that is here.
int guilds = BuildGuilds(to);
bool password = SetLoginPassword(to);
Report(to, String.Format(
"Dressed {0} characters, {1} vendors, {2} house signs; " +
"built {3} guilds; login password {4}. ({5:F1}s)",
chars, vendors, houses, guilds, password ? "set" : "skipped",
(DateTime.UtcNow - start).TotalSeconds));
// IDOC is two steps, and at boot the second one is LATE. See PrimeIdoc.
Report(to, "Primed " + PrimeIdoc() + " houses for decay.");
if (save)
Timer.DelayCall(
TimeSpan.FromSeconds(IdocDelaySeconds),
() => Report(to, "Staged " + StageIdoc() + " houses into IDOC."));
else
Report(to, "Staged " + StageIdoc() + " houses into IDOC.");
if (save)
{
Report(to, "Saving world...");
World.Save();
Report(to, "Save complete.");
}
}
catch (Exception ex)
{
Report(to, "FAILED: " + ex);
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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>(PlayerVendor.PlayerVendors);
foreach (var vendor in vendors)
{
bool touched = false;
// "Bridge Test Shop" is not the seeder's — it is left over from a hand-run
// smoke test — and it reaches the marketplace exactly like the rest.
if (LooksSeeded(vendor.ShopName, "Seed Shop") ||
LooksSeeded(vendor.ShopName, "Bridge Test") ||
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;
}
/// <summary>
/// The houses this run picked to walk into IDOC, held between the two passes so the
/// second one moves the same houses the first one primed.
/// </summary>
private static readonly List<BaseHouse> _idocPicks = new List<BaseHouse>();
/// <summary>
/// Picks the houses that will collapse and puts them at a MIDDLE decay stage.
///
/// 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.
///
/// WHY THE STAGING IS TWO PASSES, WITH A WAIT BETWEEN THEM
/// ------------------------------------------------------
/// The website's "Houses in danger" page reads a column the ingest only writes when
/// the plugin reports a house CHANGING decay stage (`house.decay`). The richer
/// `house.update` registry frame carries the stage as well, but the ingest
/// deliberately leaves that column to the transition feed so the two cannot clobber
/// each other. A house that is ALREADY in IDOC when the site connects therefore
/// never appears: the plugin's baseline records IDOC as the starting state and no
/// transition is ever emitted. The first run of this pass hit exactly that — the
/// shard plainly had two collapsing houses and the page said none.
///
/// So: prime now, collapse later. The sweep takes its baseline at the middle stage
/// and then sees a real move to IDOC, which is the event the page is built to show.
/// The underlying asymmetry is a product observation, raised rather than patched
/// from here.
/// </summary>
private static int PrimeIdoc()
{
_idocPicks.Clear();
foreach (var house in BaseHouse.AllHouses)
{
if (_idocPicks.Count >= IdocHouses)
break;
if (house == null || house.Deleted || !house.CanDecay)
continue;
_idocPicks.Add(house);
}
// Most of the world cannot decay at all: a house whose owner's account is active
// is AutoRefresh, and AutoRefresh reports Ageless forever. The seeder condemned
// its houses by backdating the owner's last login, which is the same lever a real
// shard pulls when somebody stops playing — so where there are not enough
// candidates, condemn a few more the same way rather than forcing a stage that
// the next refresh would undo.
if (_idocPicks.Count < IdocHouses)
{
foreach (var house in BaseHouse.AllHouses)
{
if (_idocPicks.Count >= IdocHouses)
break;
if (house == null || house.Deleted || house.CanDecay || house.Owner == null)
continue;
var acct = house.Owner.Account as Account;
// Never the account somebody is about to log in with: an inactive account
// is exactly what this is making, and logging in would undo it anyway.
if (acct == null || acct.Username == LoginAccount)
continue;
acct.LastLogin = DateTime.UtcNow - TimeSpan.FromDays(365);
if (house.CanDecay)
_idocPicks.Add(house);
}
}
foreach (var house in _idocPicks)
{
house.SetDynamicDecay(DecayLevel.Fairly);
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
}
return _idocPicks.Count;
}
/// <summary>Collapses the primed houses. See <see cref="PrimeIdoc"/> for the two-step.</summary>
private static int StageIdoc()
{
int n = 0;
foreach (var house in _idocPicks)
{
if (house == null || house.Deleted)
continue;
// Alternating, so the page shows a stage column doing something rather than
// four identical rows.
house.SetDynamicDecay(n % 2 == 0 ? DecayLevel.IDOC : DecayLevel.Greatly);
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
n++;
}
return n;
}
/// <summary>
/// Builds the guilds in <see cref="GuildsToBuild"/> out of seeded characters that
/// are not in a guild already, and allies the first two.
///
/// Idempotent by NAME: a guild that already exists is left exactly as it is, so a
/// second run adds nobody and a guild somebody has since edited in game is not
/// stamped back to the table. A character already in a guild is never moved, which
/// is what keeps a re-run from shuffling the world between screenshots.
///
/// Ranks are set rather than left at the default, because the roster the site draws
/// shows a rank per member and a page where every row says the same word tells a
/// reader nothing about what ranks are for. Real guilds are mostly members with a
/// couple of officers, so that is what this makes.
/// </summary>
private static int BuildGuilds(Mobile to)
{
var pool = UnguildedSeedCharacters();
var cursor = 0;
var made = 0;
var built = new List<Guild>();
foreach (var plan in GuildsToBuild)
{
var existing = FindGuild(plan.Name);
if (existing != null)
{
built.Add(existing);
continue;
}
if (cursor >= pool.Count)
{
Report(to, "Ran out of unguilded characters — " + plan.Name + " not built.");
break;
}
var leader = pool[cursor++];
var guild = new Guild(leader, plan.Name, plan.Abbr);
for (int i = 1; i < plan.Size && cursor < pool.Count; i++)
{
var member = pool[cursor++];
guild.AddMember(member);
var pm = member as PlayerMobile;
if (pm == null)
continue;
// Two officers per guild, then members. RankDefinition.Ranks is
// { Ronin, Member, Emissary, Warlord, Leader } — Ronin is the default a
// fresh member gets, and a board of Ronins looks like nobody has ever
// touched the guild.
pm.GuildRank =
i == 1 ? RankDefinition.Ranks[3] :
i == 2 ? RankDefinition.Ranks[2] :
RankDefinition.Member;
}
built.Add(guild);
made++;
}
if (built.Count >= 2 && built[0].Alliance == null && built[1].Alliance == null)
{
try
{
var alliance = new AllianceInfo(built[0], AllianceName, built[1]);
alliance.TurnToMember(built[1]);
}
catch (Exception ex)
{
Report(to, "Alliance not formed: " + ex.Message);
}
}
return made;
}
/// <summary>
/// Every seeded character with no guild, in a stable order: account name, then
/// character slot. Stable ordering is what makes the same person lead the same
/// guild on every run against the same save.
/// </summary>
private static List<Mobile> UnguildedSeedCharacters()
{
var accounts = new List<Account>();
foreach (Account acct in Accounts.GetAccounts())
{
if (acct.Username.StartsWith(Prefix, StringComparison.Ordinal))
accounts.Add(acct);
}
accounts.Sort((a, b) => String.CompareOrdinal(a.Username, b.Username));
var chars = new List<Mobile>();
foreach (var acct in accounts)
{
for (int i = 0; i < acct.Length; i++)
{
var m = acct[i];
if (m == null || m.Deleted || m.Guild != null)
continue;
chars.Add(m);
}
}
return chars;
}
private static Guild FindGuild(string name)
{
foreach (var bg in BaseGuild.List.Values)
{
var g = bg as Guild;
if (g != null && !g.Disbanded && g.Name == name)
return g;
}
return null;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}