Files
servuo-plugins/tools/scaffolding/BridgeDemoDress.cs
Claude 158c0596d8 chore(tools): dress a seeded world so it can be screenshotted
BridgeSeeder builds a world at realistic scale, which is what the bridge needed;
it never needed the world to look like anything. So a vendor is `seed vendor`
trading as `Seed Shop 810`, a character is `Seed004A` and a house sign reads
`Seed House 12` — and every one of those strings travels the whole bridge and
lands on the marketplace, the guild roster and the housing pages of the website.
That is fine for a protocol test and wrong for the marketing site's screenshots
(runicgateway.com PLAN.md §13 phase 9, D42/D46).

BridgeDemoDress renames them in place and seeds nothing: prices, listing counts,
decay stages, fame and skills stay exactly as the seeder left them, so the data
keeps its provenance and only the strings a human reads change. Names come from
fixed tables hashed off each object's serial, so a re-run reproduces the same
world and screenshots can be retaken later and still match.

It also does two things the screenshots needed and nothing else provides:

- stages a few condemned houses back into the last decay levels, because decay
  is a live process and "Houses in danger" is empty by the time anyone looks
- sets a known password on seed_000, because logging a character in is the only
  way to make the online roster non-empty and the seeder assigns a random GUID.
  The password is read from Bridge.cfg, never compiled in.

Test scaffolding, in tools/, never deployed — deploy.ps1 copies overlay/ only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 19:22:46 -05:00

403 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using Server.Accounting;
using Server.Commands;
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
/// ---------------------------------------
/// - 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.
/// - 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.
///
/// 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;
// ── 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"
};
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();
int idoc = StageIdoc();
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",
(DateTime.UtcNow - start).TotalSeconds));
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;
if (LooksSeeded(vendor.ShopName, "Seed Shop") ||
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>
/// Puts a few condemned houses back into the final decay stages.
///
/// 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.
/// </summary>
private static int StageIdoc()
{
int n = 0;
foreach (var house in BaseHouse.AllHouses)
{
if (n >= IdocHouses)
break;
if (!house.CanDecay || house.DecayLevel == DecayLevel.IDOC)
continue;
house.SetDynamicDecay(n % 2 == 0 ? DecayLevel.IDOC : DecayLevel.Greatly);
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
n++;
}
return n;
}
/// <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;
}
}
}