/* 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.IO; using System.Linq; using CultMath; using GameCult.Caching; using Xunit; using static CultMath.math; using float3 = CultMath.float3; // Cut 1 (docs/fire-control-cut.md): pins mount direction, arc authoring/defaulting and the headless // engagement this cut exists to unbreak. Builds its own hull/catalog (the IffAndCombatTests Skiff/Gun // fixture is fixed to one hardpoint) rather than reusing those private helpers. public sealed class FireControlTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrol-" + Guid.NewGuid().ToString("N")); private string Catalog => Path.Combine(_root, "Aetheria.cc"); public FireControlTests() => Directory.CreateDirectory(_root); private CultCache _openCache; public void Dispose() { _openCache?.Dispose(); Directory.Delete(_root, true); } private static GameplaySettings TestSettings() => 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 = .5f, FiringArc = 120 }; // A hull with one weapon hardpoint per (rotation, firingArc) pair, laid out along the X axis of a 1xN // shape so each gets its own cell. FiringArc 0 on a hardpoint means "use GameplaySettings.FiringArc". private (ItemManager items, Ship ship, EquippedItem[] weapons) BuildShipWithHardpoints( params (ItemRotation rotation, float firingArc)[] hardpoints) { var hullShape = new Shape(Math.Max(hardpoints.Length, 3), 3); foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; // Mass defaults to 0 (ItemData.cs:285), which zeroes ThermalMass (Entity.cs MapEntity) and NaNs // UpdateTemperature -- author a real mass so the entity's own temperature stays a real number. var hullData = new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = hullShape, Durability = 1, Mass = 1000 }; for (var i = 0; i < hardpoints.Length; i++) hullData.Hardpoints.Add(new HardpointData { Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(), Rotation = hardpoints[i].rotation, FiringArc = hardpoints[i].firingArc }); using (var buildCache = AetheriaStores.Open(Catalog, catalogWritable: true)) { buildCache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); buildCache.Upsert(hullData); buildCache.Upsert(new GearData { // The default [0,100] temperature bounds (ItemData.cs:379-382) don't necessarily cover // wherever this hull's heat model settles after a tick: author a wide plateau so // EquippableItemData.Performance stays 1 and the item actually comes Online. Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new InstantWeaponData() } }); buildCache.FlushAsync().Wait(); } _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); var ledger = new ProvenanceLedger(); ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; // hull for (var i = 0; i < hardpoints.Length; i++) ledger.Lots[2 + i] = new Lot { Origin = new Attributed(), Quality = 1 }; // guns var items = new ItemManager(_openCache, ledger, TestSettings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("TestHull")); var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 }; var ship = new Ship(items, zone, hull, new EntitySettings()); // Entity.MapEntity equips the hull itself into Equipment at int2.zero, so hardpoint 0 (also at // (0,0) here) shares a cell with the hull's own equipped-item entry: match guns by identity, not // position. var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); var weapons = new EquippedItem[hardpoints.Length]; for (var i = 0; i < hardpoints.Length; i++) { var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 + i }; Assert.True(ship.TryEquip(gun, new int2(i, 0))); weapons[i] = ship.Equipment.Single(e => e.EquippableItem == gun); } zone.Entities.Add(ship); ship.Activate(); return (items, ship, weapons); } // R6: mount direction is the item's authored rotation, not the hull's facing. A target 90 degrees to // starboard bears on the Clockwise hardpoint's weapon and not the None hardpoint's, and vice versa dead // ahead. Mutation: make InArc read Entity.Direction instead of the item's rotation, or swap Rotate's // Clockwise/CounterClockwise cases -- either breaks one of the four assertions below. [Fact] public void ArcFollowsMountRotation() { var (_, _, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f), (ItemRotation.Clockwise, 0f)); var forward = weapons[0]; var starboardMounted = weapons[1]; var deadAhead = float3(0, 0, 1); var abeamStarboard = float3(1, 0, 0); Assert.True(FireControl.InArc(forward, deadAhead)); Assert.False(FireControl.InArc(forward, abeamStarboard)); Assert.True(FireControl.InArc(starboardMounted, abeamStarboard)); Assert.False(FireControl.InArc(starboardMounted, deadAhead)); } // FiringArc is full width: at the default 120, a target 59 degrees off the mount bears and one at 61 // does not. Mutation: compare against cos(radians(arc)) instead of cos(radians(arc/2)). [Fact] public void ArcBoundaryIsHalfWidth() { var (_, _, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f)); var weapon = weapons[0]; float3 AtAngle(float degrees) { var r = radians(degrees); return float3(sin(r), 0, cos(r)); } Assert.True(FireControl.InArc(weapon, AtAngle(59))); Assert.False(FireControl.InArc(weapon, AtAngle(61))); } // R7: the simulation is 2D. A target inside the arc horizontally but far above the firer still bears. // Mutation: normalize and dot the full 3D vector instead of zeroing height first. [Fact] public void ArcIsPlanar() { var (_, _, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f)); var weapon = weapons[0]; var highButAhead = float3(0, 1000, 1); Assert.True(FireControl.InArc(weapon, highButAhead)); } // The per-hardpoint override wins over the default, and an authored 0 means "use the default," not // "zero-degree arc." A hardpoint authored FiringArc: 360 bears 170 degrees off; a hardpoint left at 0 // falls back to GameplaySettings.FiringArc (120) and fails there, but still bears a target only 5 // degrees off. Mutation: ignore HardpointData.FiringArc entirely (both hardpoints fail at 170), or // treat an authored 0 as a literal zero-width arc (the second hardpoint then fails even at 5 degrees). [Fact] public void HardpointOverrideBeatsDefault() { var (_, _, weapons) = BuildShipWithHardpoints((ItemRotation.None, 360f), (ItemRotation.None, 0f)); var turret = weapons[0]; var defaultArc = weapons[1]; float3 AtAngle(float degrees) { var r = radians(degrees); return float3(sin(r), 0, cos(r)); } Assert.True(FireControl.InArc(turret, AtAngle(170))); Assert.False(FireControl.InArc(defaultArc, AtAngle(170))); Assert.True(FireControl.InArc(defaultArc, AtAngle(5))); } // A shooter with one in-range, in-arc gun, a detected target at `targetPosition`, and a Minion driving the shooter. private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition, float projectileSpeed = 0f) { var (items, shooter, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f)); var weaponData = (InstantWeaponData) weapons[0].Data.Behaviors.Single(b => b is InstantWeaponData); weaponData.Damage = new PerformanceStat { Min = 10, Max = 10 }; weaponData.Cooldown = new PerformanceStat { Min = 1, Max = 1 }; weaponData.Range = new PerformanceStat { Min = 1000, Max = 1000 }; weaponData.MinRange = new PerformanceStat { Min = 0, Max = 0 }; weaponData.Velocity = new PerformanceStat { Min = projectileSpeed, Max = projectileSpeed }; weaponData.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; var zone = shooter.Zone; var targetHullRef = items.ItemData.RefOf(items.ItemData.GetByName("TestHull")); var target = new Ship(items, zone, new EquippableItem { Data = targetHullRef, Durability = 1, Lot = 1 }, new EntitySettings()); // Entity._orderedEquipment is populated only by TryEquip (Entity.cs:813); a ship that never equips // anything leaves it null, and Entity.Update (:932) throws on that regardless of this cut. Equip a // gun so the target is a real, ordinarily-constructed ship rather than a workaround-shaped one. var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); Assert.True(target.TryEquip(new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 1 }, new int2(0, 0))); zone.Entities.Add(target); target.Activate(); shooter.Position = float3.zero; target.Position = targetPosition; shooter.SetTarget(target); // Cut 3: CombatState now gates on FireControl.HitProbability, which is zero for an undetected target // (the target is not in VisibleEntities) regardless of arc -- cross TargetDetectionInfoThreshold // directly, the same way a Sensor's own info accrual eventually would. shooter.EntityInfoGathered[target] = 1f; // This fixture's shooter carries no targeting system, so Accuracy falls back to UnaidedAccuracy (Q4: // authored deliberately bad). This test's own concern is the headless pipeline reaching a fire // decision at all (Cut 1's regression), not AgentMinHitProbability tuning -- zero the threshold so an // unaided shot still counts as "worth it." items.GameplaySettings.AgentMinHitProbability = 0f; zone.Agents.Add(new Minion(shooter)); return (shooter, target, zone); } // The cut's real deliverable: Entity.HardpointTransforms is gone, so a Minion can step through a full // Zone.Update tick and reach the fire decision without Unity ever having run. Before this cut, // Combat.cs:100-102 threw KeyNotFoundException on the first combat tick headless, because the AI's aim // direction was a readback from a renderer that never ran. Mutation: restore the HardpointTransforms // read in Behavior.Direction / Combat.cs -- this throws again outside Unity. [Fact] public void CombatStateStepsHeadless() { // dead ahead of the None-rotation hardpoint, well within range and arc var (shooter, _, zone) = BuildCombatEncounter(float3(0, 0, 10)); var ex = Record.Exception(() => { zone.Update(1f); // tick 1: transitions the Minion into CombatState and evaluates weapon stats for the first time zone.Update(1f); // tick 2: CombatState.Update reaches FireControl.InArc -- the line that used to throw headless }); Assert.Null(ex); var weapon = shooter.Weapons.Single(); Assert.True(weapon.Firing); // arc + range satisfied: the fight actually decided to fire, not just avoided a crash } // Controls cut (helm split): Combat aims the shooter at the intercept (Aim) and commands the hull toward it // through the one steering law (Turn); it never turns the hull by writing Aim. [Fact] public void CombatAimsAtTheInterceptAndTurnsTowardIt() { var (shooter, target, zone) = BuildCombatEncounter(float3(10, 0, 10)); // 45 degrees to starboard, in arc var direction = shooter.Direction; zone.Update(1f); zone.Update(1f); var bearing = normalize(target.Position.xz - shooter.Position.xz); var aim = normalize(shooter.Aim.xz); Assert.True(dot(aim, bearing) > .999f, "Aim points at the stationary target's intercept"); Assert.Equal(direction, shooter.Direction); // no thrusters on this hull: only Turn was commanded Assert.Equal(Steering.Toward(shooter, shooter.Aim.xz), shooter.Turn); Assert.True(abs(shooter.Turn) > .5f); } // Against a target that moves, Combat aims at where the round will meet it (FireControl.PredictedIntercept), // not where the target is: the aim leads, and the hull's Turn follows the intercept bearing. [Fact] public void CombatLeadsAMovingTarget() { var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 100), projectileSpeed: 20f); zone.Update(1f); // transitions the Minion into CombatState and evaluates weapon stats // Agents update before entities, so Combat reads exactly this state on the next tick. target.Position = float3(0, 0, 100); target.Velocity = float2(4, 0); var weapon = shooter.Weapons.Single(); var intercept = normalize((FireControl.PredictedIntercept(weapon, shooter, target) - shooter.Position).xz); var bearing = normalize(target.Position.xz - shooter.Position.xz); Assert.True(dot(intercept, bearing) < cos(radians(5f)), "the fixture really leads the target"); zone.Update(1f); var aim = normalize(shooter.Aim.xz); Assert.True(dot(aim, intercept) > .9999f, $"aim {shooter.Aim}"); Assert.Equal(Steering.Toward(shooter, intercept), shooter.Turn); Assert.True(shooter.Turn > 0f); } // A Minion whose target goes away returns to its root state, which pilots nothing: the hull must stop // turning rather than keep the rate Combat last commanded. [Fact] public void AMinionThatLosesItsTargetStopsTurning() { var (shooter, _, zone) = BuildCombatEncounter(float3(10, 0, 10)); zone.Update(1f); zone.Update(1f); Assert.True(abs(shooter.Turn) > .5f, "Combat commanded a turn"); shooter.SetTarget(TargetRef.None); zone.Update(1f); // Combat has no target and the Minion transitions to its root state zone.Update(1f); Assert.Equal(0f, shooter.Turn); } }