Pin fire-control arc rules and the headless engagement
One check per rule Cut 1 lands, each with a stated mutation that would
defeat it: mount direction follows the item's authored rotation and not
the hull's facing (ArcFollowsMountRotation), FiringArc is a full width so
the boundary sits at half of it (ArcBoundaryIsHalfWidth), the arc test is
planar per R7 (ArcIsPlanar), and a per-hardpoint override wins over the
default while an authored 0 still means "use the default" rather than a
zero-width arc (HardpointOverrideBeatsDefault).
CombatStateStepsHeadless is the cut's real deliverable: a Minion with a
target in range steps through two Zone.Update ticks without Combat.cs
throwing (the KeyNotFoundException this cut exists to unbreak), and the
fight actually decides to fire, not just avoids a crash.
Builds its own hull/catalog rather than reusing IffAndCombatTests'
private BuildWorld/NewShip (fixed to one hardpoint) -- and along the way,
surfaces three catalog-authoring gotchas any headless-combat fixture
needs: Mass defaults to 0 (NaNs UpdateTemperature via ThermalMass),
default temperature bounds [0,100] don't cover wherever the heat model
settles, and Entity._orderedEquipment is only ever populated by TryEquip
so an entity that never equips anything throws on its first Update.
docs/fire-control-cut.md, Cut 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:38:06 +02:00
|
|
|
/* 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))
|
|
|
|
|
{
|
2026-10-10 07:18:00 +02:00
|
|
|
buildCache.Upsert(new VerseGrammar { Revision = 1 });
|
Pin fire-control arc rules and the headless engagement
One check per rule Cut 1 lands, each with a stated mutation that would
defeat it: mount direction follows the item's authored rotation and not
the hull's facing (ArcFollowsMountRotation), FiringArc is a full width so
the boundary sits at half of it (ArcBoundaryIsHalfWidth), the arc test is
planar per R7 (ArcIsPlanar), and a per-hardpoint override wins over the
default while an authored 0 still means "use the default" rather than a
zero-width arc (HardpointOverrideBeatsDefault).
CombatStateStepsHeadless is the cut's real deliverable: a Minion with a
target in range steps through two Zone.Update ticks without Combat.cs
throwing (the KeyNotFoundException this cut exists to unbreak), and the
fight actually decides to fire, not just avoids a crash.
Builds its own hull/catalog rather than reusing IffAndCombatTests'
private BuildWorld/NewShip (fixed to one hardpoint) -- and along the way,
surfaces three catalog-authoring gotchas any headless-combat fixture
needs: Mass defaults to 0 (NaNs UpdateTemperature via ThermalMass),
default temperature bounds [0,100] don't cover wherever the heat model
settles, and Entity._orderedEquipment is only ever populated by TryEquip
so an entity that never equips anything throws on its first Update.
docs/fire-control-cut.md, Cut 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:38:06 +02:00
|
|
|
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)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 12:33:20 +02:00
|
|
|
// A shooter with one in-range, in-arc gun, a detected target at `targetPosition`, and a Minion driving the shooter.
|
2026-10-07 16:23:56 +02:00
|
|
|
private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition, float projectileSpeed = 0f)
|
Pin fire-control arc rules and the headless engagement
One check per rule Cut 1 lands, each with a stated mutation that would
defeat it: mount direction follows the item's authored rotation and not
the hull's facing (ArcFollowsMountRotation), FiringArc is a full width so
the boundary sits at half of it (ArcBoundaryIsHalfWidth), the arc test is
planar per R7 (ArcIsPlanar), and a per-hardpoint override wins over the
default while an authored 0 still means "use the default" rather than a
zero-width arc (HardpointOverrideBeatsDefault).
CombatStateStepsHeadless is the cut's real deliverable: a Minion with a
target in range steps through two Zone.Update ticks without Combat.cs
throwing (the KeyNotFoundException this cut exists to unbreak), and the
fight actually decides to fire, not just avoids a crash.
Builds its own hull/catalog rather than reusing IffAndCombatTests'
private BuildWorld/NewShip (fixed to one hardpoint) -- and along the way,
surfaces three catalog-authoring gotchas any headless-combat fixture
needs: Mass defaults to 0 (NaNs UpdateTemperature via ThermalMass),
default temperature bounds [0,100] don't cover wherever the heat model
settles, and Entity._orderedEquipment is only ever populated by TryEquip
so an entity that never equips anything throws on its first Update.
docs/fire-control-cut.md, Cut 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:38:06 +02:00
|
|
|
{
|
|
|
|
|
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 };
|
2026-10-07 16:23:56 +02:00
|
|
|
weaponData.Velocity = new PerformanceStat { Min = projectileSpeed, Max = projectileSpeed };
|
Pin fire-control arc rules and the headless engagement
One check per rule Cut 1 lands, each with a stated mutation that would
defeat it: mount direction follows the item's authored rotation and not
the hull's facing (ArcFollowsMountRotation), FiringArc is a full width so
the boundary sits at half of it (ArcBoundaryIsHalfWidth), the arc test is
planar per R7 (ArcIsPlanar), and a per-hardpoint override wins over the
default while an authored 0 still means "use the default" rather than a
zero-width arc (HardpointOverrideBeatsDefault).
CombatStateStepsHeadless is the cut's real deliverable: a Minion with a
target in range steps through two Zone.Update ticks without Combat.cs
throwing (the KeyNotFoundException this cut exists to unbreak), and the
fight actually decides to fire, not just avoids a crash.
Builds its own hull/catalog rather than reusing IffAndCombatTests'
private BuildWorld/NewShip (fixed to one hardpoint) -- and along the way,
surfaces three catalog-authoring gotchas any headless-combat fixture
needs: Mass defaults to 0 (NaNs UpdateTemperature via ThermalMass),
default temperature bounds [0,100] don't cover wherever the heat model
settles, and Entity._orderedEquipment is only ever populated by TryEquip
so an entity that never equips anything throws on its first Update.
docs/fire-control-cut.md, Cut 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:38:06 +02:00
|
|
|
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;
|
2026-10-07 12:33:20 +02:00
|
|
|
target.Position = targetPosition;
|
Pin fire-control arc rules and the headless engagement
One check per rule Cut 1 lands, each with a stated mutation that would
defeat it: mount direction follows the item's authored rotation and not
the hull's facing (ArcFollowsMountRotation), FiringArc is a full width so
the boundary sits at half of it (ArcBoundaryIsHalfWidth), the arc test is
planar per R7 (ArcIsPlanar), and a per-hardpoint override wins over the
default while an authored 0 still means "use the default" rather than a
zero-width arc (HardpointOverrideBeatsDefault).
CombatStateStepsHeadless is the cut's real deliverable: a Minion with a
target in range steps through two Zone.Update ticks without Combat.cs
throwing (the KeyNotFoundException this cut exists to unbreak), and the
fight actually decides to fire, not just avoids a crash.
Builds its own hull/catalog rather than reusing IffAndCombatTests'
private BuildWorld/NewShip (fixed to one hardpoint) -- and along the way,
surfaces three catalog-authoring gotchas any headless-combat fixture
needs: Mass defaults to 0 (NaNs UpdateTemperature via ThermalMass),
default temperature bounds [0,100] don't cover wherever the heat model
settles, and Entity._orderedEquipment is only ever populated by TryEquip
so an entity that never equips anything throws on its first Update.
docs/fire-control-cut.md, Cut 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:38:06 +02:00
|
|
|
|
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;
|
Pin fire-control arc rules and the headless engagement
One check per rule Cut 1 lands, each with a stated mutation that would
defeat it: mount direction follows the item's authored rotation and not
the hull's facing (ArcFollowsMountRotation), FiringArc is a full width so
the boundary sits at half of it (ArcBoundaryIsHalfWidth), the arc test is
planar per R7 (ArcIsPlanar), and a per-hardpoint override wins over the
default while an authored 0 still means "use the default" rather than a
zero-width arc (HardpointOverrideBeatsDefault).
CombatStateStepsHeadless is the cut's real deliverable: a Minion with a
target in range steps through two Zone.Update ticks without Combat.cs
throwing (the KeyNotFoundException this cut exists to unbreak), and the
fight actually decides to fire, not just avoids a crash.
Builds its own hull/catalog rather than reusing IffAndCombatTests'
private BuildWorld/NewShip (fixed to one hardpoint) -- and along the way,
surfaces three catalog-authoring gotchas any headless-combat fixture
needs: Mass defaults to 0 (NaNs UpdateTemperature via ThermalMass),
default temperature bounds [0,100] don't cover wherever the heat model
settles, and Entity._orderedEquipment is only ever populated by TryEquip
so an entity that never equips anything throws on its first Update.
docs/fire-control-cut.md, Cut 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:38:06 +02:00
|
|
|
zone.Agents.Add(new Minion(shooter));
|
2026-10-07 12:33:20 +02:00
|
|
|
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));
|
Pin fire-control arc rules and the headless engagement
One check per rule Cut 1 lands, each with a stated mutation that would
defeat it: mount direction follows the item's authored rotation and not
the hull's facing (ArcFollowsMountRotation), FiringArc is a full width so
the boundary sits at half of it (ArcBoundaryIsHalfWidth), the arc test is
planar per R7 (ArcIsPlanar), and a per-hardpoint override wins over the
default while an authored 0 still means "use the default" rather than a
zero-width arc (HardpointOverrideBeatsDefault).
CombatStateStepsHeadless is the cut's real deliverable: a Minion with a
target in range steps through two Zone.Update ticks without Combat.cs
throwing (the KeyNotFoundException this cut exists to unbreak), and the
fight actually decides to fire, not just avoids a crash.
Builds its own hull/catalog rather than reusing IffAndCombatTests'
private BuildWorld/NewShip (fixed to one hardpoint) -- and along the way,
surfaces three catalog-authoring gotchas any headless-combat fixture
needs: Mass defaults to 0 (NaNs UpdateTemperature via ThermalMass),
default temperature bounds [0,100] don't cover wherever the heat model
settles, and Entity._orderedEquipment is only ever populated by TryEquip
so an entity that never equips anything throws on its first Update.
docs/fire-control-cut.md, Cut 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:38:06 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-10-07 12:33:20 +02:00
|
|
|
|
2026-10-13 02:01:20 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 12:33:20 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-10-07 16:23:56 +02:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
Pin fire-control arc rules and the headless engagement
One check per rule Cut 1 lands, each with a stated mutation that would
defeat it: mount direction follows the item's authored rotation and not
the hull's facing (ArcFollowsMountRotation), FiringArc is a full width so
the boundary sits at half of it (ArcBoundaryIsHalfWidth), the arc test is
planar per R7 (ArcIsPlanar), and a per-hardpoint override wins over the
default while an authored 0 still means "use the default" rather than a
zero-width arc (HardpointOverrideBeatsDefault).
CombatStateStepsHeadless is the cut's real deliverable: a Minion with a
target in range steps through two Zone.Update ticks without Combat.cs
throwing (the KeyNotFoundException this cut exists to unbreak), and the
fight actually decides to fire, not just avoids a crash.
Builds its own hull/catalog rather than reusing IffAndCombatTests'
private BuildWorld/NewShip (fixed to one hardpoint) -- and along the way,
surfaces three catalog-authoring gotchas any headless-combat fixture
needs: Mass defaults to 0 (NaNs UpdateTemperature via ThermalMass),
default temperature bounds [0,100] don't cover wherever the heat model
settles, and Entity._orderedEquipment is only ever populated by TryEquip
so an entity that never equips anything throws on its first Update.
docs/fire-control-cut.md, Cut 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:38:06 +02:00
|
|
|
}
|