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>
This commit is contained in:
2026-08-25 09:35:53 -05:00
parent 8cf995f27f
commit fc4ebf0f5a

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using Server.Accounting;
using Server.Commands;
using Server.Guilds;
using Server.Mobiles;
using Server.Multis;
@@ -43,13 +44,29 @@ namespace Server.Custom
///
/// 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.
/// - 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>
@@ -72,6 +89,15 @@ namespace Server.Custom
/// <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
@@ -115,6 +141,43 @@ namespace Server.Custom
"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);
@@ -147,15 +210,29 @@ namespace Server.Custom
int chars = DressCharacters();
int vendors = DressVendors();
int houses = DressHouses();
int idoc = StageIdoc();
// 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; staged {3} IDOC; " +
"login password {4}. ({5:F1}s)",
chars, vendors, houses, idoc, password ? "set" : "skipped",
"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...");
@@ -295,7 +372,10 @@ namespace Server.Custom
{
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);
@@ -345,24 +425,100 @@ namespace Server.Custom
}
/// <summary>
/// Puts a few condemned houses back into the final decay stages.
/// 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 BaseHouse.AllHouses)
foreach (var house in _idocPicks)
{
if (n >= IdocHouses)
break;
if (!house.CanDecay || house.DecayLevel == DecayLevel.IDOC)
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++;
@@ -371,6 +527,135 @@ namespace Server.Custom
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.