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,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);
}
}
}
}