Aetheria/tests/Aetheria.Shared.Tests/FireControlTests.cs

318 lines
16 KiB
C#
Raw Normal View History

/* 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 VerseGrammar { Revision = 1 });
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<ItemData>(items.ItemData.GetByName<HullData>("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<ItemData>(items.ItemData.GetByName<GearData>("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<ItemData>(items.ItemData.GetByName<HullData>("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<ItemData>(items.ItemData.GetByName<GearData>("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;
Mining Cut 3: a chunk is a target, detected by reflected light - Entity.Target is one slot of TargetRef, read-only from outside (IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is taken only while the entity can see it, so a rock in darkness cannot be picked. Every handler, agent, turret and test writes through SetTarget. - Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at, computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1. Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection threshold through the chunk path. - The lost-track rule reads through the chunk path: Activate's reconciliation and a per-tick check of the one slot drop a chunk target that has gone dark. - Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range among active weapons -- the one reach rule for cycling and programmatic picks. - Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items, the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk), turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4). TargetRange and the arc gate read a chunk's planar position. - ActionGameManager: the reticle and next/previous offer visible ships and visible chunks in reach; nearest stays enemies-only; the target panel, indicator and fire-control debug take TargetRef. EntityInstance reads .Entity. - MiningCut3Tests pin parity, darkness, monotone brightness, wear and break, reach, the chunk query, target survival and loss, TargetRef equality, lock, and field-kind assignment at generation and first load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
shooter.SetTarget(target);
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
// 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
}
// Combat picks a target item only among the items its ship holds a reveal of: the target's gun is in range and
// the target is fully tracked, yet nothing is aimed at until the shooter has seen the gun act. Mutation:
// Combat tests weapons without IsRevealed, or IsRevealed falls back to the track.
[Fact]
public void TheAIAimsOnlyAtWhatItSaw()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10));
var targetGun = target.Equipment.Single(x => x.Behaviors.Any(b => b is Weapon));
zone.Update(1f);
zone.Update(1f);
Assert.Null(shooter.TargetItem.Value);
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash);
zone.Update(1f);
Assert.Same(targetGun, shooter.TargetItem.Value);
}
// 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);
}
}