/* 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 CultMath; using GameCult.Caching; using UniRx; using Xunit; using static CultMath.math; using float2 = CultMath.float2; using float3 = CultMath.float3; using int2 = CultMath.int2; // Cut 11 (docs/fire-control-cut.md): behavioural tests for what a Stryker run against FireControl.cs found // undefended. Every test here states a rule as behaviour, observed through the public surface, and is the // test whose absence let a real mutant survive. Builds its own fixtures, the convention every cut's test // file follows. public sealed class FireControlCut11Tests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut11-" + Guid.NewGuid().ToString("N")); private string Catalog => Path.Combine(_root, "Aetheria.cc"); public FireControlCut11Tests() => 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 schematicCellSize = 1f) => 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 = .5f, SchematicCellSize = schematicCellSize, UnaidedAccuracy = .05f, UnaidedTracking = 10f, UnaidedPrecision = 2f, AgentMinHitProbability = 0f, BeamResolveInterval = .1f }; private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; 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; } private sealed class Engagement { public Zone Zone; public Ship Shooter; public Ship Target; public EquippedItem WeaponItem; public InstantWeapon Weapon; public EquippedItem PortMarker; public EquippedItem StarboardMarker; } // A shooter and a target. The shooter sits OFF the origin on purpose: a shooter at float3.zero makes // `target - source` and `target + source` the same vector, which is exactly how four position mutants in // FireControl.cs survived every earlier suite. private Engagement Build( string zoneName = "Cut11", float damage = 5, float velocity = 0, float accuracy = 1, bool equipShield = false, float shieldCapacity = 30, bool markers = false, float spread = 0, float schematicCellSize = 1f) { // 5 wide x 3 tall: schematic x runs port (0) to starboard (4). Tool gear may only occupy interior // cells, which on this hull is the middle row x = 1..3, so the markers sit at (1, 1) and (3, 1) -- // one cell either side of the centre of mass (2, 1). var hullData = new HullData { Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 3), Durability = 100000, Mass = 1000, Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(2, 2), 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(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new InstantWeaponData { Damage = Constant(damage), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0), Penetration = Constant(0), 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(1000), Precision = Constant(1), Tracking = Constant(100000) } } }); 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) } } }); cache.Upsert(new GearData { Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 100, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= 16; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; var items = new ItemManager(cache, ledger, TestSettings(schematicCellSize), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, 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 }; var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); var shooter = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = 1 }, new EntitySettings()); var gun = Make("Gun", 2, 1); Assert.True(shooter.TryEquip(gun, new int2(2, 2))); var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); var targeting = Make("Targeting", 3, 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 = 100000, Lot = 4 }, new EntitySettings()); Assert.True(target.TryEquip(Make("Gun", 5, 1), new int2(2, 2))); EquippedItem port = null, starboard = null; if (markers) { var p = Make("Marker", 6, 100); var s = Make("Marker", 7, 100); Assert.True(target.TryEquip(p, new int2(1, 1)), "port marker must fit an interior cell"); Assert.True(target.TryEquip(s, new int2(3, 1)), "starboard marker must fit an interior cell"); port = target.Equipment.Single(x => x.EquippableItem == p); starboard = target.Equipment.Single(x => x.EquippableItem == s); } if (equipShield) { var reactor = Make("Reactor", 8, 10); Assert.True(target.TryFindSpace(reactor, out var rpos)); Assert.True(target.TryEquip(reactor, rpos)); var shield = Make("Shield", 9, 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(); shooter.Position = float3(37, 0, -11); target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); if (equipShield) target.Shield.Item.Enabled.Value = true; zone.Update(0f); return new Engagement { Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon), PortMarker = port, StarboardMarker = starboard }; } private static void Charge(Engagement e) { for (var i = 0; i < 20; i++) e.Zone.Update(.1f); } // ---- The shield pays for what it absorbs. ---- // Deleting Shield.TakeHit from FireControl.Apply survived every earlier suite, because they asserted only // that an absorbed hit left the hull untouched -- never that the shield paid for it. A shield that absorbs // must eventually run dry. Stepped with dt = 0 so the (near-instant) refill cannot top the reserve back up // between shots. Mutation: delete TakeHit from the discrete path; the reserve never drains. [Fact] public void DiscreteShieldAbsorptionDrainsTheReserve() { var e = Build(damage: 5, equipShield: true, shieldCapacity: 30); Charge(e); Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); var hull = e.Target.Hull.Durability; var shots = 0; while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && shots < 200) { FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(0f); shots++; } Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); // the reserve ran dry Assert.False(e.Target.Shield.Broken); // by paying, not by breaking Assert.Equal(hull, e.Target.Hull.Durability); // and every hit was absorbed } // The same rule on a blast's path (12.4(b) rewrites this to Detonate, docs/fire-control-cut.md // "ShieldPaysForWhatReachesIt": "the reserve drops by the entity's covered share, not by the whole // blast"), whose absorb branch had no test coverage at all. The radius sits well inside the hull's own // solid interior (the 5x3 shape's centre of mass, at least 1 cell from every edge) so the disc never spills // off the hull -- the covered share equals the whole damage exactly, the same amount Splash always charged, // so this expectation is not loosened. Mutation: delete TakeHit from Detonate's absorb branch. [Fact] public void ABlastShieldAbsorptionDrainsTheReserve() { var e = Build(equipShield: true, shieldCapacity: 30); Charge(e); Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); var hull = e.Target.Hull.Durability; var blasts = 0; while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && blasts < 200) { FireControl.Detonate(e.Zone, e.Target.Position.xz, radius: 1, damage: 5, damageType: DamageType.Kinetic); blasts++; } Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); Assert.False(e.Target.Shield.Broken); Assert.Equal(hull, e.Target.Hull.Durability); } // ---- A shot whose target is gone resolves as a miss (Soul C4). ---- // The 0b table says a shot whose target has left the zone resolves as a miss "whichever stage it is at." // Step only rewrote the outcome on the uncommitted branch, so a hit committed inside the horizon and then // orphaned by the target dying was republished as a hit on ShotResolved -- a hit marker on a corpse. The // committed outcome is not mutated (R4 keeps it immutable); the resolution is published as its own miss. [Fact] public void ShotAtTargetThatLeavesResolvesAsMiss() { var e = Build(velocity: 20); // 100 units at 20 u/s: flight 5 s, commits at 4.5 s ShotOutcome committed = null, resolved = null; var commits = 0; using var c = e.Zone.ShotCommitted.Subscribe(o => { committed = o; commits++; }); using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(4.7f); // past the commit, short of arrival Assert.NotNull(committed); Assert.True(committed.Hit, "precondition: this fixture's shot must commit as a hit"); Assert.Null(resolved); // What the HUD's arrival countdown reads: time left in flight at commit, 5 s - 4.7 s. Assert.Equal(.3f, committed.ArrivalIn, 3); e.Zone.Entities.Remove(e.Target); // the target is gone before the shot arrives e.Zone.Update(1f); Assert.NotNull(resolved); Assert.Equal(committed.ShotId, resolved.ShotId); Assert.False(resolved.Hit, "a shot whose target left the zone must resolve as a miss, not publish its committed hit"); Assert.True(committed.Hit, "the committed outcome itself is not rewritten (R4)"); Assert.Equal(1, commits); // a shot commits once; its orphaned resolution is not a second commit } // The other stage of the same rule: a target gone before the shot even commits. The shot never got its // commit, so the miss is published as the commit too -- a listener waiting on ShotCommitted for this // ShotId must not wait forever -- and it is a plain miss, with no shield hit or shield break attached. [Fact] public void ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss() { var e = Build(velocity: 20, equipShield: true); // commits at 4.5 s Charge(e); var committed = new List(); var resolved = new List(); using var c = e.Zone.ShotCommitted.Subscribe(committed.Add); using var r = e.Zone.ShotResolved.Subscribe(resolved.Add); var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(1f); SafeAssert.Empty(committed); e.Zone.Entities.Remove(e.Target); e.Zone.Update(1f); foreach (var outcome in new[] { SafeAssert.Only(committed), SafeAssert.Only(resolved) }) { Assert.Equal(shotId, outcome.ShotId); Assert.False(outcome.Hit); Assert.False(outcome.Shielded, "a shot at nothing did not hit a shield"); Assert.False(outcome.ShieldBroken, "a shot at nothing did not break a shield"); } SafeAssert.NoShots(e.Zone); } // ---- A targeting system's Tracking is what forgives a target that moves off its predicted line. ---- // Tracking fell back to GameplaySettings.UnaidedTracking under a mutant that ignored the fitted system, // and nothing noticed: every fixture's target sat still, so deviation was 0 and Tracking divided nothing. // Here the target jinks 5 units sideways mid-flight. The fitted system (Tracking 100000) shrugs it off; // unaided Tracking (10) would halve the shot's chance. [Fact] public void AFittedTargetingSystemForgivesAJink() { var e = Build(velocity: 20); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(1f); e.Target.Position += float3(5, 0, 0); var shot = e.Zone.PendingShots.Single(); var p = FireControl.DeviationProbability(shot, e.Zone.Time, out var deviation); Assert.Equal(5f, deviation, 3); Assert.True(p > .99f, $"a 5-unit jink against Tracking 100000 must barely matter, got {p}"); } // ---- A beam's shot carries the interval's damage, not the weapon's. ---- // ConstantWeapon fires one shot per BeamResolveInterval for Damage * interval, through Fire's // damageOverride. Dropping the override froze the weapon's full Damage into every one of those shots -- // a beam doing ten times its damage at the default interval -- and survived, because the beam test // counted its rolls and multiplied the count by the expected damage itself. [Fact] public void ADamageOverrideIsWhatTheShotCarries() { var e = Build(damage: 5, velocity: 20); var full = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var partial = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride: .5f); Assert.Equal(5f, e.Zone.PendingShots.Single(s => s.ShotId == full).Damage); Assert.Equal(.5f, e.Zone.PendingShots.Single(s => s.ShotId == partial).Damage); } // ---- Splash damages the side the blast came from. ---- // // Superseded (docs/fire-control-cut.md, "Symmetry and frame"): ABlastDamagesTheCellsNearestIt in // FireControlCut124Tests.cs succeeds this test and Cut4Tests.SplashIsDirectional -- a blast is now an area // (12.4(b)), not a directional half-hull split, so "the side the blast came from" is pinned by which cells // the disc actually covers, not by a facing-rotated half-shape. // ---- pSpread is the share of the spread cone the target's silhouette covers. ---- // Every earlier fixture used zero spread (pSpread is then 1 regardless) and a cell size of exactly 1 (so // multiplying and dividing by it agree), which left the whole spread term untested. This pins what the // number MEANS rather than how it is computed: a silhouette that exactly fills the cone scores 1, one half // the cone's angular size scores .5, and one bigger than the cone saturates at 1. Here the 5-wide hull at a // cell size of 2 has a half-extent of 5 world units, against a 10-degree cone (5-degree half-angle). [Fact] public void PSpreadIsTheSilhouettesShareOfTheSpreadCone() { const float halfExtent = 5f, halfCone = 5f; float At(float range) { var e = Build(spread: 2 * halfCone, schematicCellSize: 2f); e.Target.Position = e.Shooter.Position + float3(0, 0, range); return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).PSpread; } var fills = halfExtent / (float) Math.Tan(halfCone * Math.PI / 180); // silhouette == cone var half = halfExtent / (float) Math.Tan(halfCone / 2 * Math.PI / 180); // silhouette == half the cone Assert.Equal(1f, At(fills), 2); Assert.Equal(.5f, At(half), 2); Assert.Equal(1f, At(fills / 3), 2); // bigger than the cone: saturates, never exceeds 1 Assert.True(At(half * 2) < At(half)); // and falls away with range } // ---- Zones roll differently. ---- // The roll is a function of (zone, shot id). Replacing `CombatSeed *` with `/` in Commit's seed drops the // zone out of it -- integer division by a large constant is zero for almost every seed -- so every zone // rolls the same sequence. That survived, because the die test only checks uniformity within one zone. // Same shot ids in two zones must not produce the same hit sequence. [Fact] public void DifferentZonesRollDifferently() { bool[] Sequence(string zoneName) { var e = Build(zoneName: zoneName, accuracy: .5f); var p = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); // accuracy times the other factors var hits = new List(); using var r = e.Zone.ShotResolved.Subscribe(o => hits.Add(o.Hit)); for (var i = 0; i < 400; i++) { FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(0f); } Assert.Equal(400, hits.Count); var fraction = hits.Count(h => h) / 400f; Assert.True(Math.Abs(fraction - p) < .08f, $"zone {zoneName}: hit fraction {fraction:F3} is not near p = {p:F3}"); return hits.ToArray(); } var a = Sequence("Rhea"); var b = Sequence("Mimas"); var differ = a.Zip(b, (x, y) => x != y).Count(d => d) / 400f; // Two independent rolls at p ~ .4 disagree 2p(1-p) ~ 48% of the time; identical seeds disagree never. Assert.True(differ > .3f, $"only {differ:P1} of shots differ between zones -- the zone is not reaching the roll"); } }