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.