Move test scaffolding out of the deploy path

BridgeSeeder and BridgeProbe are not part of the bridge, but living under
overlay/Scripts/Custom/ meant deploy.ps1 would copy them onto any server we
deployed to. Move them to tools/scaffolding/, which nothing syncs.

overlay/Config/Bridge.cfg now carries the bridge's own settings (host, port,
queue cap, sweep intervals) rather than the scaffolding flags. Config.Get
returns false for a missing key, so a deployed server never runs the seeder or
probe even if their .cs files are present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 04:40:20 -05:00
parent 710dd17745
commit 0ef756a93a
5 changed files with 87 additions and 20 deletions

View File

@@ -0,0 +1,405 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using Server.Accounting;
using Server.Items;
using Server.Mobiles;
using Server.Multis;
namespace Server.Custom
{
/// <summary>
/// Measures the main-thread cost of every read the bridge plugin would perform, against
/// whatever world is currently loaded. Read-only. Test scaffolding, not part of the bridge.
///
/// Everything here runs on the Core thread, which is exactly where the real plugin's
/// reads must run, so these timings are the ones that matter for frame budget.
/// </summary>
public static class BridgeProbe
{
private const int Iterations = 20;
private static readonly AosAttribute[] AllAttrs =
(AosAttribute[])Enum.GetValues(typeof(AosAttribute));
private static readonly AosWeaponAttribute[] AllWeaponAttrs =
(AosWeaponAttribute[])Enum.GetValues(typeof(AosWeaponAttribute));
private static readonly AosArmorAttribute[] AllArmorAttrs =
(AosArmorAttribute[])Enum.GetValues(typeof(AosArmorAttribute));
public static void Initialize()
{
if (Config.Get("Bridge.ProbeOnStart", false))
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(2.0), Run);
}
private static void Log(string fmt, params object[] args)
{
Console.WriteLine("[BridgeProbe] " + String.Format(fmt, args));
}
private static void Run()
{
try
{
var players = CollectSeededChars();
var houses = BaseHouse.AllHouses;
var vendors = PlayerVendor.PlayerVendors ?? new List<PlayerVendor>();
Log("world: {0} seeded chars, {1} houses, {2} vendors, {3} accounts",
players.Count, houses.Count, vendors.Count, Accounting.Accounts.Count);
Log("thread: {0} (id {1})",
System.Threading.Thread.CurrentThread.Name,
System.Threading.Thread.CurrentThread.ManagedThreadId);
Log("");
// ---- one full character profile, the heaviest single read ----
var sb = new StringBuilder(8192);
double perProfile = Time(() =>
{
for (int i = 0; i < players.Count; i++)
{
sb.Clear();
WriteProfile(sb, players[i]);
}
}) / Math.Max(1, players.Count);
sb.Clear();
if (players.Count > 0)
WriteProfile(sb, players[0]);
int profileBytes = sb.Length;
Log("char.profile {0,8:F3} ms/char {1,6} bytes json -> {2:F1} ms for all {3}",
perProfile, profileBytes, perProfile * players.Count, players.Count);
// ---- vitals: the 30s sweep the doc proposes ----
double vitals = Time(() =>
{
var b = new StringBuilder(256);
for (int i = 0; i < players.Count; i++)
{
b.Clear();
WriteVitals(b, players[i]);
}
});
Log("vitals sweep {0,8:F3} ms for {1} chars ({2:F4} ms/char)",
vitals, players.Count, vitals / Math.Max(1, players.Count));
// ---- house decay sweep (III.3) ----
double decay = Time(() =>
{
for (int i = 0; i < houses.Count; i++)
{
var lvl = houses[i].DecayLevel;
GC.KeepAlive(lvl);
}
});
Log("decay sweep {0,8:F3} ms for {1} houses ({2:F4} ms/house)",
decay, houses.Count, decay / Math.Max(1, houses.Count));
// ---- economy: money supply snapshot ----
double econ = 0;
double total = 0;
econ = Time(() =>
{
total = 0;
foreach (Account a in Accounting.Accounts.GetAccounts())
total += a.TotalCurrency;
});
Log("economy sweep {0,8:F3} ms for {1} accounts (supply {2:N0} gold)",
econ, Accounting.Accounts.Count, total * Account.CurrencyThreshold);
// ---- player vendor snapshot ----
int listings = 0;
double vend = Time(() =>
{
listings = 0;
var b = new StringBuilder(4096);
for (int i = 0; i < vendors.Count; i++)
{
b.Clear();
listings += WriteVendor(b, vendors[i]);
}
});
Log("vendor snap {0,8:F3} ms for {1} vendors ({2} listings)",
vend, vendors.Count, listings);
Log("");
Log("--- extrapolation (linear, same gear complexity) ---");
Log(" vitals sweep @ 200 online: {0,7:F2} ms", vitals / Math.Max(1, players.Count) * 200);
Log(" vitals sweep @ 1000 online: {0,7:F2} ms", vitals / Math.Max(1, players.Count) * 1000);
Log(" profiles for 1000 chars : {0,7:F1} ms <-- never do this in a sweep",
perProfile * 1000);
Log(" decay sweep @ 2000 houses: {0,7:F2} ms", decay / Math.Max(1, houses.Count) * 2000);
Log(" economy @ 5000 accts : {0,7:F2} ms", econ / Math.Max(1, Accounting.Accounts.Count) * 5000);
}
catch (Exception ex)
{
Log("FAILED: " + ex);
}
}
/// <summary>Best-of-N: the minimum is the least noisy estimate of true cost.</summary>
private static double Time(Action action)
{
action(); // warm up JIT and caches
double best = double.MaxValue;
var sw = new Stopwatch();
for (int i = 0; i < Iterations; i++)
{
sw.Restart();
action();
sw.Stop();
double ms = sw.Elapsed.TotalMilliseconds;
if (ms < best)
best = ms;
}
return best;
}
private static List<PlayerMobile> CollectSeededChars()
{
var list = new List<PlayerMobile>();
foreach (Account a in Accounting.Accounts.GetAccounts())
{
if (!a.Username.StartsWith("seed_", StringComparison.Ordinal))
continue;
for (int i = 0; i < a.Length; i++)
{
var pm = a[i] as PlayerMobile;
if (pm != null)
list.Add(pm);
}
}
return list;
}
private static void WriteVitals(StringBuilder sb, PlayerMobile m)
{
sb.Append("{\"kind\":\"char.vitals\",\"serial\":\"0x");
sb.Append(m.Serial.Value.ToString("X"));
sb.Append("\",\"hits\":").Append(m.Hits);
sb.Append(",\"hitsMax\":").Append(m.HitsMax);
sb.Append(",\"mana\":").Append(m.Mana);
sb.Append(",\"stam\":").Append(m.Stam);
sb.Append(",\"str\":").Append(m.Str);
sb.Append(",\"dex\":").Append(m.Dex);
sb.Append(",\"int\":").Append(m.Int);
sb.Append(",\"x\":").Append(m.X);
sb.Append(",\"y\":").Append(m.Y);
sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
sb.Append('}');
}
private static void WriteProfile(StringBuilder sb, PlayerMobile m)
{
sb.Append("{\"kind\":\"char.profile\",\"serial\":\"0x");
sb.Append(m.Serial.Value.ToString("X"));
sb.Append("\",\"name\":\"").Append(m.Name).Append('"');
sb.Append(",\"body\":").Append(m.Body.BodyID);
sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
sb.Append(",\"stats\":{\"str\":").Append(m.Str);
sb.Append(",\"dex\":").Append(m.Dex);
sb.Append(",\"int\":").Append(m.Int);
sb.Append(",\"hits\":").Append(m.Hits).Append(",\"hitsMax\":").Append(m.HitsMax);
sb.Append(",\"mana\":").Append(m.Mana).Append(",\"manaMax\":").Append(m.ManaMax);
sb.Append(",\"stam\":").Append(m.Stam).Append(",\"stamMax\":").Append(m.StamMax);
sb.Append(",\"fame\":").Append(m.Fame).Append(",\"karma\":").Append(m.Karma);
sb.Append(",\"luck\":").Append(m.Luck);
sb.Append(",\"resist\":{\"phys\":").Append(m.PhysicalResistance);
sb.Append(",\"fire\":").Append(m.FireResistance);
sb.Append(",\"cold\":").Append(m.ColdResistance);
sb.Append(",\"pois\":").Append(m.PoisonResistance);
sb.Append(",\"energy\":").Append(m.EnergyResistance).Append("}}");
sb.Append(",\"skills\":[");
bool first = true;
for (int i = 0; i < m.Skills.Length; i++)
{
var s = m.Skills[i];
if (s.Base <= 0.0)
continue; // untrained: the bridge should not ship ~50 zeroes per char
if (!first)
sb.Append(',');
first = false;
sb.Append("{\"n\":\"").Append(s.SkillName).Append('"');
sb.Append(",\"base\":").Append(s.Base.ToString("F1"));
sb.Append(",\"value\":").Append(s.Value.ToString("F1"));
sb.Append(",\"cap\":").Append(s.Cap.ToString("F1"));
sb.Append(",\"lock\":\"").Append(s.Lock).Append("\"}");
}
sb.Append(']');
sb.Append(",\"equipment\":[");
first = true;
foreach (var item in m.Items)
{
if (item.Layer == Layer.Backpack || item.Layer == Layer.Bank ||
item.Layer == Layer.Hair || item.Layer == Layer.FacialHair ||
item.Layer == Layer.Mount)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X"));
sb.Append("\",\"layer\":\"").Append(item.Layer).Append('"');
sb.Append(",\"itemId\":").Append(item.ItemID);
sb.Append(",\"hue\":").Append(item.Hue);
sb.Append(",\"cliloc\":").Append(item.LabelNumber);
if (item.Name != null)
sb.Append(",\"name\":\"").Append(item.Name).Append('"');
sb.Append(",\"mods\":{");
bool m1 = true;
var weapon = item as BaseWeapon;
var armor = item as BaseArmor;
if (weapon != null)
{
sb.Append("\"minDamage\":").Append(weapon.MinDamage);
sb.Append(",\"maxDamage\":").Append(weapon.MaxDamage);
m1 = false;
WriteAttrs(sb, weapon.Attributes, ref m1);
WriteWeaponAttrs(sb, weapon.WeaponAttributes, ref m1);
}
else if (armor != null)
{
sb.Append("\"baseRating\":").Append(armor.BaseArmorRating);
m1 = false;
WriteAttrs(sb, armor.Attributes, ref m1);
WriteArmorAttrs(sb, armor.ArmorAttributes, ref m1);
}
sb.Append("}}");
}
sb.Append(']');
sb.Append('}');
}
private static void WriteAttrs(StringBuilder sb, AosAttributes a, ref bool first)
{
if (a == null)
return;
for (int i = 0; i < AllAttrs.Length; i++)
{
int v = a[AllAttrs[i]];
if (v == 0)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append('"').Append(AllAttrs[i]).Append("\":").Append(v);
}
}
private static void WriteWeaponAttrs(StringBuilder sb, AosWeaponAttributes a, ref bool first)
{
if (a == null)
return;
for (int i = 0; i < AllWeaponAttrs.Length; i++)
{
int v = a[AllWeaponAttrs[i]];
if (v == 0)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append('"').Append(AllWeaponAttrs[i]).Append("\":").Append(v);
}
}
private static void WriteArmorAttrs(StringBuilder sb, AosArmorAttributes a, ref bool first)
{
if (a == null)
return;
for (int i = 0; i < AllArmorAttrs.Length; i++)
{
int v = a[AllArmorAttrs[i]];
if (v == 0)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append('"').Append(AllArmorAttrs[i]).Append("\":").Append(v);
}
}
private static int WriteVendor(StringBuilder sb, PlayerVendor v)
{
int count = 0;
sb.Append("{\"kind\":\"vendor.snapshot\",\"serial\":\"0x");
sb.Append(v.Serial.Value.ToString("X"));
sb.Append("\",\"holdGold\":").Append(v.HoldGold);
sb.Append(",\"listings\":[");
var pack = v.Backpack;
if (pack != null)
{
bool first = true;
foreach (var item in pack.Items)
{
var vi = v.GetVendorItem(item);
if (vi == null)
continue;
if (!first)
sb.Append(',');
first = false;
count++;
sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X"));
sb.Append("\",\"itemId\":").Append(item.ItemID);
sb.Append(",\"price\":").Append(vi.Price);
sb.Append(",\"forSale\":").Append(vi.IsForSale ? "true" : "false");
sb.Append('}');
}
}
sb.Append("]}");
return count;
}
}
}

View File

@@ -0,0 +1,455 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Server.Accounting;
using Server.Commands;
using Server.Items;
using Server.Mobiles;
using Server.Multis;
namespace Server.Custom
{
/// <summary>
/// Populates a test shard with synthetic accounts, characters, houses and player
/// vendors so the ServUO/sidecar bridge can be exercised at a realistic scale.
///
/// Test scaffolding. Not part of the bridge. Remove before production use.
///
/// A house only reaches IDOC when BaseHouse.CanDecay is true, and CanDecay is true
/// only for DecayType.Condemned or DecayType.ManualRefresh. An active owner's newest
/// house is AutoRefresh, which never decays. So the decaying houses below are given
/// to accounts whose LastLogin is backdated past Account.InactiveDuration (180 days),
/// which makes them Condemned.
///
/// Decay stage is then forced with SetDynamicDecay rather than by backdating
/// LastRefreshed: this shard is EJ, so Core.ML is true, so DynamicDecay.Enabled is
/// true and BaseHouse.GetOldDecayLevel (the percentage-of-DecayPeriod model) is never
/// reached.
/// </summary>
public static class BridgeSeeder
{
private const string Prefix = "seed_";
private const int Accounts = 50;
private const int CharsPerAccount = 3;
private const int ActiveHouses = 12; // healthy, owned by active accounts
private const int DecayingHouses = 18; // owned by inactive accounts, staged below
private const int VendorHouses = 15;
private const int VendorsPerHouse = 2;
private const int ItemsPerVendor = 40;
private static readonly Point3D HouseOrigin = new Point3D(1400, 1600, 0);
private const int HouseSpacing = 40;
private const int HousesPerRow = 6;
// Spread the decaying houses across stages so a transition sweep sees variety.
private static readonly DecayLevel[] DecayStages =
{
DecayLevel.Slightly, DecayLevel.Somewhat, DecayLevel.Fairly,
DecayLevel.Greatly, DecayLevel.IDOC, DecayLevel.IDOC
};
private static readonly Random Rng = new Random(20260710);
// VendorItem.Price is get-only and PlayerVendor.SetVendorItem is private, so a seeded
// vendor would otherwise be stuck at the 999 default that OnSubItemAdded assigns.
private static readonly MethodInfo SetVendorItemMethod = typeof(PlayerVendor).GetMethod(
"SetVendorItem",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
new[] { typeof(Item), typeof(int), typeof(string) },
null);
public static void Initialize()
{
CommandSystem.Register("seedworld", AccessLevel.Administrator, Seed_OnCommand);
CommandSystem.Register("unseedworld", AccessLevel.Administrator, Unseed_OnCommand);
if (Config.Get("Bridge.SeedOnStart", false))
EventSink.ServerStarted += () => Run(null, save: true);
if (Config.Get("Bridge.CensusOnStart", false))
EventSink.ServerStarted += Census;
}
/// <summary>
/// Reports what the seeded world actually contains after a load, rather than what
/// the seeder intended to create. Read-only.
/// </summary>
private static void Census()
{
try
{
var byLevel = new Dictionary<DecayLevel, int>();
foreach (var house in BaseHouse.AllHouses)
{
var level = house.DecayLevel;
int n;
byLevel.TryGetValue(level, out n);
byLevel[level] = n + 1;
}
Console.WriteLine("[BridgeSeeder] houses={0}", BaseHouse.AllHouses.Count);
foreach (var kv in byLevel)
Console.WriteLine("[BridgeSeeder] decay {0,-18} {1}", kv.Key, kv.Value);
int chars = 0, totalEquipped = 0, naked = 0;
foreach (Account a in Accounting.Accounts.GetAccounts())
{
if (!a.Username.StartsWith(Prefix, StringComparison.Ordinal))
continue;
for (int i = 0; i < a.Length; i++)
{
var m = a[i];
if (m == null)
continue;
chars++;
int worn = 0;
foreach (var item in m.Items)
{
if (item.Layer != Layer.Backpack && item.Layer != Layer.Bank &&
item.Layer != Layer.Hair && item.Layer != Layer.FacialHair)
worn++;
}
totalEquipped += worn;
if (worn == 0)
naked++;
}
}
Console.WriteLine("[BridgeSeeder] seeded chars={0} avgEquipped={1:F2} naked={2}",
chars, chars == 0 ? 0.0 : (double)totalEquipped / chars, naked);
Console.WriteLine("[BridgeSeeder] playervendors={0}",
PlayerVendor.PlayerVendors == null ? 0 : PlayerVendor.PlayerVendors.Count);
}
catch (Exception ex)
{
Console.WriteLine("[BridgeSeeder] census failed: " + ex);
}
}
[Usage("seedworld")]
[Description("Populates the shard with synthetic bridge-test accounts, houses and vendors.")]
private static void Seed_OnCommand(CommandEventArgs e)
{
Run(e.Mobile, save: false);
}
[Usage("unseedworld")]
[Description("Deletes everything created by [seedworld.")]
private static void Unseed_OnCommand(CommandEventArgs e)
{
Unseed(e.Mobile);
}
private static void Report(Mobile to, string text)
{
Console.WriteLine("[BridgeSeeder] " + text);
if (to != null)
to.SendMessage(text);
}
private static void Run(Mobile to, bool save)
{
try
{
if (Accounting.Accounts.GetAccount(Prefix + "000") != null)
{
Report(to, "Seed data already present. Run [unseedworld first.");
return;
}
var start = DateTime.UtcNow;
var seeded = Seed();
var elapsed = DateTime.UtcNow - start;
Report(to, String.Format(
"Seeded {0} accounts, {1} chars, {2} houses, {3} vendors in {4:F1}s.",
seeded.AccountCount, seeded.CharCount, seeded.HouseCount,
seeded.VendorCount, elapsed.TotalSeconds));
if (save)
{
Report(to, "Saving world...");
World.Save();
Report(to, "Save complete.");
}
}
catch (Exception ex)
{
Report(to, "FAILED: " + ex);
}
}
private class Counts
{
public int AccountCount, CharCount, HouseCount, VendorCount;
}
private static Counts Seed()
{
var counts = new Counts();
var accounts = new List<Account>();
var owners = new List<PlayerMobile>();
for (int i = 0; i < Accounts; i++)
{
var acct = new Account(String.Format("{0}{1:000}", Prefix, i), Guid.NewGuid().ToString("N"));
acct.DepositGold(Utility.RandomMinMax(5000, 4000000));
accounts.Add(acct);
counts.AccountCount++;
for (int c = 0; c < CharsPerAccount; c++)
{
var pm = CreateChar(acct, c);
acct[c] = pm;
counts.CharCount++;
if (c == 0)
owners.Add(pm);
}
}
// Houses. The first ActiveHouses go to accounts left active; the remainder go to
// accounts backdated into inactivity so their houses are Condemned and will decay.
int placed = 0;
for (int i = 0; i < ActiveHouses && i < owners.Count; i++, placed++)
{
PlaceHouse(owners[i], placed);
counts.HouseCount++;
}
for (int i = 0; i < DecayingHouses && (ActiveHouses + i) < owners.Count; i++, placed++)
{
var owner = owners[ActiveHouses + i];
var house = PlaceHouse(owner, placed);
counts.HouseCount++;
// Condemn the account: LastLogin older than Account.InactiveDuration (180d).
var acct = (Account)owner.Account;
acct.LastLogin = DateTime.UtcNow - TimeSpan.FromDays(200 + i);
// Force the stage directly; DynamicDecay owns the model on this shard.
var stage = DecayStages[i % DecayStages.Length];
house.SetDynamicDecay(stage);
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
}
// Vendors on the first VendorHouses placed.
var allHouses = new List<BaseHouse>();
foreach (var pm in owners)
allHouses.AddRange(BaseHouse.GetHouses(pm));
for (int h = 0; h < VendorHouses && h < allHouses.Count; h++)
{
var house = allHouses[h];
for (int v = 0; v < VendorsPerHouse; v++)
{
CreateVendor(house);
counts.VendorCount++;
}
}
return counts;
}
private static PlayerMobile CreateChar(Account acct, int slot)
{
var pm = new PlayerMobile
{
Player = true,
AccessLevel = AccessLevel.Player,
Name = String.Format("Seed{0}{1}", acct.Username.Substring(Prefix.Length), (char)('A' + slot)),
Female = Utility.RandomBool(),
Hue = Utility.RandomSkinHue(),
Fame = Utility.RandomMinMax(0, 15000),
Karma = Utility.RandomMinMax(-15000, 15000)
};
pm.Body = pm.Female ? 401 : 400;
// Str must clear the plate requirements in EquipGear (PlateChest needs 95), or
// BaseArmor.CanEquip refuses and the gear is left parentless for Cleanup to sweep.
pm.RawStr = Utility.RandomMinMax(100, 125);
pm.RawDex = Utility.RandomMinMax(25, 125);
pm.RawInt = Utility.RandomMinMax(25, 125);
pm.Hits = pm.HitsMax;
pm.Mana = pm.ManaMax;
pm.Stam = pm.StamMax;
// Full skill sheet: the bridge's profile export reads every skill.
for (int i = 0; i < pm.Skills.Length; i++)
{
pm.Skills[i].Base = Rng.Next(100) < 20 ? Utility.RandomMinMax(60, 120) : 0.0;
pm.Skills[i].Cap = 120.0;
}
var pack = new Backpack { Movable = false };
pm.AddItem(pack);
EquipGear(pm);
pm.MoveToWorld(
new Point3D(HouseOrigin.X + Utility.RandomMinMax(-50, 50),
HouseOrigin.Y + Utility.RandomMinMax(-50, 50), 0),
Map.Felucca);
return pm;
}
/// <summary>
/// Gear carries AOS attribute bags. The profile exporter has to walk these, so a
/// seeded character must have them populated or the cost measurement is meaningless.
/// </summary>
private static void EquipGear(PlayerMobile pm)
{
Equip(pm, MakeWeapon());
Equip(pm, Decorate(new PlateChest()));
Equip(pm, Decorate(new PlateLegs()));
Equip(pm, Decorate(new PlateHelm()));
Equip(pm, Decorate(new PlateArms()));
Equip(pm, Decorate(new PlateGloves()));
Equip(pm, new Boots(Utility.RandomNeutralHue()));
Equip(pm, new Cloak(Utility.RandomNeutralHue()));
}
/// <summary>
/// A rejected EquipItem leaves the item in World.Items with no parent, which the
/// Cleanup pass later deletes en masse. Drop it immediately instead.
/// </summary>
private static void Equip(PlayerMobile pm, Item item)
{
if (!pm.EquipItem(item))
{
Console.WriteLine("[BridgeSeeder] equip rejected: {0} on {1}", item.GetType().Name, pm.Name);
item.Delete();
}
}
private static BaseWeapon MakeWeapon()
{
BaseWeapon w;
switch (Utility.Random(3))
{
case 0: w = new Longsword(); break;
case 1: w = new Katana(); break;
default: w = new Broadsword(); break;
}
w.Hue = Utility.RandomNeutralHue();
w.Attributes.WeaponDamage = Utility.RandomMinMax(10, 50);
w.Attributes.AttackChance = Utility.RandomMinMax(5, 15);
w.Attributes.DefendChance = Utility.RandomMinMax(5, 15);
w.Attributes.BonusHits = Utility.RandomMinMax(1, 8);
w.WeaponAttributes.HitLightning = Utility.RandomMinMax(10, 50);
w.WeaponAttributes.HitLeechHits = Utility.RandomMinMax(10, 40);
return w;
}
private static BaseArmor Decorate(BaseArmor a)
{
a.Hue = Utility.RandomNeutralHue();
a.Attributes.BonusHits = Utility.RandomMinMax(1, 6);
a.Attributes.LowerManaCost = Utility.RandomMinMax(1, 8);
a.ArmorAttributes.SelfRepair = Utility.RandomMinMax(1, 5);
return a;
}
private static BaseHouse PlaceHouse(PlayerMobile owner, int index)
{
int x = HouseOrigin.X + (index % HousesPerRow) * HouseSpacing;
int y = HouseOrigin.Y + (index / HousesPerRow) * HouseSpacing;
// Placement validation (HousePlacement.Check) is deliberately bypassed: the bridge
// only reads serial/owner/coords/decay off these, never their terrain validity.
var house = new SmallOldHouse(owner, 0x64);
house.MoveToWorld(new Point3D(x, y, 0), Map.Felucca);
house.BuiltOn = DateTime.UtcNow - TimeSpan.FromDays(Utility.RandomMinMax(10, 400));
house.LastRefreshed = house.BuiltOn;
if (house.Sign != null)
house.Sign.Name = String.Format("Seed House {0}", index);
return house;
}
private static void CreateVendor(BaseHouse house)
{
var owner = house.Owner;
var vendor = new PlayerVendor(owner, house)
{
Name = "seed vendor",
ShopName = String.Format("Seed Shop {0}", Utility.Random(1000))
};
vendor.MoveToWorld(
new Point3D(house.X + Utility.RandomMinMax(-2, 2),
house.Y + Utility.RandomMinMax(-2, 2), house.Z),
house.Map);
vendor.HoldGold = Utility.RandomMinMax(1000, 250000);
for (int i = 0; i < ItemsPerVendor; i++)
{
Item item = MakeWeapon();
// Dropping into the pack fires OnSubItemAdded, which registers a VendorItem
// at the default price of 999; then correct the price.
vendor.Backpack.DropItem(item);
if (SetVendorItemMethod != null)
SetVendorItemMethod.Invoke(vendor, new object[] { item, Utility.RandomMinMax(50, 75000), "" });
}
}
private static void Unseed(Mobile to)
{
try
{
var doomed = new List<Account>();
foreach (Account a in Accounting.Accounts.GetAccounts())
{
if (a.Username.StartsWith(Prefix, StringComparison.Ordinal))
doomed.Add(a);
}
// Account.Delete also deletes the account's characters and their houses.
foreach (var a in doomed)
a.Delete();
Report(to, String.Format("Removed {0} seed accounts (chars and houses included).", doomed.Count));
}
catch (Exception ex)
{
Report(to, "FAILED: " + ex);
}
}
}
}

View File

@@ -0,0 +1,63 @@
# Test scaffolding
**Not part of the bridge. Never deployed.** `deploy.ps1` only copies `overlay/`, so nothing here reaches a server unless you put it there by hand.
These two scripts produced the measured budget in `docs/PLAN.md` §1. They are kept because those numbers should be reproducible, and because re-running the probe is the only honest way to check whether a change to the plugin's read path got more expensive.
| File | Server path when testing | What |
|------|--------------------------|------|
| `BridgeSeeder.cs` | `Scripts/Custom/BridgeSeeder.cs` | Populates a synthetic world: 50 accounts, 150 characters, 30 houses, 30 player vendors with 40 listings each. |
| `BridgeProbe.cs` | `Scripts/Custom/BridgeProbe.cs` | Times every read the plugin performs, on the Core thread. Read-only. |
## Using them
Copy both into `Scripts/Custom/`, then append the flags to `Config/Bridge.cfg`:
```ini
SeedOnStart=True
CensusOnStart=False
ProbeOnStart=False
```
Boot once to seed and save, then set `SeedOnStart=False`. `CensusOnStart` reports what the loaded world actually contains; `ProbeOnStart` prints timings two seconds after `ServerStarted`.
Because `Config.Get` returns `false` for a missing key, a server whose `Bridge.cfg` lacks these keys never runs the scaffolding — even if the `.cs` files are sitting in `Scripts/Custom/`. That is the safety net, not an excuse to ship them.
In-game, `[seedworld` and `[unseedworld` (Administrator) do the same work on a live shard.
## Back up `Saves/` first
`[seedworld` and `SeedOnStart` **write to the live world**. Copy `Saves/` somewhere outside the repo before running either. `Backups/Automatic` is rotated by `AutoSave.cs` and `Backups/Temp` is deleted outright, so neither is a safe destination.
`[unseedworld` deletes every `seed_*` account, which takes their characters and houses with it — but not necessarily their `PlayerVendor` mobiles. Restoring a backup is the reliable reset.
## What the seeder had to work around
Worth knowing before you trust its output:
- **Plate needs strength.** `BaseArmor.CanEquip` rejects when `from.Str < strReq` (`PlateChest` needs 95). A rejected `EquipItem` leaves the item parentless, and the `Cleanup` pass later deletes it en masse. The seeder gives characters `Str` 100125 and deletes any item whose equip is refused, rather than orphaning it.
- **`VendorItem.Price` is get-only** and `PlayerVendor.SetVendorItem` is private. Dropping an item into a vendor's pack fires `OnSubItemAdded`, which registers the item at the default price of 999. The seeder reaches `SetVendorItem` by reflection to set a real price. Acceptable in throwaway scaffolding; do not do this in the plugin.
- **Houses only decay when condemned.** `BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `ManualRefresh`. An active owner's newest house is `AutoRefresh` and never decays. The seeder backdates 18 accounts past `Account.InactiveDuration` (180 days) to condemn them, then forces stages with `SetDynamicDecay` — not by backdating `LastRefreshed`, because `DynamicDecay.Enabled` is true on this expansion and `GetOldDecayLevel` is unreachable.
## Reference output
Census after a fresh load of the seeded world:
```
[BridgeSeeder] houses=35
[BridgeSeeder] decay Ageless 13, Slightly 3, Somewhat 7, Fairly 3, Greatly 3, IDOC 6
[BridgeSeeder] seeded chars=150 avgEquipped=8.00 naked=0
[BridgeSeeder] playervendors=30
```
Probe, best-of-20 on the Core thread:
```
[BridgeProbe] char.profile 0.069 ms/char 2386 bytes json
[BridgeProbe] vitals sweep 0.223 ms for 150 chars (0.0015 ms/char)
[BridgeProbe] decay sweep 0.007 ms for 35 houses (0.0002 ms/house)
[BridgeProbe] economy sweep 0.001 ms for 51 accounts (supply 110,478,209 gold)
[BridgeProbe] vendor snap 0.343 ms for 30 vendors (1200 listings)
```
Seeded characters carry 8 items with ~6 mods each and ~12 trained skills. A real endgame character has more of both, so profile cost and payload are a **floor** — budget 24× for a fully-kitted character.