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:
455
tools/scaffolding/BridgeSeeder.cs
Normal file
455
tools/scaffolding/BridgeSeeder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user