Aetheria/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs

451 lines
24 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;
using Random = CultMath.Random;
// Cut 2 (docs/fire-control-cut.md): pins the targeting subsystem, the reveal rule between the two info
// thresholds, the selection slot/predicate, and the required-equipment ruling (Q4). Builds its own
// hull/catalog fixtures, the same style FireControlTests uses for Cut 1, rather than reusing that file's
// or LoadoutTests' private helpers.
public sealed class TargetingSystemTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-targeting-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
public TargetingSystemTests() => 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(),
TargetGearInfoThreshold = .9f,
UnaidedAccuracy = .1f,
FiringArc = 120
};
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
// Min=0 (not Min=Max): a PowerSupply term can only be observed to degrade something with room to move
// between floor and authored ceiling (BrownoutTests' own convention).
private static PerformanceStat Curved(float max, float exponent) =>
new PerformanceStat { Min = 0, Max = max, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = exponent } } };
// A 6x4 hull with one Sensors hardpoint at (0,0) (hardpoint-mounted weapon) and interior room for two
// more Tool items, one 2-cell and one 1-cell: a hardpoint weapon, a wide interior item and a narrow one.
// `targetingData`, when given, authors a fourth non-hull item (a Tool gear carrying that behaviour) in
// the same catalog-build pass, so a test that needs a targeting system never has to reopen the catalog
// file after the live, already-open ItemManager exists (CultCache is single-writer).
private (ItemManager items, Ship target, EquippedItem weapon, EquippedItem wideItem, EquippedItem narrowItem, EquippedItem targetingItem)
BuildTarget(GameplaySettings settings, TargetingSystemData targetingData = null)
{
var hullShape = new Shape(6, 4);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
var hullData = new HullData
{
Name = "TestHull", HullType = HullType.Ship, Shape = hullShape, Durability = 1, Mass = 1000,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
};
using (var buildCache = AetheriaStores.Open(Catalog, catalogWritable: true))
{
buildCache.Upsert(new VerseGrammar { Revision = 1 });
buildCache.Upsert(hullData);
buildCache.Upsert(new GearData
{
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new InstantWeaponData() }
});
var wideShape = new Shape(2, 1);
wideShape[new int2(0, 0)] = true;
wideShape[new int2(1, 0)] = true;
buildCache.Upsert(new GearData
{
Name = "Wide", Hardpoint = HardpointType.Tool, Shape = wideShape, Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000
});
buildCache.Upsert(new GearData
{
Name = "Narrow", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000
});
if (targetingData != null)
buildCache.Upsert(new GearData
{
Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { targetingData }
});
buildCache.FlushAsync().Wait();
}
_openCache = AetheriaStores.Open(Catalog, catalogWritable: true);
var ledger = new ProvenanceLedger();
ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; // hull
ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; // gun
ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; // wide
ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; // narrow
ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; // targeting (unused unless authored above)
var items = new ItemManager(_openCache, ledger, settings, _ => { });
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 target = new Ship(items, zone, hull, new EntitySettings());
var narrowRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Narrow"));
var narrow = new EquippableItem { Data = narrowRef, Durability = 1, Lot = 4 };
Assert.True(target.TryFindSpace(narrow, out var narrowPos));
Assert.True(target.TryEquip(narrow, narrowPos));
var narrowItem = target.Equipment.Single(e => e.EquippableItem == narrow);
var wideRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Wide"));
var wide = new EquippableItem { Data = wideRef, Durability = 1, Lot = 3 };
Assert.True(target.TryFindSpace(wide, out var widePos));
Assert.True(target.TryEquip(wide, widePos));
var wideItem = target.Equipment.Single(e => e.EquippableItem == wide);
var gunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Gun"));
var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 };
Assert.True(target.TryEquip(gun, new int2(0, 0)));
var weapon = target.Equipment.Single(e => e.EquippableItem == gun);
EquippedItem targetingItem = null;
if (targetingData != null)
{
var targetingRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Targeting"));
var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 5 };
Assert.True(target.TryFindSpace(targeting, out var targetingPos));
Assert.True(target.TryEquip(targeting, targetingPos));
targetingItem = target.Equipment.Single(e => e.EquippableItem == targeting);
}
zone.Entities.Add(target);
target.Activate();
return (items, target, weapon, wideItem, narrowItem, targetingItem);
}
// A bare-hull observer sharing the target's zone, with no equipment of its own -- only its
// EntityInfoGathered dictionary and Target/TrySelectTargetItem matter to these tests.
private Ship BuildObserver(ItemManager items, Zone zone)
{
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("TestHull"));
var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 };
var observer = new Ship(items, zone, hull, new EntitySettings());
zone.Entities.Add(observer);
observer.Activate();
return observer;
}
// The selection predicate: TrySelectTargetItem accepts only an item of the current target that the observer's
// book holds and that is still whole; a Target change nulls the aim point, and destruction drops it on read
// with no loop clearing it. Mutation: IsRevealed returns true unconditionally, or ignores the book; the
// Target-change subscription is removed; or ResolvedTargetItem returns TargetItem.Value without re-checking.
[Fact]
public void SelectionNeedsReveal()
{
var (items, target, weapon, _, _, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
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
observer.SetTarget(target);
Assert.False(observer.TrySelectTargetItem(weapon)); // nothing seen yet
Assert.Null(observer.TargetItem.Value);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
Assert.True(observer.TrySelectTargetItem(weapon));
Assert.Same(weapon, observer.TargetItem.Value);
Assert.Same(weapon, observer.ResolvedTargetItem);
// A Target change nulls the aim point.
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
observer.SetTarget(TargetRef.None);
Assert.Null(observer.TargetItem.Value);
// Destruction: re-select, then destroy the item without touching TargetItem.Value at all -- the raw field
// still holds the item (no loop cleared it), but the read accessor drops it.
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
observer.SetTarget(target);
Assert.True(observer.TrySelectTargetItem(weapon));
weapon.EquippableItem.Durability = 0f;
Assert.Same(weapon, observer.TargetItem.Value);
Assert.Null(observer.ResolvedTargetItem);
}
// Rulings visibility-per-capability-reveal and reveal-ends-on-perception: a target with three items, one seen
// acting. Only that item is revealed; the others are refused by the same writer. Mutation: IsRevealed true for
// any fitted item (book ignored).
[Fact]
public void GearIsRevealedByWhatItDid()
{
var (items, target, weapon, wideItem, narrowItem, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
observer.SetTarget(target);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
Assert.True(FireControl.IsRevealed(observer, weapon));
Assert.False(FireControl.IsRevealed(observer, wideItem));
Assert.False(FireControl.IsRevealed(observer, narrowItem));
Assert.True(observer.TrySelectTargetItem(weapon));
Assert.False(observer.TrySelectTargetItem(wideItem));
Assert.False(observer.TrySelectTargetItem(narrowItem));
}
// Ruling quiet-gear-identifiable-hull-layout-known: watching reveals no gear. An observer whose track is full
// (it identifies the ship) on a ship that has done nothing can aim at none of its items, though ContactOf
// reads Identified. Mutation: a track-tier or dwell fallback in IsRevealed.
[Fact]
public void WatchingRevealsNoGear()
{
var (items, target, weapon, wideItem, narrowItem, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
observer.EntityInfoGathered[target] = 1f;
Assert.True(observer.ContactOf(target).Identified);
foreach (var item in new[] { weapon, wideItem, narrowItem })
Assert.False(FireControl.IsRevealed(observer, item));
Assert.Empty(observer.RevealsOf(target));
}
// A revealed item made IsDestroyed drops out of the aim read: IsRevealed false, ResolvedTargetItem null,
// TrySelectTargetItem refuses it. Mutation: drop the IsDestroyed filter from IsRevealed.
[Fact]
public void ADestroyedItemDropsOut()
{
var (items, target, weapon, wideItem, _, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
observer.SetTarget(target);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, wideItem, SignatureChannel.LaunchFlash);
Assert.True(observer.TrySelectTargetItem(weapon));
weapon.EquippableItem.Durability = 0f;
Assert.False(FireControl.IsRevealed(observer, weapon));
Assert.Null(observer.ResolvedTargetItem);
Assert.False(observer.TrySelectTargetItem(weapon));
Assert.True(FireControl.IsRevealed(observer, wideItem)); // the survivor stays aimable
}
// A revealed item that is no longer fitted to its ship cannot be aimed at, though the observer's book still
// holds it. Mutation: drop the Equipment.Contains check from IsRevealed.
[Fact]
public void AnUnfittedItemCannotBeAimedAt()
{
var (items, target, weapon, _, _, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
observer.SetTarget(target);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
Assert.True(FireControl.IsRevealed(observer, weapon));
target.Deactivate(); // an active ship refuses to unequip
Assert.NotNull(target.TryUnequip(weapon));
Assert.Contains(observer.RevealsOf(target), r => r.Item == weapon);
Assert.False(FireControl.IsRevealed(observer, weapon));
}
// The aim read is live; the book is not. Below the identify threshold the observer perceives nothing of a
// destruction, so a revealed gun destroyed by a third party stays in RevealsOf (knowledge) yet cannot be aimed
// at. Mutations: IsRevealed without the live filter fails this; a live filter in RevealsOf fails it too.
[Fact]
public void TheAimReadIsLiveTheBookIsNot()
{
var (items, target, weapon, _, _, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
observer.EntityInfoGathered[target] = .5f; // below TargetGearInfoThreshold (.9)
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
Assert.True(FireControl.IsRevealed(observer, weapon));
weapon.EquippableItem.Durability = 0f;
Assert.Contains(observer.RevealsOf(target), r => r.Item == weapon);
Assert.False(FireControl.IsRevealed(observer, weapon));
}
// Q4: the subsystem is what supplies accuracy. No targeting system, a destroyed one, and a disabled one
// all resolve the authored unaided figures. Mutation: fall back to the (destroyed/offline) system's own
// stats instead of the unaided ones.
[Fact]
public void UnaidedFiresWorse()
{
var settings = TestSettings();
var targetingData = new TargetingSystemData
{
Accuracy = Constant(.8f), Resolution = Constant(.6f), Precision = Constant(.7f), Tracking = Constant(.5f)
};
var (items, target, _, _, _, targetingUnit) = BuildTarget(settings, targetingData);
var equippedTargeting = targetingUnit;
var system = equippedTargeting.Behaviors.OfType<TargetingSystem>().Single();
system.Execute(0f);
// No targeting system at all: a second, bare-hull ship in the same catalog/zone (one CultCache
// handle stays open per test -- BuildTarget is not called twice).
var bareShip = BuildObserver(items, target.Zone);
Assert.Equal(settings.UnaidedAccuracy, FireControl.Accuracy(bareShip), 4);
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
// Cut 6d (docs/fire-control-cut.md): Precision now falls back to UnaidedPrecision, the same shape as
// Accuracy/Tracking above -- it used to fall back to a bare 0, which was fine when Precision only fed
// the old coin-flip's aimed-item branch and meant nothing to a shot with no aim point. Now it derives
// every shot's sigma, aimed or not, so the unaided floor has to be an authored number, not a bare zero.
Assert.Equal(settings.UnaidedPrecision, FireControl.Precision(bareShip), 4);
Assert.Equal(1f, FireControl.Resolution(bareShip), 4);
// Bring it online to prove the fixture can actually supply its own stats...
equippedTargeting.UpdatePerformance();
Assert.True(equippedTargeting.Active.Value);
Assert.Equal(.8f, FireControl.Accuracy(target), 4);
Assert.Equal(.7f, FireControl.Precision(target), 4);
// ...then destroy it: durability gone, still falls back.
equippedTargeting.EquippableItem.Durability = 0f;
equippedTargeting.UpdatePerformance();
Assert.False(equippedTargeting.Active.Value);
Assert.Equal(settings.UnaidedAccuracy, FireControl.Accuracy(target), 4);
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
Assert.Equal(settings.UnaidedPrecision, FireControl.Precision(target), 4);
Assert.Equal(1f, FireControl.Resolution(target), 4);
// Repair it, then disable it directly instead: same fallback.
equippedTargeting.EquippableItem.Durability = 1f;
equippedTargeting.UpdatePerformance();
Assert.True(equippedTargeting.Active.Value);
equippedTargeting.Enabled.Value = false;
Assert.False(equippedTargeting.Active.Value);
Assert.Equal(settings.UnaidedAccuracy, FireControl.Accuracy(target), 4);
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
Assert.Equal(settings.UnaidedPrecision, FireControl.Precision(target), 4);
}
// The brownout ruling (docs/stats-and-power-cut.md) applies to Accuracy same as any other stat: a
// half-grant halves it (PowerSupply exponent 1), not a switch-off. Reactor + a real PowerBus tick, the
// same fixture shape BrownoutTests uses, because Item.PowerSupply's setter is internal to a different
// assembly here and can only be driven through a live grant.
[Fact]
public void StarvedTargetingRollsWorse()
{
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
var hullShape = new Shape(5, 5);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 });
cache.Upsert(new GearData
{
Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
Behaviors = { new ReactorData { Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } }
});
cache.Upsert(new GearData
{
Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
Behaviors =
{
new EnergyDrawData { EnergyDraw = Constant(100) },
new TargetingSystemData { Accuracy = Curved(1, 1), Resolution = Constant(1), Precision = Constant(1), Tracking = Constant(1) }
}
});
cache.FlushAsync().Wait();
EquippableItem Mint(ItemData design) => new EquippableItem
{
Data = cache.RefOf<ItemData>(design),
Durability = design is EquippableItemData equippable ? equippable.Durability : 0,
};
Ship BuildShip(float reactorCharge, out ItemManager items)
{
items = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
var hullData = cache.GetByName<HullData>("Skiff");
var hull = Mint(hullData);
hull.Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(hullData), Origin = new Attributed(), Quality = .5f, Roles = new System.Collections.Generic.List<RoleFill>() });
var ship = new Ship(items, zone, hull, new EntitySettings());
var reactorData = cache.GetByName<GearData>("Reactor");
((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge);
var reactor = Mint(reactorData);
reactor.Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(reactorData), Origin = new Attributed(), Quality = .5f, Roles = new System.Collections.Generic.List<RoleFill>() });
Assert.True(ship.TryEquip(reactor));
var targetingData = cache.GetByName<GearData>("Targeting");
var targeting = Mint(targetingData);
targeting.Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(targetingData), Origin = new Attributed(), Quality = .5f, Roles = new System.Collections.Generic.List<RoleFill>() });
Assert.True(ship.TryEquip(targeting));
zone.Entities.Add(ship);
ship.Aim = float3(0, 0, 1);
ship.Activate();
return ship;
}
float ResolveAccuracy(float reactorCharge)
{
var ship = BuildShip(reactorCharge, out _);
ship.Update(1f); // one tick: PowerBus.Step grants, then Execute resolves Accuracy against it
return FireControl.Accuracy(ship);
}
var full = ResolveAccuracy(1000); // demand 100, generation far exceeds it -> ratio 1
var half = ResolveAccuracy(50); // demand 100, generation 50 -> ratio .5
Assert.Equal(1f, full, 3);
Assert.Equal(.5f, half, 3);
}
// Q4: required equipment for anything that can fire. Across several seeds, every generated loadout with
// a weapon also carries a targeting system. Mutation: drop `required: true` on the targeting-system
// RandomProduct call in LoadoutGenerator.FillInterior.
[Fact]
public void EveryArmedLoadoutGetsATargetingSystem()
{
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
var maker = cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" });
var hullShape = new Shape(5, 5);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
var hull = cache.Upsert(new HullData
{
Name = "Armed", HullType = HullType.Ship, Shape = hullShape, Price = 100,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
});
var gun = cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, Behaviors = { new InstantWeaponData() } });
var cargo = cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = new Shape(), Price = 5 });
var capacitor = cache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } });
var targeting = cache.Upsert(new GearData { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new TargetingSystemData() } });
foreach (var (name, design) in new[]
{
("Armed by Maker", hull.Key), ("Gun by Maker", gun.Key), ("Crate by Maker", cargo.Key),
("Cap by Maker", capacitor.Key), ("Targeting by Maker", targeting.Key)
})
cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef<CraftedItemData>(design), Manufacturer = maker });
cache.FlushAsync().Wait();
for (var seed = 1; seed <= 10; seed++)
{
var random = new Random((uint) seed);
var items = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { });
var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f);
var pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Armed");
Assert.NotNull(pack);
Assert.Contains(pack.Equipment, e => (cache.Get(e.item.Data) as GearData)?.Behaviors.Any(b => b is TargetingSystemData) ?? false);
}
}
}