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>
456 lines
16 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|