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>
This commit is contained in:
2026-07-10 04:34:18 -05:00
commit 2c51bc720c
6 changed files with 1024 additions and 0 deletions

75
deploy.ps1 Normal file
View File

@@ -0,0 +1,75 @@
<#
.SYNOPSIS
Copies overlay/ into a ServUO server root.
.DESCRIPTION
overlay/ mirrors the server root exactly, so deployment is a straight file copy.
Nothing is deleted from the server; this only adds or overwrites.
Run with -Verify first. It reports what would change and touches nothing.
.EXAMPLE
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo -Verify
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string] $ServerPath,
[switch] $Verify
)
$ErrorActionPreference = 'Stop'
$overlay = Join-Path $PSScriptRoot 'overlay'
if (-not (Test-Path $overlay)) { throw "overlay/ not found next to deploy.ps1" }
if (-not (Test-Path $ServerPath)) { throw "server path not found: $ServerPath" }
# ServUO.exe holds a lock on Scripts.dll and writes Saves/ on exit. Never deploy under it.
if (Get-Process -Name ServUO -ErrorAction SilentlyContinue) {
throw "ServUO is running. Stop it before deploying."
}
function Get-Sha([string] $path) {
if (-not (Test-Path $path)) { return $null }
return (Get-FileHash $path -Algorithm SHA256).Hash
}
$added = 0; $changed = 0; $same = 0
Get-ChildItem $overlay -Recurse -File | ForEach-Object {
$rel = $_.FullName.Substring($overlay.Length + 1)
$dst = Join-Path $ServerPath $rel
$srcHash = Get-Sha $_.FullName
$dstHash = Get-Sha $dst
if ($null -eq $dstHash) {
$state = 'ADD '; $added++
} elseif ($srcHash -ne $dstHash) {
$state = 'CHANGE '; $changed++
} else {
$state = 'same '; $same++
}
if ($state -ne 'same ') {
Write-Output "$state $rel"
}
if (-not $Verify -and $state -ne 'same ') {
$parent = Split-Path $dst -Parent
if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }
Copy-Item $_.FullName -Destination $dst -Force
}
}
Write-Output ""
if ($Verify) {
Write-Output "VERIFY only. add=$added change=$changed unchanged=$same (nothing written)"
} else {
Write-Output "deployed. add=$added change=$changed unchanged=$same"
Write-Output ""
Write-Output "Scripts.csproj changed => next boot rebuilds Scripts.dll (Compiler.cfg Dynamic=True)."
}

14
overlay/Config/Bridge.cfg Normal file
View File

@@ -0,0 +1,14 @@
# Settings for the ServUO/sidecar bridge test scaffolding.
# When true, BridgeSeeder populates the world with synthetic accounts, characters,
# houses and player vendors on ServerStarted, then saves. It refuses to run twice:
# if the account "seed_000" already exists it does nothing.
# Set back to False once the world is seeded.
SeedOnStart=False
# Read-only census of the seeded world on startup (see BridgeSeeder.Census).
CensusOnStart=False
# Times the bridge reads against the loaded world (see BridgeProbe).
ProbeOnStart=False

View File

@@ -0,0 +1,405 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using Server.Accounting;
using Server.Items;
using Server.Mobiles;
using Server.Multis;
namespace Server.Custom
{
/// <summary>
/// Measures the main-thread cost of every read the bridge plugin would perform, against
/// whatever world is currently loaded. Read-only. Test scaffolding, not part of the bridge.
///
/// Everything here runs on the Core thread, which is exactly where the real plugin's
/// reads must run, so these timings are the ones that matter for frame budget.
/// </summary>
public static class BridgeProbe
{
private const int Iterations = 20;
private static readonly AosAttribute[] AllAttrs =
(AosAttribute[])Enum.GetValues(typeof(AosAttribute));
private static readonly AosWeaponAttribute[] AllWeaponAttrs =
(AosWeaponAttribute[])Enum.GetValues(typeof(AosWeaponAttribute));
private static readonly AosArmorAttribute[] AllArmorAttrs =
(AosArmorAttribute[])Enum.GetValues(typeof(AosArmorAttribute));
public static void Initialize()
{
if (Config.Get("Bridge.ProbeOnStart", false))
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(2.0), Run);
}
private static void Log(string fmt, params object[] args)
{
Console.WriteLine("[BridgeProbe] " + String.Format(fmt, args));
}
private static void Run()
{
try
{
var players = CollectSeededChars();
var houses = BaseHouse.AllHouses;
var vendors = PlayerVendor.PlayerVendors ?? new List<PlayerVendor>();
Log("world: {0} seeded chars, {1} houses, {2} vendors, {3} accounts",
players.Count, houses.Count, vendors.Count, Accounting.Accounts.Count);
Log("thread: {0} (id {1})",
System.Threading.Thread.CurrentThread.Name,
System.Threading.Thread.CurrentThread.ManagedThreadId);
Log("");
// ---- one full character profile, the heaviest single read ----
var sb = new StringBuilder(8192);
double perProfile = Time(() =>
{
for (int i = 0; i < players.Count; i++)
{
sb.Clear();
WriteProfile(sb, players[i]);
}
}) / Math.Max(1, players.Count);
sb.Clear();
if (players.Count > 0)
WriteProfile(sb, players[0]);
int profileBytes = sb.Length;
Log("char.profile {0,8:F3} ms/char {1,6} bytes json -> {2:F1} ms for all {3}",
perProfile, profileBytes, perProfile * players.Count, players.Count);
// ---- vitals: the 30s sweep the doc proposes ----
double vitals = Time(() =>
{
var b = new StringBuilder(256);
for (int i = 0; i < players.Count; i++)
{
b.Clear();
WriteVitals(b, players[i]);
}
});
Log("vitals sweep {0,8:F3} ms for {1} chars ({2:F4} ms/char)",
vitals, players.Count, vitals / Math.Max(1, players.Count));
// ---- house decay sweep (III.3) ----
double decay = Time(() =>
{
for (int i = 0; i < houses.Count; i++)
{
var lvl = houses[i].DecayLevel;
GC.KeepAlive(lvl);
}
});
Log("decay sweep {0,8:F3} ms for {1} houses ({2:F4} ms/house)",
decay, houses.Count, decay / Math.Max(1, houses.Count));
// ---- economy: money supply snapshot ----
double econ = 0;
double total = 0;
econ = Time(() =>
{
total = 0;
foreach (Account a in Accounting.Accounts.GetAccounts())
total += a.TotalCurrency;
});
Log("economy sweep {0,8:F3} ms for {1} accounts (supply {2:N0} gold)",
econ, Accounting.Accounts.Count, total * Account.CurrencyThreshold);
// ---- player vendor snapshot ----
int listings = 0;
double vend = Time(() =>
{
listings = 0;
var b = new StringBuilder(4096);
for (int i = 0; i < vendors.Count; i++)
{
b.Clear();
listings += WriteVendor(b, vendors[i]);
}
});
Log("vendor snap {0,8:F3} ms for {1} vendors ({2} listings)",
vend, vendors.Count, listings);
Log("");
Log("--- extrapolation (linear, same gear complexity) ---");
Log(" vitals sweep @ 200 online: {0,7:F2} ms", vitals / Math.Max(1, players.Count) * 200);
Log(" vitals sweep @ 1000 online: {0,7:F2} ms", vitals / Math.Max(1, players.Count) * 1000);
Log(" profiles for 1000 chars : {0,7:F1} ms <-- never do this in a sweep",
perProfile * 1000);
Log(" decay sweep @ 2000 houses: {0,7:F2} ms", decay / Math.Max(1, houses.Count) * 2000);
Log(" economy @ 5000 accts : {0,7:F2} ms", econ / Math.Max(1, Accounting.Accounts.Count) * 5000);
}
catch (Exception ex)
{
Log("FAILED: " + ex);
}
}
/// <summary>Best-of-N: the minimum is the least noisy estimate of true cost.</summary>
private static double Time(Action action)
{
action(); // warm up JIT and caches
double best = double.MaxValue;
var sw = new Stopwatch();
for (int i = 0; i < Iterations; i++)
{
sw.Restart();
action();
sw.Stop();
double ms = sw.Elapsed.TotalMilliseconds;
if (ms < best)
best = ms;
}
return best;
}
private static List<PlayerMobile> CollectSeededChars()
{
var list = new List<PlayerMobile>();
foreach (Account a in Accounting.Accounts.GetAccounts())
{
if (!a.Username.StartsWith("seed_", StringComparison.Ordinal))
continue;
for (int i = 0; i < a.Length; i++)
{
var pm = a[i] as PlayerMobile;
if (pm != null)
list.Add(pm);
}
}
return list;
}
private static void WriteVitals(StringBuilder sb, PlayerMobile m)
{
sb.Append("{\"kind\":\"char.vitals\",\"serial\":\"0x");
sb.Append(m.Serial.Value.ToString("X"));
sb.Append("\",\"hits\":").Append(m.Hits);
sb.Append(",\"hitsMax\":").Append(m.HitsMax);
sb.Append(",\"mana\":").Append(m.Mana);
sb.Append(",\"stam\":").Append(m.Stam);
sb.Append(",\"str\":").Append(m.Str);
sb.Append(",\"dex\":").Append(m.Dex);
sb.Append(",\"int\":").Append(m.Int);
sb.Append(",\"x\":").Append(m.X);
sb.Append(",\"y\":").Append(m.Y);
sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
sb.Append('}');
}
private static void WriteProfile(StringBuilder sb, PlayerMobile m)
{
sb.Append("{\"kind\":\"char.profile\",\"serial\":\"0x");
sb.Append(m.Serial.Value.ToString("X"));
sb.Append("\",\"name\":\"").Append(m.Name).Append('"');
sb.Append(",\"body\":").Append(m.Body.BodyID);
sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
sb.Append(",\"stats\":{\"str\":").Append(m.Str);
sb.Append(",\"dex\":").Append(m.Dex);
sb.Append(",\"int\":").Append(m.Int);
sb.Append(",\"hits\":").Append(m.Hits).Append(",\"hitsMax\":").Append(m.HitsMax);
sb.Append(",\"mana\":").Append(m.Mana).Append(",\"manaMax\":").Append(m.ManaMax);
sb.Append(",\"stam\":").Append(m.Stam).Append(",\"stamMax\":").Append(m.StamMax);
sb.Append(",\"fame\":").Append(m.Fame).Append(",\"karma\":").Append(m.Karma);
sb.Append(",\"luck\":").Append(m.Luck);
sb.Append(",\"resist\":{\"phys\":").Append(m.PhysicalResistance);
sb.Append(",\"fire\":").Append(m.FireResistance);
sb.Append(",\"cold\":").Append(m.ColdResistance);
sb.Append(",\"pois\":").Append(m.PoisonResistance);
sb.Append(",\"energy\":").Append(m.EnergyResistance).Append("}}");
sb.Append(",\"skills\":[");
bool first = true;
for (int i = 0; i < m.Skills.Length; i++)
{
var s = m.Skills[i];
if (s.Base <= 0.0)
continue; // untrained: the bridge should not ship ~50 zeroes per char
if (!first)
sb.Append(',');
first = false;
sb.Append("{\"n\":\"").Append(s.SkillName).Append('"');
sb.Append(",\"base\":").Append(s.Base.ToString("F1"));
sb.Append(",\"value\":").Append(s.Value.ToString("F1"));
sb.Append(",\"cap\":").Append(s.Cap.ToString("F1"));
sb.Append(",\"lock\":\"").Append(s.Lock).Append("\"}");
}
sb.Append(']');
sb.Append(",\"equipment\":[");
first = true;
foreach (var item in m.Items)
{
if (item.Layer == Layer.Backpack || item.Layer == Layer.Bank ||
item.Layer == Layer.Hair || item.Layer == Layer.FacialHair ||
item.Layer == Layer.Mount)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X"));
sb.Append("\",\"layer\":\"").Append(item.Layer).Append('"');
sb.Append(",\"itemId\":").Append(item.ItemID);
sb.Append(",\"hue\":").Append(item.Hue);
sb.Append(",\"cliloc\":").Append(item.LabelNumber);
if (item.Name != null)
sb.Append(",\"name\":\"").Append(item.Name).Append('"');
sb.Append(",\"mods\":{");
bool m1 = true;
var weapon = item as BaseWeapon;
var armor = item as BaseArmor;
if (weapon != null)
{
sb.Append("\"minDamage\":").Append(weapon.MinDamage);
sb.Append(",\"maxDamage\":").Append(weapon.MaxDamage);
m1 = false;
WriteAttrs(sb, weapon.Attributes, ref m1);
WriteWeaponAttrs(sb, weapon.WeaponAttributes, ref m1);
}
else if (armor != null)
{
sb.Append("\"baseRating\":").Append(armor.BaseArmorRating);
m1 = false;
WriteAttrs(sb, armor.Attributes, ref m1);
WriteArmorAttrs(sb, armor.ArmorAttributes, ref m1);
}
sb.Append("}}");
}
sb.Append(']');
sb.Append('}');
}
private static void WriteAttrs(StringBuilder sb, AosAttributes a, ref bool first)
{
if (a == null)
return;
for (int i = 0; i < AllAttrs.Length; i++)
{
int v = a[AllAttrs[i]];
if (v == 0)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append('"').Append(AllAttrs[i]).Append("\":").Append(v);
}
}
private static void WriteWeaponAttrs(StringBuilder sb, AosWeaponAttributes a, ref bool first)
{
if (a == null)
return;
for (int i = 0; i < AllWeaponAttrs.Length; i++)
{
int v = a[AllWeaponAttrs[i]];
if (v == 0)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append('"').Append(AllWeaponAttrs[i]).Append("\":").Append(v);
}
}
private static void WriteArmorAttrs(StringBuilder sb, AosArmorAttributes a, ref bool first)
{
if (a == null)
return;
for (int i = 0; i < AllArmorAttrs.Length; i++)
{
int v = a[AllArmorAttrs[i]];
if (v == 0)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append('"').Append(AllArmorAttrs[i]).Append("\":").Append(v);
}
}
private static int WriteVendor(StringBuilder sb, PlayerVendor v)
{
int count = 0;
sb.Append("{\"kind\":\"vendor.snapshot\",\"serial\":\"0x");
sb.Append(v.Serial.Value.ToString("X"));
sb.Append("\",\"holdGold\":").Append(v.HoldGold);
sb.Append(",\"listings\":[");
var pack = v.Backpack;
if (pack != null)
{
bool first = true;
foreach (var item in pack.Items)
{
var vi = v.GetVendorItem(item);
if (vi == null)
continue;
if (!first)
sb.Append(',');
first = false;
count++;
sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X"));
sb.Append("\",\"itemId\":").Append(item.ItemID);
sb.Append(",\"price\":").Append(vi.Price);
sb.Append(",\"forSale\":").Append(vi.IsForSale ? "true" : "false");
sb.Append('}');
}
}
sb.Append("]}");
return count;
}
}
}

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

View File

@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<OutputType>Library</OutputType>
<AssemblyName>Scripts</AssemblyName>
<RootNamespace>Server</RootNamespace>
<AppendTargetFrameworkToOutputPath>False</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>False</GenerateAssemblyInfo>
<UseVSHostingProcess>False</UseVSHostingProcess>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<UseWindowsForms>false</UseWindowsForms>
<Platforms>x64</Platforms>
</PropertyGroup>
<!--
Conditioned on Configuration alone, not Configuration|Platform. ScriptCompiler.Compile
runs `dotnet build Scripts/Scripts.csproj -c Release` with no Platform, so Platform
defaults to AnyCPU. Under the old Platform-qualified conditions that meant OutputPath
was unset (the DLL landed in Scripts/bin/Release/ while the core loads Scripts.dll from
the base directory) and DefineConstants were empty (XmlSpawner compiled its non-ServUO
branches). The net effect was that runtime script compilation silently had no effect.
-->
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
<OutputPath>..\</OutputPath>
<DefineConstants>TRACE;DEBUG;NEWTIMERS;ServUO</DefineConstants>
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<OutputPath>..\</OutputPath>
<DefineConstants>TRACE;NEWTIMERS;ServUO</DefineConstants>
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<Reference Include="System.Web" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Server\Server.csproj" />
<ProjectReference Include="..\Ultima\Ultima.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Data.DataSetExtensions" Version="4.5.0" />
</ItemGroup>
</Project>

33
patches/README.md Normal file
View File

@@ -0,0 +1,33 @@
# patches
Unified diffs against stock ServUO 57.4 for files the bridge must **modify** rather than add. Anything that can be shipped as a whole file belongs in `overlay/` instead.
Apply from the server root:
```bash
git apply --check patches/<name>.patch # dry run
git apply patches/<name>.patch
```
## Current
| Patch | Phase | File | Why |
|-------|:-----:|------|-----|
| _(none yet)_ | | | |
## Planned
| Patch | Phase | File | Why |
|-------|:-----:|------|-----|
| `playervendor-sale-event` | 7 | `Server/EventSink.cs` | Declare `PlayerVendorSale`, `InvokePlayerVendorSale`, `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }`. |
| `playervendor-sale-event` | 7 | `Scripts/Gumps/PlayerVendorGumps.cs` | One `InvokePlayerVendorSale` call after the `HoldGold +=` at line 96, where the sale commits. |
Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, and it is the only place where buyer, vendor **owner**, price, and commission are all in scope — which is exactly what cheat detection needs. See `docs/PLAN.md` §6.
## Note on `Scripts.csproj`
Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Scripts/Scripts.csproj`) because the file is small, we own it operationally, and a copy is less fragile than a diff against a project file. Revisit if it starts drifting from upstream.
## Note on shard repairs
The deletions and edits described in `docs/SHARD_PREREQS.md` are one-time repairs to a specific broken install, not part of the bridge. They are not shipped here.