IsRevealed is now the observer's book (Entity.HasReveal) plus the live filter (still on its ship, not IsDestroyed). The tier ladder and GameplaySettings.TargetArmorInfoThreshold are deleted, with the 19 fixture initializers and the Settings.asset line. Fixtures that aim at an item record its reveal through RecordReveal; Aetheria.Shared exposes internals to Aetheria.Shared.Tests for that. Unbuilt and untested at this commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
318 lines
16 KiB
C#
318 lines
16 KiB
C#
/* 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;
|
|
|
|
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
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|