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 { /// /// 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. /// 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; } /// /// Reports what the seeded world actually contains after a load, rather than what /// the seeder intended to create. Read-only. /// private static void Census() { try { var byLevel = new Dictionary(); 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(); var owners = new List(); 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(); 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; } /// /// 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. /// 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())); } /// /// A rejected EquipItem leaves the item in World.Items with no parent, which the /// Cleanup pass later deletes en masse. Drop it immediately instead. /// 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(); 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); } } } }