Files
servuo-plugins/overlay/Scripts/Custom/BridgeSeeder.cs
Claude 2c51bc720c Phase 0: fix runtime script compilation
ScriptCompiler.Compile() runs `dotnet build Scripts/Scripts.csproj -c Release`
with no Platform, so MSBuild defaults to AnyCPU. Scripts.csproj gated both
OutputPath and DefineConstants on Configuration|Platform == Release|x64, so
under the server's own build the DLL landed in Scripts/bin/Release/ (while the
core loads Scripts.dll from the base directory) and TRACE;NEWTIMERS;ServUO went
undefined (XmlSpawner compiled its non-ServUO branches).

Compile() also never checks the build's exit code before Assembly.LoadFrom, so
the failure was silent and the stale DLL reloaded. Runtime script compilation
had had no effect since 2026-05-30.

Condition both property groups on Configuration alone. Server.csproj is left
alone: nothing under Server/ uses those symbols, and giving it OutputPath=..\
would make the boot-time build try to overwrite the running ServUO.exe.

Verified end-to-end: a plain boot now logs "Core: Compiling scripts... / Build
succeeded." and loads 206208 items, 42771 mobiles.

Also adds the implementation plan, the measured performance budget, the test
scaffolding used to produce it (seeder + probe, both default-off), and the
record of shard repairs that had to precede any of this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 04:34:18 -05:00

456 lines
16 KiB
C#

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