Follow-up to #2721. It fixes how the remaining tests behave and changes no production code. ## CI - Both `dotnet test` steps pass `--filter "Category!=Manual"`. Before this, Manual-tagged tests ran on CI anyway. - The one surviving movement-throttle latency test is the only coverage of burst forgiveness over a full, wrapped history. It loses its Manual tag, so it runs on CI. ## Tests that passed on CI without testing anything - **Corpse and throwing tests.** They relied on tiledata for the weapon `Layer`, so on CI the equip silently failed and they asserted against empty lists. They now set `Layer` explicitly and assert the equip took effect. `TestThrown` also pins its own strength and range values, so a shard retuning SoulGlaive no longer breaks them. - **Healthbar poison rows.** Server.Tests registers no poisons, so every row tested `null`. They now use real poison levels. - **Account packet tests.** The mock accounts reported `Length` 0, so the character-list tests only ever encoded empty slots. They now encode the real characters. - **Pathing and AI tests that only mean something on real Trammel/Felucca terrain** are now guarded with `TileDataRequirement.SkipIfMissing()`: Britain desk and clutter scenes, house footprints (no MultiData on CI) and fingerprints. Tests that are valid on flat blank terrain stay unguarded. (A synthetic tiledata fixture to run these on CI is a planned follow-up.) - **Assertions that couldn't fail now check their claim.** Affected tests include the `DefaultWalker` cache use, cache on/off parity requiring a path, the recorder capability flags, the idle-pet gate, the monthly DST recurrence, the AdvancedSearch `Record.Exception` wrappers, `CanFitItem` with no branch-guarded assert, the Firewall callback, and `Import_NoMap`. The Geometry Parse asserts also now use the same input as their TryParse. ## Shared state Every test that changes process-wide state now restores it, even when an assert fails: - **The clock.** A new `TimerWheelScope` saves and restores `Core._tickCount`, `Core._now` and the timer wheel. `SimulatedClockScope` covers decay tests. - **StepCache settings and caches**, via `StepCacheStateScope`. - **Network state.** The ManualAllowlist, LoginAllowlist, firewall and BanChannel reporters are restored. Global packet handlers are now registered per test and removed afterwards, so they no longer stay registered for later tests. - **Data and config tables.** `ArchiveJournal` statics, `TileData.ItemTable`, `ObjectPropertyList.Enabled`, `Core.Expansion` and the localization table are restored. One `MobileStatusVersion` restore was resetting a different expansion than the one the test changed. - **Entities.** Tests with fixed serials now delete what they create. Fixed mobile serials moved to `World.NewMobile`, because deleting a hard-coded serial could unregister a live entity. - **Collection race.** `SpiralScanTests` no longer clears the spawn cache from a separate collection that could run in parallel with the cache-manager tests. - **Shared statics.** Classes reading shared static allowlists joined the sequential collection. ## Duplication New shared helpers replace copy-pasted harness code: - **Server.Tests:** `PacketAssert.Sends`, used by about 130 packet tests; `TestClientHarness`, `PersistencePipelineScope`, `NetTestHelpers`, `EntityCleanup` and one `TestMulti`. - **UOContent.Tests:** `TimerWheelScope`, which also holds one wraparound-safe `RunFor`/`RunUntil`; `AiTestStubs`, which merges six walker copies and five identical creature stubs; `MobileTestHarness`, `CommandTestHarness` and `TempDirectory`. Dead oracle packet classes are removed. ## Cost | Test | Before | After | |---|---|---| | `VirtualSerialTests` | 1.57s | 2ms (starts near the wrap via reflection instead of drawing ~17.9M serials) | | Event scheduler recurrence loop | 473ms | 1ms | | CrowdSec drain | 517ms | 16ms (event-driven instead of a fixed 500ms wait) | | AuthId tests | 248ms | 99ms (SHA2 except the two GameLogin tests) | | Container cap tests | 5,000 items | 3,449 / 3,277 (one past each protocol cap) | | Bulk removal | 100k children | 20k children | | OPL hash distribution | 60k builds | 10k builds | ## Shard-tuning decoupling - AI assertions read `ReacquireDelay` and `FollowMoveSpeed` instead of hard-coding production tuning. - The guard friendly-fire test uses an `AI_Mage` stub instead of WhiteWyrm. - Argon2 assertions pin the `argon2id` variant and `v=19` but read the cost parameters from the configured hasher. ## Tests | Run | Server.Tests | UOContent.Tests | |---|---|---| | With client files | 812 pass | 1,123 pass | | Without client files (CI simulation) | 800 pass, 12 skip | 1,021 pass, 102 skip | - The UOContent fixture reads `MODERNUO_TEST_DATA_DIR`; Server.Tests reads `MODERNUO_CLIENT_PATH`. - The 15 new UOContent skips are the map-dependent guards above. - Total run time with client files is about 5s, down from about 8s.
402 lines
13 KiB
C#
402 lines
13 KiB
C#
using System;
|
|
using Server;
|
|
using Server.Items;
|
|
using Server.Mobiles;
|
|
using Server.Tests;
|
|
using Xunit;
|
|
|
|
namespace UOContent.Tests;
|
|
|
|
[Collection("Sequential UOContent Tests")]
|
|
public class ThrowingTests
|
|
{
|
|
// Exposes protected ModifyHitChance so the math can be verified directly. The requirement and
|
|
// throw band are pinned here so retuning a shipped weapon cannot move these expectations.
|
|
private class TestThrown : BaseThrown
|
|
{
|
|
// Weapons take their layer from tiledata, which CI doesn't load; without it AddItem is refused.
|
|
public TestThrown() : base(0x8FF) => Layer = Layer.OneHanded;
|
|
|
|
public override int AosStrengthReq => 60;
|
|
|
|
public override int MinThrowRange => 4;
|
|
|
|
public override int MaxThrowRange => 7;
|
|
|
|
public double TestModifyHitChance(Mobile attacker, Mobile defender, double chance) =>
|
|
ModifyHitChance(attacker, defender, chance);
|
|
|
|
public int TestModifyDamage(Mobile attacker, Mobile defender, int damage) =>
|
|
ModifyDamage(attacker, defender, damage);
|
|
}
|
|
|
|
// Hit chance modifiers
|
|
|
|
/// <summary>At optimal range with no shield the chance should not change.</summary>
|
|
[Fact]
|
|
public void ModifyHitChance_OptimalRange_NoShield_NoChange()
|
|
{
|
|
var map = Map.Felucca;
|
|
var attacker = CreateMobile(map, new Point3D(5000, 500, 0));
|
|
var defender = CreateMobile(map, new Point3D(5005, 500, 0)); // distance 5, within range 4..7
|
|
var weapon = new TestThrown();
|
|
|
|
try
|
|
{
|
|
attacker.Skills.Throwing.BaseFixedPoint = 0;
|
|
attacker.RawDex = 10;
|
|
|
|
var result = weapon.TestModifyHitChance(attacker, defender, 0.8);
|
|
|
|
Assert.Equal(0.8, result, 10);
|
|
}
|
|
finally
|
|
{
|
|
attacker.Delete();
|
|
defender.Delete();
|
|
weapon.Delete();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// At distance 1 with no throwing skill and minimum dex, close to the full -12% applies.
|
|
/// Elf race avoids the Human Jack-of-All-Trades 20.0 skill floor.
|
|
/// RawDex clamps to 1 minimum, so mitigation = (0+1)/20 = 0.05 -> penalty = 0.1195.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ModifyHitChance_CloseQuarters_MinimumSkill_AppliesNearFullPenalty()
|
|
{
|
|
var map = Map.Felucca;
|
|
var attacker = CreateMobile(map, new Point3D(5100, 500, 0));
|
|
var defender = CreateMobile(map, new Point3D(5101, 500, 0)); // distance 1
|
|
var weapon = new TestThrown();
|
|
|
|
try
|
|
{
|
|
attacker.Race = Race.Elf; // humans have Jack-of-All-Trades (+20.0 floor on all skills)
|
|
attacker.Skills.Throwing.BaseFixedPoint = 0;
|
|
attacker.RawDex = 1; // minimum
|
|
|
|
var result = weapon.TestModifyHitChance(attacker, defender, 0.8);
|
|
|
|
Assert.Equal(0.6805, result, 10);
|
|
}
|
|
finally
|
|
{
|
|
attacker.Delete();
|
|
defender.Delete();
|
|
weapon.Delete();
|
|
}
|
|
}
|
|
|
|
/// <summary>At distance 0 with 120 skill and 120 dex, (120+120)/20 = 12 caps mitigation, no penalty.</summary>
|
|
[Fact]
|
|
public void ModifyHitChance_CloseQuarters_MaxSkillAndDex_NoChange()
|
|
{
|
|
var map = Map.Felucca;
|
|
var attacker = CreateMobile(map, new Point3D(5200, 500, 0));
|
|
var defender = CreateMobile(map, new Point3D(5200, 500, 0)); // same tile, distance 0
|
|
var weapon = new TestThrown();
|
|
|
|
try
|
|
{
|
|
attacker.Skills.Throwing.BaseFixedPoint = 1200; // 120.0
|
|
attacker.RawDex = 120;
|
|
|
|
var result = weapon.TestModifyHitChance(attacker, defender, 0.8);
|
|
|
|
Assert.Equal(0.8, result, 10);
|
|
}
|
|
finally
|
|
{
|
|
attacker.Delete();
|
|
defender.Delete();
|
|
weapon.Delete();
|
|
}
|
|
}
|
|
|
|
/// <summary>Distance 2 is above melee range but below MinThrowRange of 4, so the flat -12% applies.</summary>
|
|
[Fact]
|
|
public void ModifyHitChance_BelowMinRange_AppliesFlatPenalty()
|
|
{
|
|
var map = Map.Felucca;
|
|
var attacker = CreateMobile(map, new Point3D(5300, 500, 0));
|
|
var defender = CreateMobile(map, new Point3D(5302, 500, 0)); // distance 2
|
|
var weapon = new TestThrown();
|
|
|
|
try
|
|
{
|
|
var result = weapon.TestModifyHitChance(attacker, defender, 0.8);
|
|
|
|
Assert.Equal(0.68, result, 10);
|
|
}
|
|
finally
|
|
{
|
|
attacker.Delete();
|
|
defender.Delete();
|
|
weapon.Delete();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shield at 100 Parry: 1200/100 = 12% penalty, chance * 0.88.
|
|
/// Layer must be set explicitly since tile data is not loaded in tests.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ModifyHitChance_Shield_100Parry_AppliesCorrectPenalty()
|
|
{
|
|
var map = Map.Felucca;
|
|
var attacker = CreateMobile(map, new Point3D(5400, 500, 0));
|
|
var defender = CreateMobile(map, new Point3D(5405, 500, 0)); // distance 5, optimal
|
|
var weapon = new TestThrown();
|
|
var shield = new Buckler { Layer = Layer.TwoHanded }; // tile data not loaded in tests
|
|
|
|
try
|
|
{
|
|
attacker.Skills.Parry.BaseFixedPoint = 1000; // 100.0
|
|
attacker.AddItem(shield);
|
|
|
|
var result = weapon.TestModifyHitChance(attacker, defender, 0.8);
|
|
|
|
Assert.Equal(0.704, result, 10);
|
|
}
|
|
finally
|
|
{
|
|
attacker.Delete();
|
|
defender.Delete();
|
|
weapon.Delete();
|
|
}
|
|
}
|
|
|
|
/// <summary>Shield at 120 Parry: 1200/120 = 10% penalty, chance * 0.90.</summary>
|
|
[Fact]
|
|
public void ModifyHitChance_Shield_120Parry_AppliesCorrectPenalty()
|
|
{
|
|
var map = Map.Felucca;
|
|
var attacker = CreateMobile(map, new Point3D(5500, 500, 0));
|
|
var defender = CreateMobile(map, new Point3D(5505, 500, 0)); // distance 5, optimal
|
|
var weapon = new TestThrown();
|
|
var shield = new Buckler { Layer = Layer.TwoHanded };
|
|
|
|
try
|
|
{
|
|
attacker.Skills.Parry.BaseFixedPoint = 1200; // 120.0
|
|
attacker.AddItem(shield);
|
|
|
|
var result = weapon.TestModifyHitChance(attacker, defender, 0.8);
|
|
|
|
Assert.Equal(0.72, result, 10);
|
|
}
|
|
finally
|
|
{
|
|
attacker.Delete();
|
|
defender.Delete();
|
|
weapon.Delete();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shield at 0 Parry hits the 90% cap, chance * 0.10.
|
|
/// Elf race prevents JOAT from raising Parry to 20.0.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ModifyHitChance_Shield_ZeroParry_CapsAt90Percent()
|
|
{
|
|
var map = Map.Felucca;
|
|
var attacker = CreateMobile(map, new Point3D(5600, 500, 0));
|
|
var defender = CreateMobile(map, new Point3D(5605, 500, 0)); // distance 5, optimal
|
|
var weapon = new TestThrown();
|
|
var shield = new Buckler { Layer = Layer.TwoHanded };
|
|
|
|
try
|
|
{
|
|
attacker.Race = Race.Elf; // humans have Jack-of-All-Trades (+20.0 floor on all skills)
|
|
attacker.Skills.Parry.BaseFixedPoint = 0;
|
|
attacker.AddItem(shield);
|
|
|
|
var result = weapon.TestModifyHitChance(attacker, defender, 0.8);
|
|
|
|
Assert.Equal(0.08, result, 10);
|
|
}
|
|
finally
|
|
{
|
|
attacker.Delete();
|
|
defender.Delete();
|
|
weapon.Delete();
|
|
}
|
|
}
|
|
|
|
// DefMaxRange STR scaling (TestThrown: StrReq 60, Min 4, Max 7, so one tile per 26 Str)
|
|
|
|
[Fact]
|
|
public void DefMaxRange_AtStrReq_EqualsMinThrowRange()
|
|
{
|
|
var weapon = new TestThrown();
|
|
var attacker = CreateMobile(Map.Felucca, new Point3D(5800, 500, 0));
|
|
try
|
|
{
|
|
attacker.RawStr = 60; // == AosStrengthReq
|
|
attacker.AddItem(weapon);
|
|
Assert.Same(attacker, weapon.Parent);
|
|
Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 4
|
|
}
|
|
finally
|
|
{
|
|
weapon.Delete();
|
|
attacker.Delete();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void DefMaxRange_At140Str_EqualsMaxThrowRange()
|
|
{
|
|
var weapon = new TestThrown();
|
|
var attacker = CreateMobile(Map.Felucca, new Point3D(5810, 500, 0));
|
|
try
|
|
{
|
|
attacker.RawStr = 140;
|
|
attacker.AddItem(weapon);
|
|
Assert.Same(attacker, weapon.Parent);
|
|
Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 7
|
|
}
|
|
finally
|
|
{
|
|
weapon.Delete();
|
|
attacker.Delete();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void DefMaxRange_AboveMaxStr_IsCappedAtMaxThrowRange()
|
|
{
|
|
var weapon = new TestThrown();
|
|
var attacker = CreateMobile(Map.Felucca, new Point3D(5820, 500, 0));
|
|
try
|
|
{
|
|
attacker.RawStr = 200; // uncapped formula would give 9
|
|
attacker.AddItem(weapon);
|
|
Assert.Same(attacker, weapon.Parent);
|
|
Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 7, not 9
|
|
}
|
|
finally
|
|
{
|
|
weapon.Delete();
|
|
attacker.Delete();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void DefMaxRange_BelowStrReq_FlooredAtMinThrowRange()
|
|
{
|
|
var weapon = new TestThrown();
|
|
var attacker = CreateMobile(Map.Felucca, new Point3D(5830, 500, 0));
|
|
try
|
|
{
|
|
attacker.RawStr = 10; // below StrReq 60
|
|
attacker.AddItem(weapon);
|
|
Assert.Same(attacker, weapon.Parent);
|
|
Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 4, not 3
|
|
}
|
|
finally
|
|
{
|
|
weapon.Delete();
|
|
attacker.Delete();
|
|
}
|
|
}
|
|
|
|
/// <summary>At the outermost admissible tile (dist == MaxRange) a throw loses 47% damage.</summary>
|
|
[Fact]
|
|
public void ModifyDamage_AtMaxRange_Reduces47Percent()
|
|
{
|
|
var map = Map.Felucca;
|
|
var attacker = CreateMobile(map, new Point3D(5900, 500, 0));
|
|
var weapon = new TestThrown(); // MinThrowRange 4 -> MaxThrowRange 7
|
|
try
|
|
{
|
|
attacker.RawStr = 140; // MaxRange == MaxThrowRange == 7
|
|
attacker.AddItem(weapon);
|
|
Assert.Same(attacker, weapon.Parent);
|
|
var defender = CreateMobile(map, new Point3D(5907, 500, 0)); // distance 7 == MaxRange
|
|
try
|
|
{
|
|
Assert.Equal(53, weapon.TestModifyDamage(attacker, defender, 100));
|
|
}
|
|
finally
|
|
{
|
|
defender.Delete();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
weapon.Delete();
|
|
attacker.Delete();
|
|
}
|
|
}
|
|
|
|
/// <summary>Inside max range, damage is unchanged.</summary>
|
|
[Fact]
|
|
public void ModifyDamage_WithinRange_NoChange()
|
|
{
|
|
var map = Map.Felucca;
|
|
var attacker = CreateMobile(map, new Point3D(5920, 500, 0));
|
|
var weapon = new TestThrown();
|
|
try
|
|
{
|
|
attacker.RawStr = 140; // MaxRange 7
|
|
attacker.AddItem(weapon);
|
|
Assert.Same(attacker, weapon.Parent);
|
|
var defender = CreateMobile(map, new Point3D(5925, 500, 0)); // distance 5 < 7
|
|
try
|
|
{
|
|
Assert.Equal(100, weapon.TestModifyDamage(attacker, defender, 100));
|
|
}
|
|
finally
|
|
{
|
|
defender.Delete();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
weapon.Delete();
|
|
attacker.Delete();
|
|
}
|
|
}
|
|
|
|
/// <summary>Close-quarters mitigation uses RawDex, so a Dex debuff does not change the penalty.</summary>
|
|
[Fact]
|
|
public void ModifyHitChance_CloseQuarters_UsesRawDex_IgnoresStatMods()
|
|
{
|
|
var map = Map.Felucca;
|
|
var attacker = CreateMobile(map, new Point3D(5940, 500, 0));
|
|
var defender = CreateMobile(map, new Point3D(5941, 500, 0)); // distance 1
|
|
var weapon = new TestThrown();
|
|
try
|
|
{
|
|
attacker.Race = Race.Elf; // avoid Human JOAT skill floor
|
|
attacker.Skills.Throwing.BaseFixedPoint = 0;
|
|
attacker.RawDex = 40;
|
|
// RawDex-based mitigation = (0 + 40)/20 = 2.0 -> penalty = (12 - 2)/100 = 0.10
|
|
var baseline = weapon.TestModifyHitChance(attacker, defender, 0.8); // 0.70
|
|
|
|
attacker.AddStatMod(new StatMod(StatType.Dex, "curse", -30, TimeSpan.Zero)); // effective Dex 10
|
|
var withMod = weapon.TestModifyHitChance(attacker, defender, 0.8);
|
|
|
|
Assert.Equal(0.70, baseline, 10);
|
|
Assert.Equal(baseline, withMod, 10); // unchanged because RawDex is used
|
|
}
|
|
finally
|
|
{
|
|
attacker.Delete();
|
|
defender.Delete();
|
|
weapon.Delete();
|
|
}
|
|
}
|
|
|
|
private static PlayerMobile CreateMobile(Map map, Point3D location)
|
|
{
|
|
var mobile = new PlayerMobile(World.NewMobile);
|
|
mobile.DefaultMobileInit();
|
|
mobile.MoveToWorld(location, map);
|
|
return mobile;
|
|
}
|
|
}
|