diff --git a/tools/scaffolding/BridgeDemoDress.cs b/tools/scaffolding/BridgeDemoDress.cs
index d991752..4b990d2 100644
--- a/tools/scaffolding/BridgeDemoDress.cs
+++ b/tools/scaffolding/BridgeDemoDress.cs
@@ -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`.
///
@@ -72,6 +89,15 @@ namespace Server.Custom
/// How many condemned houses to put back into the last two decay stages.
private const int IdocHouses = 4;
+ ///
+ /// How long after boot the second IDOC pass runs. See —
+ /// the delay is the whole point, not a politeness.
+ ///
+ 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"
};
+ ///
+ /// 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.
+ ///
+ 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)
+ };
+
+ ///
+ /// 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.
+ ///
+ 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
}
///
- /// 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.
+ ///
+ private static readonly List _idocPicks = new List();
+
+ ///
+ /// 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.
///
+ 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;
+ }
+
+ /// Collapses the primed houses. See for the two-step.
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;
}
+ ///
+ /// Builds the guilds in 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.
+ ///
+ private static int BuildGuilds(Mobile to)
+ {
+ var pool = UnguildedSeedCharacters();
+ var cursor = 0;
+ var made = 0;
+
+ var built = new List();
+
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ private static List UnguildedSeedCharacters()
+ {
+ var accounts = new List();
+
+ 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();
+
+ 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;
+ }
+
///
/// 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.