/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using CultMath; using GameCult.Caching; using GameCult.Caching.MessagePack; using UniRx; using Xunit; using static CultMath.math; using float2 = CultMath.float2; using float3 = CultMath.float3; using int2 = CultMath.int2; // Cut 12.4(b) (docs/fire-control-cut.md, "The detonation primitive"): behavioural tests for Detonate -- the // blast-as-area owner that replaces FireControl.Splash and Entity.Absorb -- and for Apply's switch on the // frozen Fuse (null/Proximity/Contact/Delayed). Builds its own fixtures, the convention every cut's test file // follows, with the rules this cut's own tests must obey: SchematicCellSize 2 throughout (the 12.3 fixture's // cell size of 1 would hide a missing `/ SchematicCellSize`), the target off the origin, and a hull whose // centre of mass is not an integer cell. public sealed class FireControlCut124Tests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut124-" + Guid.NewGuid().ToString("N")); private string Catalog => Path.Combine(_root, "Aetheria.cc"); public FireControlCut124Tests() => Directory.CreateDirectory(_root); private readonly List _openCaches = new List(); public void Dispose() { foreach (var c in _openCaches) c.Dispose(); Directory.Delete(_root, true); } private static GameplaySettings TestSettings(float commitHorizon = .5f) => new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp(), TargetDetectionInfoThreshold = .1f, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f, FiringArc = 170, CommitHorizon = commitHorizon, SchematicCellSize = 2f, UnaidedAccuracy = .05f, UnaidedTracking = 10f, UnaidedPrecision = 1000f, AgentMinHitProbability = 0f, BeamResolveInterval = .1f }; private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; // 5 wide x 4 tall: centre of mass (2, 1.5) -- not an integer cell, the fixture rule this whole file obeys. private static Shape SolidShape(int w, int h) { var shape = new Shape(w, h); foreach (var cell in shape.AllCoordinates) shape[cell] = true; return shape; } // The interior cell (1,1) of a 3x3 solid hull, ringed by its 8 border cells -- Shape.Shrink needs all 8 // neighbours present, which only the centre of a 3x3 has (the same fixture 12.3's BuriedCockpitNeedsPenetration // and PenetratorBurrowsBeforeItBursts both need: an item reachable only by penetrating the ring). private sealed class Engagement { public ItemManager Items; public Zone Zone; public Ship Shooter; public Ship Target; public EquippedItem WeaponItem; public InstantWeapon Weapon; public HullData HullData; public EquippedItem Cockpit; public Dictionary Custom = new Dictionary(); } private Engagement Build( GameplaySettings settings, Shape hullShape, float2? targetFacing = null, float targetRange = 100f, float velocity = 0f, WeaponFuse? fuse = null, float blastRadius = 0f, float penetration = 0f, float damage = 500f, WeaponModifiers modifiers = WeaponModifiers.None, bool equipShield = false, float shieldCapacity = 1000f, bool shieldActive = true, int2? cockpitCell = null, float cockpitDurability = 1000f, (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null, float accuracy = 1f, int2? gunCell = null) { var hullData = new HullData { Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 1000000, Mass = 1000, Armor = 0, Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = gunCell ?? new int2(0, 0), Shape = new Shape() } } }; var shooterHullData = new HullData { Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000, Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } }; var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); _openCaches.Add(cache); cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); cache.Upsert(hullData); cache.Upsert(shooterHullData); cache.Upsert(new WeaponItemData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Fuse = fuse, BlastRadius = blastRadius, WeaponModifiers = modifiers, Behaviors = { new InstantWeaponData { Damage = Constant(damage), Range = Constant(100000), MinRange = Constant(0), Velocity = Constant(velocity), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new TargetingSystemData { Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } }); cache.Upsert(new GearData { Name = "Cockpit", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new CockpitData() } }); cache.Upsert(new GearData { Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); var barShape = new Shape(2, 1); foreach (var v in barShape.AllCoordinates) barShape[v] = true; cache.Upsert(new GearData { Name = "Bar", Hardpoint = HardpointType.Tool, Shape = barShape, Durability = 1000000, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); cache.Upsert(new GearData { Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } }); cache.Upsert(new GearData { Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } } }); if (custom != null) foreach (var cu in custom) cache.Upsert(new GearData { Name = cu.Name, Hardpoint = HardpointType.Tool, Shape = cu.Shape, Durability = 1000000, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; var items = new ItemManager(cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut124", Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem { Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot }; EquippableItem MakeWeapon(int lot) => new EquippableItem { Data = items.ItemData.RefOf(items.ItemData.GetByName("Gun")), Durability = 1, Lot = lot }; var lot = 1; var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); var gun = MakeWeapon(lot++); Assert.True(shooter.TryEquip(gun, new int2(0, 0))); var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); var targeting = Make("Targeting", lot++, 1); Assert.True(shooter.TryFindSpace(targeting, out var tpos)); Assert.True(shooter.TryEquip(targeting, tpos)); var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); var targetGun = MakeWeapon(lot++); Assert.True(target.TryEquip(targetGun, gunCell ?? new int2(0, 0))); EquippedItem cockpit = null; if (cockpitCell != null) { var ci = Make("Cockpit", lot++, cockpitDurability); Assert.True(target.TryEquip(ci, cockpitCell.Value), $"cockpit must fit at {cockpitCell.Value}"); cockpit = target.Equipment.Single(x => x.EquippableItem == ci); } var customItems = new Dictionary(); if (custom != null) foreach (var cu in custom) { var ci0 = Make(cu.Name, lot++, cu.Durability); Assert.True(target.TryEquip(ci0, cu.Cell), $"custom {cu.Name} must fit at {cu.Cell}"); customItems[cu.Name] = target.Equipment.Single(x => x.EquippableItem == ci0); } if (equipShield) { var reactor = Make("Reactor", lot++, 10); Assert.True(target.TryFindSpace(reactor, out var rpos)); Assert.True(target.TryEquip(reactor, rpos)); var shield = Make("Shield", lot++, 10); Assert.True(target.TryFindSpace(shield, out var spos)); Assert.True(target.TryEquip(shield, spos)); } zone.Entities.Add(shooter); zone.Entities.Add(target); shooter.Activate(); target.Activate(); // Off-origin (the fixture rule): the shooter sits away from the world origin, and the target sits // `targetRange` ahead of it along the shooter's own +z, so a stationary target's TravelDirection is // exactly (0,1) when targetFacing is left at its default. shooter.Position = float3(-53, 0, -101); target.Position = shooter.Position + float3(0, 0, targetRange); if (targetFacing != null) target.Direction = normalize(targetFacing.Value); shooter.Target.Value = target; shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); if (equipShield) target.Shield.Item.Enabled.Value = shieldActive; zone.Update(1f); // fire times nonzero, same convention 12.3's fixture uses return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, HullData = hullData, Cockpit = cockpit, Custom = customItems }; } // Numeric cross-check, independent of FireControl's own exact-overlap implementation: the area of a disc // (centred at world point (cx,cy), radius r) that lies inside the axis-aligned rectangle [xlo,xhi]x[ylo,yhi], // by fine grid sampling. Used only to compute an EXPECTED value from first principles; the tests that need // exact values instead (conservation, exact half-splits) get them from geometry, not sampling. private static double DiscAreaInRect(double cx, double cy, double r, double xlo, double xhi, double ylo, double yhi, int samples = 1000) { var bxlo = Math.Max(xlo, cx - r); var bxhi = Math.Min(xhi, cx + r); var bylo = Math.Max(ylo, cy - r); var byhi = Math.Min(yhi, cy + r); if (bxhi <= bxlo || byhi <= bylo) return 0; long inside = 0; var dx = (bxhi - bxlo) / samples; var dy = (byhi - bylo) / samples; for (var i = 0; i < samples; i++) { var x = bxlo + (i + .5) * dx; var dxc = x - cx; for (var j = 0; j < samples; j++) { var y = bylo + (j + .5) * dy; var dyc = y - cy; if (dxc * dxc + dyc * dyc <= r * r) inside++; } } return inside * dx * dy; } private static double DiscAreaInCell(double cx, double cy, double r, int2 cell, int samples = 1000) => DiscAreaInRect(cx, cy, r, cell.x - .5, cell.x + .5, cell.y - .5, cell.y + .5, samples); // ==== Geometry ==== // TheDiscIsConservedOverTheGrid: on a large solid, armourless, itemless hull that contains the disc // entirely, the total hull damage delivered equals `damage`, at three different disc placements. // Kills: sampling in place of the exact overlap; the normaliser (2r)^2 in place of pi*r^2; a bounding box // that clips the rim cells; a dropped chord breakpoint -- any of these would move the total away from // `damage` even though the whole disc lies on metal. [Theory] [InlineData(0f, 0f, 0.3f)] // radius under one cell, centred on a cell's own integer coordinate [InlineData(.5f, .5f, 1.7f)] // centred on a cell corner [InlineData(.37f, -.21f, 1.2f)] // an arbitrary off-grid centre public void TheDiscIsConservedOverTheGrid(float ox, float oy, float radius) { var e = Build(TestSettings(), SolidShape(21, 21)); var centreCell = new int2(10, 10); // well inside the 21x21 grid at any of the radii above var worldCentre = e.Target.Position.xz + float2(ox, oy) * e.Items.GameplaySettings.SchematicCellSize; // Shift so the schematic centre lands at centreCell + (ox,oy): CenterOfMass is (10,10) for a 21x21 // solid hull, so the untranslated target position already maps there. var before = e.Target.Hull.Durability; FireControl.Detonate(e.Zone, worldCentre, radius * e.Items.GameplaySettings.SchematicCellSize, 100f, DamageType.Kinetic); var delivered = before - e.Target.Hull.Durability; Assert.Equal(100f, delivered, 2); } // EachCellTakesItsShareOfTheDisc: a solid hull with no items, and per-cell armour large enough that // ArmorAbsorb never depletes it -- so the ArmorDamage event for a cell reports that cell's raw share. // Pass: the named cell's reported share matches damage * overlap/(pi r^2), overlap integrated numerically // by the test. Kills: an even split over the covered cells (Splash's rule), and a share decided by the // cell's distance from the centre rather than its overlap area. [Fact] public void EachCellTakesItsShareOfTheDisc() { var e = Build(TestSettings(), SolidShape(9, 9)); foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1e6f; e.Target.MaxArmor[c.x, c.y] = 1e6f; } var cellSize = e.Items.GameplaySettings.SchematicCellSize; var com = e.HullData.Shape.CenterOfMass; // (4,4) for a 9x9 solid hull var namedCell = new int2(5, 4); // one cell off-centre laterally var radius = 2.3; // world units under cellSize*4, comfortably covering several cells var deposits = new Dictionary(); using (e.Target.ArmorDamage.Subscribe(x => deposits[x.pos] = x.damage)) { FireControl.Detonate(e.Zone, e.Target.Position.xz, (float) radius, 1000f, DamageType.Kinetic); } var expectedOverlap = DiscAreaInCell(com.x, com.y, radius / cellSize, namedCell); var expectedShare = 1000.0 * expectedOverlap / (Math.PI * (radius / cellSize) * (radius / cellSize)); Assert.True(deposits.TryGetValue(namedCell, out var actual), "named cell took no share at all"); Assert.Equal(expectedShare, actual, 1); } // TheShareOffTheHullIsLost (was ExternalBlastWastesMostOfItsEnergy): the disc is centred exactly on the // hull's own edge, so exactly half its area sits off the grid by construction (no numeric integration // needed -- a disc split by a straight line through its centre halves exactly). Pass: the delivered total // is half the blast's damage. Kills: renormalising the shares over the occupied cells (which would deliver // the full damage). [Fact] public void TheShareOffTheHullIsLost() { var e = Build(TestSettings(), SolidShape(5, 4)); // x: 0..4 (CoM.x = 2), left edge at schematic x = -0.5 var cellSize = e.Items.GameplaySettings.SchematicCellSize; // World point at schematic (-0.5, 1.5): CoM.y = 1.5 (the fixture's own non-integer centre), 2.5 cells // to port of CoM.x -- exactly the hull's left edge, with default facing (0,1) so world x is schematic x. var worldCentre = e.Target.Position.xz + float2(-2.5f * cellSize, 0); var before = e.Target.Hull.Durability; // Radius 0.5 cells: the on-hull half never reaches the next cell boundary at schematic x = 0.5. FireControl.Detonate(e.Zone, worldCentre, .5f * cellSize, 100f, DamageType.Kinetic); var delivered = before - e.Target.Hull.Durability; Assert.Equal(50f, delivered, 1); } // CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius: a long thin hull whose centre of mass lies well // beyond the blast radius, while its near end lies inside. Kills: Splash's own centre-distance cull. [Fact] public void CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius() { var e = Build(TestSettings(), SolidShape(1, 20)); // CoM at schematic (0, 9.5) var cellSize = e.Items.GameplaySettings.SchematicCellSize; // The near end (schematic y = 0) is `9.5 * cellSize` world units from CoM; the blast sits right there, // far outside a radius that would ever reach the centre. var nearEndWorld = e.Target.Position.xz + float2(0, -9.5f * cellSize); var before = e.Target.Hull.Durability; FireControl.Detonate(e.Zone, nearEndWorld, cellSize, 100f, DamageType.Kinetic); Assert.True(e.Target.Hull.Durability < before, "the near end of a long hull must take damage even though its centre is far outside the radius"); } // Per-cell armour large enough that ArmorAbsorb never depletes it: every ArmorDamage event then reports its // cell's raw share, whether or not an item sits on the cell. private static void ArmourEverything(Engagement e) { foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1e6f; e.Target.MaxArmor[c.x, c.y] = 1e6f; } } // The independent expectation: `damage` shared over a disc of radius rCells centred at schematic (cx,cy) in // proportion to numeric overlap with each occupied cell. private static double ExpectedDelivered(Shape shape, double cx, double cy, double rCells, double damage) { var overlap = 0.0; foreach (var c in shape.Coordinates) overlap += DiscAreaInCell(cx, cy, rCells, c); return damage * overlap / (Math.PI * rCells * rCells); } // ==== 12.4 fix batch F1: a disc tangent to a cell edge must not count the whole square ==== // A cell edge exactly r from the centre, with the piece's midpoint at 0, used to fall through // `h < yhi` / `-h > ylo` into the "whole rectangle" branch, so the square counted in full. BlastRadius 1 // world unit is half a cell (edges at +-.5 from an integer centre of mass), 3 is one and a half (edges at // +-.5 and +-1.5): both are tangent to cell edges. Measured through Fire before the fix: 127.32 and 100.80 // delivered of 100. [Theory] [InlineData(1f)] [InlineData(3f)] public void ATangentDiscDeliversExactlyItsDamageThroughFire(float blastRadius) { var e = Build(TestSettings(), SolidShape(5, 5), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: blastRadius, damage: 100f); ArmourEverything(e); var total = 0f; using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) { FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } Assert.Equal(100f, total, 2); } [Theory] [InlineData(1f)] [InlineData(3f)] public void ATangentDiscDeliversExactlyItsDamageThroughDetonate(float radius) { var e = Build(TestSettings(), SolidShape(5, 5)); ArmourEverything(e); var total = 0f; using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) FireControl.Detonate(e.Zone, e.Target.Position.xz, radius, 100f, DamageType.Kinetic); Assert.Equal(100f, total, 2); } // ==== 12.4 fix batch F2: the exact overlap is the only decider of "the disc touches the hull" ==== // An L-shaped hull whose centre of mass sits far from the far end of its long arm. The blast lands on that // arm's end, 14 world units from the centre of mass -- beyond radius + half the bounding box's diagonal // measured from it, which the old candidate cull used, so the cull threw away a blast that overlaps a // cell. Expected value: a numeric overlap of the cells, not the code under test. [Fact] public void ABlastAtTheFarEndOfAnLShapedHullLands() { var s = new Shape(10, 4); foreach (var c in s.AllCoordinates) s[c] = c.x < 4 || c.y == 0; var e = Build(TestSettings(), s); ArmourEverything(e); var com = e.HullData.Shape.CenterOfMass; var p = float2(9.9f, 0f); var expected = ExpectedDelivered(e.HullData.Shape, p.x, p.y, 1.0, 100.0); Assert.True(expected > 10.0, $"fixture: the blast must overlap the hull, expected {expected}"); var total = 0f; using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) FireControl.Detonate(e.Zone, e.Target.Position.xz + (p - com) * 2f, 2f, 100f, DamageType.Kinetic); Assert.Equal(expected, total, 1); } // The same rule on the shipped hulls: a blast just grazing the corner farthest from the centre of mass. The // corners of the shipped Longinus and LonginusX reach past the old cull's box-half-diagonal bound. [Theory] [InlineData("Longinus")] [InlineData("LonginusX")] public void AGrazingBlastAtAShippedHullsFarthestCornerLands(string hullName) { Shape shape; var cache = OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), out _); try { shape = cache.GetByName(hullName).Shape; } finally { cache.Dispose(); } var com = shape.CenterOfMass; var far = float2.zero; var best = -1f; foreach (var c in shape.Coordinates) foreach (var (dx, dy) in new[] { (-.5f, -.5f), (.5f, -.5f), (-.5f, .5f), (.5f, .5f) }) { var corner = float2(c.x + dx, c.y + dy); var d = length(corner - com); if (d > best) { best = d; far = corner; } } // One cell of radius, centred .9 cell outward of the corner: the disc reaches the corner by .1 cell. var centre = far + normalize(far - com) * .9f; var e = Build(TestSettings(), shape, gunCell: shape.Coordinates[0]); ArmourEverything(e); var expected = ExpectedDelivered(shape, centre.x, centre.y, 1.0, 100.0); Assert.True(expected > .05, $"fixture: the blast must graze the hull, expected {expected}"); var total = 0f; using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) FireControl.Detonate(e.Zone, e.Target.Position.xz + (centre - com) * 2f, 2f, 100f, DamageType.Kinetic); Assert.Equal(expected, total, 1); } // ==== 12.4 fix batch F3: a lethal blast must not break the entity enumeration ==== private Ship AddShip(Engagement e, float3 position, float hullDurability, int lot) { var hullRef = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Hull")); var ship = new Ship(e.Items, e.Zone, new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = lot }, new EntitySettings()); var marker = new EquippableItem { Data = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Marker")), Durability = 1000000, Lot = lot + 1 }; Assert.True(ship.TryEquip(marker, new int2(1, 1))); e.Zone.Entities.Add(ship); ship.Activate(); ship.Position = position; return ship; } // A blast that kills an entity mid-pass: death removes it from Zone.Entities, and the next MoveNext of the // live enumeration threw. The shot was never removed, so it detonated again the next tick (the probe saw // 2.69 then 5.38 on the target). Pass: nothing throws, the shot resolves and leaves exactly once, and the // target is damaged exactly once. [Fact] public void ALethalBlastResolvesOnceAndBreaksNothing() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); var near = AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300); var far = AddShip(e, e.Shooter.Position + float3(-5000, 0, 0), 1f, 302); var resolved = 0; var targetDamage = 0f; using var r = e.Zone.ShotResolved.Subscribe(_ => resolved++); using var a = e.Target.ArmorDamage.Subscribe(x => targetDamage += x.damage); using var h = e.Target.HullDamage.Subscribe(x => targetDamage += x); using var i = e.Target.ItemDamage.Subscribe(x => targetDamage += x.damage); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); Assert.Empty(e.Zone.PendingShots); Assert.Equal(1, resolved); Assert.DoesNotContain(near, e.Zone.Entities); // fixture: the blast really was lethal to a ship mid-pass Assert.Contains(far, e.Zone.Entities); Assert.True(targetDamage > 0f, "fixture: the target must be inside the blast"); var afterFirstTick = targetDamage; e.Zone.Update(.01f); Assert.Equal(1, resolved); Assert.Equal(afterFirstTick, targetDamage, 4); } // AOneBlastKillsEveryShipInsideIt: three ships, each dead to one hit, inside one blast. Each takes hull // damage exactly once and dies exactly once, and the shot leaves the queue once. Kills: the snapshot loop // breaking after the first kill (the other two would never be touched). [Fact] public void AOneBlastKillsEveryShipInsideIt() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); var ships = new[] { AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300), AddShip(e, e.Target.Position + float3(-8, 0, 0), 1f, 302), AddShip(e, e.Target.Position + float3(0, 0, 8), 1f, 304), }; var hullHits = new int[3]; var deaths = new int[3]; var subs = new List(); for (var n = 0; n < 3; n++) { var k = n; subs.Add(ships[k].HullDamage.Subscribe(_ => hullHits[k]++)); subs.Add(ships[k].Death.Subscribe(_ => deaths[k]++)); } var resolved = 0; subs.Add(e.Zone.ShotResolved.Subscribe(_ => resolved++)); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); foreach (var s in subs) s.Dispose(); Assert.Equal(new[] { 1, 1, 1 }, hullHits); Assert.Equal(new[] { 1, 1, 1 }, deaths); foreach (var s in ships) Assert.DoesNotContain(s, e.Zone.Entities); Assert.Empty(e.Zone.PendingShots); Assert.Equal(1, resolved); } // AShieldlessShipOutsideTheDiscIsNotTouched: nothing of any kind reaches an entity the disc does not // overlap. Kills: an uncovered entity falling through to the damage call (`covered ??= new List`). [Fact] public void AShieldlessShipOutsideTheDiscIsNotTouched() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 3f, damage: 100f); var outside = AddShip(e, e.Target.Position + float3(60, 0, 0), 1f, 300); var touched = 0; using var a = outside.ArmorDamage.Subscribe(_ => touched++); using var h = outside.HullDamage.Subscribe(_ => touched++); using var i = outside.ItemDamage.Subscribe(_ => touched++); var targetHit = 0f; using var t = e.Target.HullDamage.Subscribe(x => targetHit += x); var armourHit = 0f; using var ta = e.Target.ArmorDamage.Subscribe(x => armourHit += x.damage); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); Assert.Equal(0, touched); Assert.Equal(1f, outside.Hull.Durability); Assert.Contains(outside, e.Zone.Entities); Assert.True(targetHit + armourHit > 0f, "fixture: the blast must land on the target"); } // ADestroyedCockpitRaisesDeathOnce: the blast destroys the cockpit, Death removes and deactivates the // entity, and the blast's own DamageHull then still runs; no subscriber sees a second Death. [Fact] public void ADestroyedCockpitRaisesDeathOnce() { var e = Build(TestSettings(), SolidShape(3, 3), cockpitCell: new int2(1, 1), cockpitDurability: 10f); var deaths = new List(); using var d = e.Target.Death.Subscribe(deaths.Add); var hullEvents = 0; using var h = e.Target.HullDamage.Subscribe(_ => hullEvents++); FireControl.Detonate(e.Zone, e.Target.Position.xz, .6f, 5000f, DamageType.Kinetic); Assert.Equal(new[] { CauseOfDeath.CockpitDestroyed }, deaths); Assert.DoesNotContain(e.Target, e.Zone.Entities); Assert.True(hullEvents >= 1, "fixture: the blast's hull damage still lands after the cockpit dies"); } // ADirectHullKillRaisesDeathOnce: the non-blast path (Entity.DamageHull straight, as a projectile hit does) // raises Death once, and further damage to the corpse does not raise it again. [Fact] public void ADirectHullKillRaisesDeathOnce() { var e = Build(TestSettings(), SolidShape(3, 3)); var deaths = 0; using var d = e.Target.Death.Subscribe(_ => deaths++); e.Target.DamageHull(2000000f); e.Target.DamageHull(5f); Assert.Equal(1, deaths); } // ==== Absorption order ==== // ABlastIsAbsorbedThroughArmourFirstPerCell (replaces AbsorbSpendsArmourBeforeTheItemOnTheSameCell): a disc // wholly inside one armoured item cell, so its share is the whole damage -- the three damage levels of the // superseded test. Kills: the item absorbing before the armour, and armour being skipped. [Fact] public void ABlastIsAbsorbedThroughArmourFirstPerCell() { var cell = new int2(1, 1); Engagement Fresh() => Build(TestSettings(), SolidShape(3, 3), custom: new[] { ("Marker", new Shape(), cell, 50f) }); void Reset(Engagement e) { e.Target.Armor[cell.x, cell.y] = 10f; e.Target.MaxArmor[cell.x, cell.y] = 10f; e.Custom["Marker"].EquippableItem.Durability = 50f; } var cellSize = 2f; var worldCentre = Fresh().Target.Position.xz; // recomputed fresh below per case (CoM == cell (1,1) for a 3x3 hull) { var e = Fresh(); Reset(e); FireControl.Detonate(e.Zone, e.Target.Position.xz, .3f * cellSize, 5f, DamageType.Kinetic); // below armour Assert.Equal(5f, e.Target.Armor[cell.x, cell.y], 2); Assert.Equal(50f, e.Custom["Marker"].EquippableItem.Durability, 2); Assert.Equal(1000000f, e.Target.Hull.Durability, 1); } { var e = Fresh(); Reset(e); FireControl.Detonate(e.Zone, e.Target.Position.xz, .3f * cellSize, 25f, DamageType.Kinetic); // between armour and armour+durability Assert.Equal(0f, e.Target.Armor[cell.x, cell.y], 2); Assert.Equal(35f, e.Custom["Marker"].EquippableItem.Durability, 2); // 25 - 10 armour = 15 excess Assert.Equal(1000000f, e.Target.Hull.Durability, 1); } { var e = Fresh(); Reset(e); FireControl.Detonate(e.Zone, e.Target.Position.xz, .3f * cellSize, 80f, DamageType.Kinetic); // above both Assert.Equal(0f, e.Target.Armor[cell.x, cell.y], 2); Assert.Equal(0f, e.Custom["Marker"].EquippableItem.Durability, 2); Assert.Equal(1000000f - 20f, e.Target.Hull.Durability, 1); // 80 - 10 armour - 50 item = 20 } } // ArmourProtectsOnlyItsOwnCellsShare: a 2x1 item with armour on one cell only, and a disc symmetric about // the seam so both cells take the same share. Pass: the item takes (shareA - armourA)+ + shareB. Kills: // pooling the raw shares and then applying armour to the pool (which would let cell A's plate eat cell B's // share). [Fact] public void ArmourProtectsOnlyItsOwnCellsShare() { var cellA = new int2(1, 1); var cellB = new int2(2, 1); // Bar occupies (1,1)-(2,1) var e = Build(TestSettings(), SolidShape(4, 3), custom: new[] { ("Bar", BarShape(), cellA, 1000f) }); var cellSize = e.Items.GameplaySettings.SchematicCellSize; var com = e.HullData.Shape.CenterOfMass; e.Target.Armor[cellA.x, cellA.y] = 100f; e.Target.MaxArmor[cellA.x, cellA.y] = 100f; e.Target.Armor[cellB.x, cellB.y] = 0f; e.Target.MaxArmor[cellB.x, cellB.y] = 0f; // Centred exactly on the seam between the two cells (schematic x = 1.5), same y as both cells: by // symmetry shareA == shareB exactly, no numeric integration needed. var worldCentre = e.Target.Position.xz + (float2(1.5f, cellA.y) - com) * cellSize; var radius = 1f; // world units -- stays within the seam's own pair of cells (each 1 cell wide) // Armour (100) exceeds a symmetric share (damage/2), so pick damage large enough that shareA - armourA // is positive: shareA ~= damage/2 (by symmetry, exactly, regardless of the exact overlap fraction -- // both cells see the identical geometry mirrored across the seam). FireControl.Detonate(e.Zone, worldCentre, radius, 400f, DamageType.Kinetic); var shareEach = 200f; // damage/2, by the seam symmetry var expectedItemLoss = Math.Max(0f, shareEach - 100f) + shareEach; // (shareA - armourA)+ + shareB Assert.Equal(1000f - expectedItemLoss, e.Custom["Bar"].EquippableItem.Durability, 1); Assert.Equal(0f, e.Target.Armor[cellA.x, cellA.y], 1); // cell A's own plate is spent by its own share (200 > 100), same as any single-cell absorb } private static Shape BarShape() { var shape = new Shape(2, 1); foreach (var v in shape.AllCoordinates) shape[v] = true; return shape; } // AMultiCellItemAbsorbsItsCoveredCellsAsOnePool (the ProbeC2 geometry): a 2x1 item with no armour, and a // disc straddling its seam that puts less than .1 on each cell but more than .1 on the two together. Pass: // the item absorbs the sum. Kills: resolving the item per cell in any form -- Absorb resurrected, ItemAbsorb // called per deposit, or a pool that resolves before every covered cell has deposited. [Fact] public void AMultiCellItemAbsorbsItsCoveredCellsAsOnePool() { var cellA = new int2(1, 1); var cellB = new int2(2, 1); var e = Build(TestSettings(), SolidShape(4, 3), custom: new[] { ("Bar", BarShape(), cellA, 50f) }); var cellSize = e.Items.GameplaySettings.SchematicCellSize; // Centred exactly on the seam, radius small enough that the whole disc is contained within the two // cells combined (radius 0.4 cells < 0.5 cells' worth of margin either side of the seam). var worldCentre = e.Target.Position.xz + (float2(1.5f, cellA.y) - e.HullData.Shape.CenterOfMass) * cellSize; var radiusCells = .4f; // Full disc retained (contained within the pair of cells), split evenly by the seam symmetry: total // share = damage exactly, .08 on each side when damage = .16. FireControl.Detonate(e.Zone, worldCentre, radiusCells * cellSize, .16f, DamageType.Kinetic); Assert.Equal(50f - .16f, e.Custom["Bar"].EquippableItem.Durability, 3); } // ItemDamageFiresOncePerCoveredCell: the same pooled geometry as above. Pass: the number of ItemDamage // events equals the number of covered cells with a positive post-armour deposit (two), each carrying that // cell's own deposit. Kills: one event per item, and events for zero-deposit cells. [Fact] public void ItemDamageFiresOncePerCoveredCell() { var cellA = new int2(1, 1); var e = Build(TestSettings(), SolidShape(4, 3), custom: new[] { ("Bar", BarShape(), cellA, 50f) }); var cellSize = e.Items.GameplaySettings.SchematicCellSize; var worldCentre = e.Target.Position.xz + (float2(1.5f, cellA.y) - e.HullData.Shape.CenterOfMass) * cellSize; var events = new List(); using (e.Target.ItemDamage.Subscribe(x => events.Add(x.damage))) { FireControl.Detonate(e.Zone, worldCentre, .4f * cellSize, .16f, DamageType.Kinetic); } Assert.Equal(2, events.Count); Assert.All(events, amt => Assert.Equal(.08f, amt, 2)); } // ==== Symmetry and frame ==== // MirroredBlastsDoMirroredDamage: a hull, its armour and its items all mirror-symmetric about the bow // axis, facing (2,1)/sqrt(5). Two independent engagements, blasted at world points mirrored across the // target's own bow axis -- the mirror computed here from the facing, not through ToSchematic. Pass: // per-cell armour and item durability mirror. Kills: the +.5 cell convention returning in the overlap, a // missing or wrong CenterOfMass anchor, and any scheduling rule that depends on enumeration order. [Fact] public void MirroredBlastsDoMirroredDamage() { var facing = normalize(float2(2, 1)); var markerLeft = new int2(1, 1); var markerRight = new int2(3, 1); // mirror pair about x = 2 on a 5-wide hull Engagement Fresh() { var e = Build(TestSettings(), SolidShape(5, 3), targetFacing: facing, custom: new[] { ("MarkerL", new Shape(), markerLeft, 200f), ("MarkerR", new Shape(), markerRight, 200f) }); foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 40f; e.Target.MaxArmor[c.x, c.y] = 40f; } return e; } // Reflect a world-space offset across the target's own bow axis (its forward direction), computed here // independently of Entity.ToSchematic: reflecting across the line through the origin along `forward` // negates the lateral (starboard) component and keeps the along-bow component. float2 Mirror(float2 offset, float2 forward) { var along = dot(offset, forward) * forward; var lateral = offset - along; return along - lateral; } var e1 = Fresh(); var e2 = Fresh(); var cellSize = e1.Items.GameplaySettings.SchematicCellSize; var offset = float2(6f, 3f); // an arbitrary world offset from the target, not aligned to any axis var mirroredOffset = Mirror(offset, facing); FireControl.Detonate(e1.Zone, e1.Target.Position.xz + offset, 3f * cellSize, 300f, DamageType.Kinetic); FireControl.Detonate(e2.Zone, e2.Target.Position.xz + mirroredOffset, 3f * cellSize, 300f, DamageType.Kinetic); foreach (var c in e1.HullData.Shape.Coordinates) { var mirroredCell = new int2(4 - c.x, c.y); Assert.Equal(e1.Target.Armor[c.x, c.y], e2.Target.Armor[mirroredCell.x, mirroredCell.y], 1); } Assert.Equal(e1.Custom["MarkerL"].EquippableItem.Durability, e2.Custom["MarkerR"].EquippableItem.Durability, 1); Assert.Equal(e1.Custom["MarkerR"].EquippableItem.Durability, e2.Custom["MarkerL"].EquippableItem.Durability, 1); Assert.Equal(e1.Target.Hull.Durability, e2.Target.Hull.Durability, 1); } // ABlastDamagesTheCellsNearestIt. This succeeds SplashDamagesTheSideTheBlastCameFrom // (FireControlCut11Tests.cs) and SplashIsDirectional (FireControlCut4Tests.cs). Kills: a flip of // ToSchematicPoint's right axis, an x/y swap, and a missing `/ SchematicCellSize`. [Theory] [InlineData(0f, 1f)] [InlineData(1f, 0f)] [InlineData(2f, 1f)] [InlineData(-.6f, .8f)] public void ABlastDamagesTheCellsNearestIt(float fx, float fy) { var facing = normalize(float2(fx, fy)); var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: facing); foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } // Starboard computed here independently of FireControl -- the facing rotated clockwise. var starboard = float2(facing.y, -facing.x); var port = -starboard; var cellSize = e.Items.GameplaySettings.SchematicCellSize; var blastWorld = e.Target.Position.xz + float3(port.x, 0, port.y).xz * 10f; var damaged = new HashSet(); using (e.Target.ArmorDamage.Subscribe(x => damaged.Add(x.pos))) { FireControl.Detonate(e.Zone, blastWorld, 7f, 500f, DamageType.Kinetic); } // Port cells (schematic x < CoM.x) must take damage; starboard cells (schematic x > CoM.x) must not. // The centre column (x == CoM.x, only when CoM.x is an integer) is excluded from the assertion since // it can legitimately fall on either side depending on float rounding. var com = e.HullData.Shape.CenterOfMass; var portDamaged = false; foreach (var c in e.HullData.Shape.Coordinates) { if (c.x < com.x - .5f) { if (damaged.Contains(c)) portDamaged = true; } if (c.x > com.x + .5f) Assert.False(damaged.Contains(c), $"facing ({fx},{fy}): starboard cell {c} took damage from a port blast"); } Assert.True(portDamaged, $"facing ({fx},{fy}): no port cell took damage"); } // ==== Shields (Q12-9 = A) ==== // ShieldPaysForWhatReachesIt: the reserve drops by the entity's covered share, not by the whole blast. The // disc is centred exactly on the hull's edge (as in TheShareOffTheHullIsLost), so exactly half the raw // damage is the covered share -- a shield capacity between the covered share and the raw damage tells the // two apart cleanly. Kills: charging the full damage. [Fact] public void ShieldPaysForWhatReachesIt() { var e = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 70f); for (var i = 0; i < 30; i++) e.Zone.Update(.1f); // charge the reserve to full var cellSize = e.Items.GameplaySettings.SchematicCellSize; var worldCentre = e.Target.Position.xz + float2(-2.5f * cellSize, 0); Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); // fixture: the reserve is exactly full Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70.5f)); // damage 100, half retained (50) -- 50 <= capacity 70, so a correct implementation absorbs it whole and // never breaks; a "charge the full 100" mutant would find 100 > 70 and break the shield instead. var cellEvents = 0; using var a = e.Target.ArmorDamage.Subscribe(_ => cellEvents++); FireControl.Detonate(e.Zone, worldCentre, .5f * cellSize, 100f, DamageType.Kinetic); Assert.False(e.Target.Shield.Broken); Assert.Equal(0, cellEvents); // the shield took it all, nothing reached a cell // The reserve dropped by the covered share and by nothing else: 70 - 50 = 20 left. It is read through the // public query as a threshold probe, since no recharge runs between Detonate and here. Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 19.9f)); Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 20.1f)); } // An entity the disc does not touch has nothing to decide: it is skipped whole, its active shield is not // consulted and nothing is dealt. The blast lands 20 cells away from a shielded hull. [Fact] public void AnUntouchedEntitysShieldIsNotConsulted() { var e = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 70f); for (var i = 0; i < 30; i++) e.Zone.Update(.1f); Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); var events = 0; using var a = e.Target.ArmorDamage.Subscribe(_ => events++); using var h = e.Target.HullDamage.Subscribe(_ => events++); FireControl.Detonate(e.Zone, e.Target.Position.xz + float2(40f, 0f), 2f, 1000f, DamageType.Kinetic); Assert.Equal(0, events); Assert.False(e.Target.Shield.Broken); Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); } // ABlastShotsShieldIsDecidedAtDetonation (Q12-9 = A): Commit decides no shield for a shot that carries a // fuse, whatever the shield's own state at commit -- Detonate decides every blast's shield, live. Pinned // directly on the field Commit would otherwise set: ShotOutcome.Shielded/ShieldBroken must stay false for a // fused shot even when the shield could have absorbed the whole raw damage at commit. Kills: Commit's // shield block left ungated for fused shots. [Fact] public void ABlastShotsShieldIsDecidedAtDetonation() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, targetRange: 100, fuse: WeaponFuse.Contact, blastRadius: 4, damage: 50, equipShield: true, shieldCapacity: 1000f); // capacity far exceeds the raw damage for (var i = 0; i < 30; i++) e.Zone.Update(.1f); Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 50f)); // the shield COULD absorb the whole shot ShotOutcome outcome = null; using var s = e.Zone.ShotCommitted.Subscribe(o => outcome = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); Assert.NotNull(outcome); Assert.True(outcome.Hit); Assert.False(outcome.Shielded); Assert.False(outcome.ShieldBroken); } // The other half of Q12-9 = A: the shield's state at arrival, not at commit, decides. A contact shot in // 2 s of flight commits 1.5 s in; the shield is flipped between commit and arrival, and the blast (radius // 15 cells, so the whole 20-cell hull lies inside the disc and the covered share is exactly // damage * 20 / (pi * 15^2)) must follow the state it finds. The reserve is read through the public // CanTakeHit as a threshold probe: nothing recharges between Detonate and the read. [Fact] public void AShieldRaisedBetweenCommitAndArrivalAbsorbsTheBlast() { const float capacity = 1000f; var share = 50f * 20f / (PI * 15f * 15f); var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, fuse: WeaponFuse.Contact, blastRadius: 30f, damage: 50f, equipShield: true, shieldCapacity: capacity, shieldActive: false); ArmourEverything(e); var cellDamage = 0f; using var a = e.Target.ArmorDamage.Subscribe(x => cellDamage += x.damage); using var h = e.Target.HullDamage.Subscribe(x => cellDamage += x); ShotOutcome committed = null; using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) e.Zone.Update(.05f); Assert.True(committed.Hit); Assert.False(committed.Shielded); Assert.False(committed.ShieldBroken); e.Target.Shield.Item.Enabled.Value = true; // up before arrival, down at commit for (var i = 0; i < 4; i++) e.Zone.Update(.05f); Assert.True(e.Zone.PendingShots.Any(s => s.ShotId == shotId), "fixture: the shot must still be in flight"); Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - .1f), "fixture: the reserve must be full"); while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); Assert.Equal(0f, cellDamage); // the shield decided at arrival, so no cell took anything Assert.False(e.Target.Shield.Broken); Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - share - .05f)); Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - share + .05f)); } // And the reverse: up at commit, down at arrival -- the blast finds no shield and lands on the cells, and // the reserve is not charged. [Fact] public void AShieldDroppedBetweenCommitAndArrivalDoesNotAbsorbTheBlast() { const float capacity = 1000f; var share = 50f * 20f / (PI * 15f * 15f); var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, fuse: WeaponFuse.Contact, blastRadius: 30f, damage: 50f, equipShield: true, shieldCapacity: capacity); ArmourEverything(e); for (var i = 0; i < 30; i++) e.Zone.Update(.1f); var armour = 0f; using var a = e.Target.ArmorDamage.Subscribe(x => armour += x.damage); ShotOutcome committed = null; using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) e.Zone.Update(.05f); Assert.False(committed.Shielded); e.Target.Shield.Item.Enabled.Value = false; while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); Assert.Equal(share, armour, 2); // the whole covered share landed on the cells Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - .1f)); // and the reserve was not charged } // ==== Fuses (through Fire, SchematicCellSize 2) ==== // PenetratorBurrowsBeforeItBursts, which pins Q12-8 in both halves. A 3x3 solid hull's only interior cell // (1,1) carries a Cockpit item, ringed by the hull's own 8 border cells. [Fact] public void PenetratorBurrowsBeforeItBursts() { // With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, and the // armour of every ring cell on the lane is unchanged. Nothing is absorbed along the burrow. { var e = Build(TestSettings(), SolidShape(3, 3), penetration: 1.5f, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 40f, cockpitCell: new int2(1, 1), cockpitDurability: 1000f); foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } e.Target.Armor[1, 1] = 0f; e.Target.MaxArmor[1, 1] = 0f; // the cockpit's own cell carries no armour var before = e.Cockpit.EquippableItem.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); Assert.Equal(before - 40f, e.Cockpit.EquippableItem.Durability, 1); foreach (var c in e.HullData.Shape.Coordinates) if (!(c.x == 1 && c.y == 1)) Assert.Equal(1000f, e.Target.Armor[c.x, c.y], 1); } // A contact fuse with the same stats leaves the cockpit untouched (P sits at the impact cell, on the // ring, never reaching the buried cockpit). { var e = Build(TestSettings(), SolidShape(3, 3), penetration: 1.5f, fuse: WeaponFuse.Contact, blastRadius: .3f, damage: 40f, cockpitCell: new int2(1, 1), cockpitDurability: 1000f); var before = e.Cockpit.EquippableItem.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); Assert.Equal(before, e.Cockpit.EquippableItem.Durability); } // With the fuse removed and everything else held, the shot is a dumb AP round: the lane's armour is // spent in order, and the cockpit takes only what the ring left (12.3). Damage in a line. { var e = Build(TestSettings(), SolidShape(3, 3), penetration: 2f, fuse: null, blastRadius: 0f, damage: 40f, cockpitCell: new int2(1, 1), cockpitDurability: 1000f); foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 5f; e.Target.MaxArmor[c.x, c.y] = 5f; } e.Target.Armor[1, 1] = 0f; e.Target.MaxArmor[1, 1] = 0f; var before = e.Cockpit.EquippableItem.Durability; ShotOutcome outcome = null; for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit); attempt++) { using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } Assert.NotNull(outcome); Assert.True(outcome.Hit); Assert.Equal(before - 35f, e.Cockpit.EquippableItem.Durability, 1); // 40 - the front ring cell's 5 armour } } // TheFusePointStaysOnMetal: a delayed fuse with a penetration deeper than the hull clamps to the far edge. [Fact] public void TheFusePointStaysOnMetal() { var e = Build(TestSettings(), SolidShape(1, 5), penetration: 100f, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 100f); // The far edge of a 1x5 hull, straight ahead of the shooter (bearing (0,1)), is schematic y = 4.5 (the // exit of cell (0,4)); the fuse point clamps there instead of past the hull. Schematic x is the hull's // own CenterOfMass.x (0, the only column). var comX = e.HullData.Shape.CenterOfMass.x; var rCells = .3 / e.Items.GameplaySettings.SchematicCellSize; // BlastRadius is in world units // Independent expectation, from the test's own area integral centred on the far edge. var expected = 0f; foreach (var c in e.HullData.Shape.Coordinates) expected += (float) (100.0 * DiscAreaInCell(comX, 4.5, rCells, c) / (Math.PI * rCells * rCells)); var before = e.Target.Hull.Durability; ShotOutcome outcome = null; for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) { using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } Assert.NotNull(outcome); Assert.True(outcome.Hit); var delivered = before - e.Target.Hull.Durability; Assert.Equal(expected, delivered, 0); } // TheFusePointStaysOnMetal, the concave half: a 1x5 column with cell (0,2) missing. A lane's walk ends at // its first gap, so the reached metal is cells (0,0) and (0,1) whatever the penetration, and the fuse point // must clamp to the last reached cell's exit (schematic y 1.5, the metal's edge) rather than sit at // entry + penetration inside the gap (y 2.4 at penetration 2.9) or on the far segment the walk never // reached (y 2.7 at 3.2). Entries run from -.5, so at penetration 1.5 the point is y 1.0, inside cell (0,1), // with the disc wholly on metal. [Theory] [InlineData(1.5f, 1.0)] [InlineData(2.9f, 1.5)] [InlineData(3.2f, 1.5)] public void TheFusePointStaysOnMetalAcrossAConcaveGap(float penetration, double pointY) { var shape = SolidShape(1, 5); shape[new int2(0, 2)] = false; var e = Build(TestSettings(), shape, penetration: penetration, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 100f); var comX = e.HullData.Shape.CenterOfMass.x; var rCells = .3 / e.Items.GameplaySettings.SchematicCellSize; var expected = ExpectedDelivered(e.HullData.Shape, comX, pointY, rCells, 100.0); var before = e.Target.Hull.Durability; ShotOutcome outcome = null; for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) { using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } Assert.NotNull(outcome); Assert.True(outcome.Hit); Assert.Equal(expected, before - e.Target.Hull.Durability, 0); } // A contact or delayed blast needs a committed hit, exactly like a direct hit's own gate: on a miss nothing // happens -- no blast, no IncomingHit. Accuracy 0 pins the roll to a miss. Kills: ApplyBlastHit without its // `Outcome.Hit` guard, which would detonate at a point derived from a miss's empty geometry. [Theory] [InlineData(WeaponFuse.Contact)] [InlineData(WeaponFuse.Delayed)] public void AContactOrDelayedBlastOnAMissDoesNothing(WeaponFuse fuse) { var e = Build(TestSettings(), SolidShape(5, 4), fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 500f, accuracy: 0f); ArmourEverything(e); var before = e.Target.Hull.Durability; var events = 0; using var a = e.Target.ArmorDamage.Subscribe(_ => events++); using var h = e.Target.HullDamage.Subscribe(_ => events++); using var i = e.Target.ItemDamage.Subscribe(_ => events++); using var hit = e.Target.IncomingHit.Subscribe(_ => events++); ShotOutcome outcome = null; using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); Assert.NotNull(outcome); Assert.False(outcome.Hit); // fixture: a guaranteed miss Assert.Empty(e.Zone.PendingShots); Assert.Equal(0, events); Assert.Equal(before, e.Target.Hull.Durability); } // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre // of mass two world units along the starboard axis (right = (forward.y, -forward.x), spelled out here). At // facing (0,1) a flipped right axis is still masked by a centre-lane point; (2,1) is not. [Fact] public void ToWorldPointIsTheInverseOfToSchematicPointAtANonAxisFacing() { var facing = normalize(float2(2, 1)); var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: facing); var t = e.Target; var com = e.HullData.Shape.CenterOfMass; var starboard = float2(facing.y, -facing.x); var world = t.Position.xz + float2(7f, -3f); var back = t.ToWorldPoint(t.ToSchematicPoint(world)); Assert.Equal(world.x, back.x, 3); Assert.Equal(world.y, back.y, 3); var oneToStarboard = t.ToWorldPoint(com + float2(1, 0)); Assert.Equal(t.Position.x + 2f * starboard.x, oneToStarboard.x, 3); Assert.Equal(t.Position.z + 2f * starboard.y, oneToStarboard.y, 3); var oneToBow = t.ToWorldPoint(com + float2(0, 1)); Assert.Equal(t.Position.x + 2f * facing.x, oneToBow.x, 3); Assert.Equal(t.Position.z + 2f * facing.y, oneToBow.y, 3); } // A contact blast on a hull at facing (2,1): the shot enters over the starboard side, off the hull's own // centre line, so the blast point's lateral offset from the centre of mass is not zero and a flipped right // axis in ToWorldPoint would mirror it onto the far side. Placement is asserted per cell: the impact cell // takes a share, and no cell farther than one from it takes anything (radius .15 cells). Kills: a flip of // ToWorldPoint's right axis, which the host's round trip would otherwise hide from every total. [Fact] public void AContactBlastLandsOnTheCellItHitAtANonAxisFacing() { var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: float2(2, 1), fuse: WeaponFuse.Contact, blastRadius: .3f, damage: 100f); ArmourEverything(e); var deposits = new Dictionary(); using var a = e.Target.ArmorDamage.Subscribe(x => deposits[x.pos] = (deposits.TryGetValue(x.pos, out var d) ? d : 0f) + x.damage); ShotOutcome outcome = null; for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) { using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } Assert.NotNull(outcome); Assert.True(outcome.Hit); Assert.True(deposits.ContainsKey(outcome.Cell), $"the impact cell {outcome.Cell} took nothing; damaged: {string.Join(",", deposits.Keys)}"); foreach (var cell in deposits.Keys) Assert.True(Math.Abs(cell.x - outcome.Cell.x) <= 1 && Math.Abs(cell.y - outcome.Cell.y) <= 1, $"cell {cell} took damage far from the impact cell {outcome.Cell}"); } // TurningAfterCommitDoesNotMoveTheBlastOnItsHost: a contact-fuse shot. After commit and before arrival, the // host turns and moves. Pass: the host's per-cell damage equals that of the same shot with no turn. Kills: // taking P from the live bearing, or from Outcome.Cell's centre in place of the lane point. [Fact] public void TurningAfterCommitDoesNotMoveTheBlastOnItsHost() { Dictionary ArmorDamageByCell(Action afterCommit) { var e = Build(TestSettings(commitHorizon: .5f), SolidShape(5, 4), velocity: 50, targetRange: 100, fuse: WeaponFuse.Contact, blastRadius: 3f, damage: 100f); foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } // Per cell, not a total: a blast that lands on the wrong cells deals the same total. var perCell = new Dictionary(); using var a = e.Target.ArmorDamage.Subscribe(x => perCell[x.pos] = (perCell.TryGetValue(x.pos, out var d) ? d : 0f) + x.damage); var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); // Step to just past commit (flight time 2s, commit horizon 0.5s -> commits at t ~= 1.5s). while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) e.Zone.Update(.05f); afterCommit?.Invoke(e); while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); return perCell; } var noTurn = ArmorDamageByCell(null); var turned = ArmorDamageByCell(e => { e.Target.Direction = normalize(float2(1, 0)); e.Target.Position += float3(50, 0, -30); }); Assert.NotEmpty(noTurn); Assert.Equal(noTurn.Keys.OrderBy(k => k.x).ThenBy(k => k.y), turned.Keys.OrderBy(k => k.x).ThenBy(k => k.y)); foreach (var cell in noTurn.Keys) Assert.Equal(noTurn[cell], turned[cell], 2); } // LabelsDoNotDecideBehaviour: a weapon labelled Airburst with no fuse resolves as a direct hit, and a // weapon with Fuse = Proximity and no label detonates. [Fact] public void LabelsDoNotDecideBehaviour() { { var e = Build(TestSettings(), SolidShape(5, 4), fuse: null, blastRadius: 0f, damage: 50f, modifiers: WeaponModifiers.Airburst); var before = e.Target.Hull.Durability; var armorEvents = 0; using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); ShotOutcome outcome = null; for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) { using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } Assert.NotNull(outcome); Assert.True(outcome.Hit); Assert.True(armorEvents > 0); // a direct hit -- one lane's cells, never an area } { var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 20f, damage: 500f, modifiers: WeaponModifiers.None); var before = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); Assert.True(e.Target.Hull.Durability < before); } } // AFuseWithoutARadiusIsADirectHit: Fuse = Contact with a null or zero radius does lane damage. [Fact] public void AFuseWithoutARadiusIsADirectHit() { var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Contact, blastRadius: 0f, damage: 50f); var armorEvents = 0; using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); ShotOutcome outcome = null; for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) { using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } Assert.NotNull(outcome); Assert.True(outcome.Hit); Assert.True(armorEvents > 0); var shot = e.Zone.PendingShots.Count == 0 ? null : (PendingShot?) e.Zone.PendingShots[0]; Assert.Null(shot); // resolved and removed -- confirms it went through Apply's null-fuse path to completion } // ARadiusWithoutAFuseIsADirectHit (Soul F1): the symmetric inert half -- BlastRadius > 0 but the catalog // record carries no Fuse. Fire freezes Fuse as null regardless of the radius, so this resolves as a direct // hit exactly like AFuseWithoutARadiusIsADirectHit above. Kills: Fire freezing "detonates" from BlastRadius // alone, without also requiring a Fuse. [Fact] public void ARadiusWithoutAFuseIsADirectHit() { var e = Build(TestSettings(), SolidShape(5, 4), fuse: null, blastRadius: 40f, damage: 50f); var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); Assert.Null(shot.Fuse); Assert.Equal(40f, shot.BlastRadius); var armorEvents = 0; using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); ShotOutcome outcome = null; for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) { using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } Assert.NotNull(outcome); Assert.True(outcome.Hit); Assert.True(armorEvents > 0); // a direct hit -- not spread over a disc } // AContactBlastReportsTheHit: IncomingHit fires once, for the host, on a committed contact hit, and never // for a proximity burst. [Fact] public void AContactBlastReportsTheHit() { { var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Contact, blastRadius: 3f, damage: 50f); var hits = 0; using var h = e.Target.IncomingHit.Subscribe(_ => hits++); ShotOutcome outcome = null; for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) { using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } Assert.NotNull(outcome); Assert.True(outcome.Hit); Assert.Equal(1, hits); } { var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 20f, damage: 50f); var hits = 0; using var h = e.Target.IncomingHit.Subscribe(_ => hits++); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); Assert.Equal(0, hits); } } // Fire freezes a proximity shot's burst point at the predicted intercept of a moving target -- where a shot // of the weapon's own velocity, fired now, meets the target -- not at the target's position now. The // expectation is solved here from first principles: |d + v t| = s t for the flight time t, planar, the // shooter's own motion never fed in. Target 100 ahead, drifting (30,0) against a 50 unit/s shot: t = 2.5 s, // so the burst sits 75 units to the side of where the target is at Fire. // Kills: BurstPosition frozen at the target's current position (PredictedIntercept never consulted). [Fact] public void ProximityBurstPositionIsThePredictedInterceptForAMovingTarget() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, fuse: WeaponFuse.Proximity, blastRadius: 3f, damage: 50f); var v = float2(30f, 0f); e.Target.Velocity = v; var s = e.Shooter.Position.xz; var d = e.Target.Position.xz - s; var a = dot(v, v) - 50f * 50f; var b = 2f * dot(d, v); var c = dot(d, d); var t = (-b - sqrt(b * b - 4f * a * c)) / (2f * a); // a < 0: the positive root Assert.Equal(2.5f, t, 3); var expected = e.Target.Position.xz + v * t; var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var shot = e.Zone.PendingShots.Single(x => x.ShotId == shotId); Assert.Equal(expected.x, shot.BurstPosition.x, 2); Assert.Equal(expected.y, shot.BurstPosition.z, 2); Assert.True(length(shot.BurstPosition.xz - e.Target.Position.xz) > 70f, "the burst must lead the target, not sit on it"); } // Detonate has no radius guard: a nonpositive radius covers no cell (a disc with no area overlaps nothing), // so it delivers nothing and touches no shield. [Theory] [InlineData(0f)] [InlineData(-5f)] public void DetonateCannotBeReachedWithANonPositiveRadius(float radius) { var e = Build(TestSettings(), SolidShape(5, 4)); foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 100f; e.Target.MaxArmor[c.x, c.y] = 100f; } var before = e.Target.Hull.Durability; FireControl.Detonate(e.Zone, e.Target.Position.xz, radius, 1000f, DamageType.Kinetic); Assert.Equal(before, e.Target.Hull.Durability); Assert.Equal(100f, e.Target.Armor[2, 1], 2); } // ==== Commit's probes: the shipped catalog is neutral, and the field survives a full write/reopen. ==== private static string FindRepoRoot() { for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) return dir.FullName; throw new DirectoryNotFoundException("Run from inside the Aetheria repository."); } private static CultCache OpenReadOnlyRealCatalog(string catalogPath, out SingleFileMessagePackBackingStore store) { var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() .Where(t => t is { IsAbstract: false, IsInterface: false }) .Where(t => t.GetCustomAttribute() != null)); var cache = new CultCache(registry); store = new SingleFileMessagePackBackingStore(catalogPath, true); cache.AddBackingStore(store, AetheriaStores.CatalogTypes); return cache; } // Every WeaponItemData in the shipped catalog has a null BlastRadius and a null Fuse, and the weapon // schema's own migration report is CompatibleDrift with only slot 32 defaulted and no other slots touched. [Fact] public void ShippedCatalogIsNeutralOnBlastRadiusAndFuse() { var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); var cache = OpenReadOnlyRealCatalog(gameData, out var store); try { var weapons = cache.GetAll().ToList(); Assert.NotEmpty(weapons); foreach (var w in weapons) { Assert.Null(w.BlastRadius); Assert.Null(w.Fuse); } var reports = store.LastSchemaMigrationReports.Where(r => r.LocalSchemaName == "aetheria.weaponitemdata").ToList(); Assert.NotEmpty(reports); foreach (var report in reports) { Assert.Equal(CultSchemaMigrationKind.CompatibleDrift, report.Kind); Assert.Equal(new[] { 32 }, report.DefaultedMissingSlots); Assert.Empty(report.IgnoredExtraSlots); } } finally { cache.Dispose(); } } // A WeaponItemData round-trip for each WeaponFuse value and null, crossed with radius null, 0 and // positive, through a written and reopened temporary copy of the shipped catalog. [Fact] public void WeaponFuseAndBlastRadiusRoundTripThroughAWrittenCatalog() { var src = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); var fuseValues = new WeaponFuse?[] { null, WeaponFuse.Contact, WeaponFuse.Proximity, WeaponFuse.Delayed }; var radiusValues = new float?[] { null, 0f, 12.5f }; foreach (var fuse in fuseValues) foreach (var radius in radiusValues) { var tmp = Path.Combine(_root, "roundtrip-" + Guid.NewGuid().ToString("N") + ".cc"); File.Copy(src, tmp); try { { var cache = OpenReadOnlyRealCatalog(tmp, out _); cache.Dispose(); } var writeCache = new CultCache(CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() .Where(t => t is { IsAbstract: false, IsInterface: false }) .Where(t => t.GetCustomAttribute() != null))); writeCache.AddBackingStore(new SingleFileMessagePackBackingStore(tmp, false), AetheriaStores.CatalogTypes); var w = writeCache.GetAll().OrderBy(x => x.Name).First(); w.Fuse = fuse; w.BlastRadius = radius; writeCache.Upsert(w); writeCache.FlushAsync().Wait(); writeCache.Dispose(); var readCache = OpenReadOnlyRealCatalog(tmp, out _); var reopened = readCache.GetAll().OrderBy(x => x.Name).First(); Assert.Equal(fuse, reopened.Fuse); Assert.Equal(radius, reopened.BlastRadius); readCache.Dispose(); } finally { File.Delete(tmp); } } } }