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>
688 lines
28 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|