Visibility 5: an item can be aimed at when the observer's book holds it and it is still fitted and whole

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>
This commit is contained in:
Meta Vens 2026-10-13 02:01:20 +02:00
commit eebf6f375a
27 changed files with 153 additions and 128 deletions

View file

@ -23,6 +23,10 @@
<Compile Include="$(Assets)Plugins\UniRx\Scripts\UnityEngineBridge\ReactiveProperty.cs;$(Assets)Plugins\UniRx\Scripts\UnityEngineBridge\ReactiveCollection.cs;$(Assets)Plugins\UniRx\Scripts\UnityEngineBridge\ReactiveDictionary.cs" />
<Compile Include="$(Assets)Plugins\Ink\**\*.cs" />
</ItemGroup>
<ItemGroup>
<!-- Tests record reveals through Entity.RecordReveal, the book's one writer -->
<InternalsVisibleTo Include="Aetheria.Shared.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="$(CultMathRoot)\packages\cultmath\src\CultMath\CultMath.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />

BIN
Assets/Resources/Settings.asset (Stored with Git LFS)

Binary file not shown.

View file

@ -952,6 +952,10 @@ public abstract class Entity
public IEnumerable<Reveal> RevealsOf(Entity subject) =>
_reveals.TryGetValue(subject, out var book) ? book.Values : Enumerable.Empty<Reveal>();
// Whether this observer's book for `subject` holds `item`: the book's membership, no live gear read.
internal bool HasReveal(Entity subject, EquippedItem item) =>
_reveals.TryGetValue(subject, out var book) && book.ContainsKey(item);
// The one derivation of how much this observer holds of `subject`: see Contact.
public Contact ContactOf(Entity subject)
{

View file

@ -152,35 +152,13 @@ public static class FireControl
};
}
// Cut 2, R5: whether `observer` has resolved `item` (which must belong to some entity in the zone) well
// enough to aim at it. Derived fresh from EntityInfoGathered on every call -- nothing caches a reveal
// result, so decay drops it the moment info falls back below tier, with no clearing loop anywhere.
//
// Ranking: the target's own non-hull equipment, hardpoint-mounted gear first (the big obvious things),
// then by size descending, ties broken by equipment order (OrderByDescending is a stable sort, so this
// needs no explicit tie-break key). Item i of N reveals once info crosses
// lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1)) -- the first item to reveal
// does so at the armor tier, the last at the gear tier.
public static bool IsRevealed(Entity observer, EquippedItem item)
{
var target = item.Entity;
// Cut 5, 5.5 (Soul finding 10): a destroyed item drops out of the ranking entirely rather than merely
// failing its own tier -- ranked.IndexOf(item) then returns -1 for it (the same "no longer on this
// target" path below), and the survivors' tiers close up over the gap instead of leaving one.
var ranked = target.Equipment
.Where(e => e.Data.HardpointType != HardpointType.Hull && !e.EquippableItem.IsDestroyed)
.OrderByDescending(e => e.Data.HardpointType != HardpointType.Tool)
.ThenByDescending(e => e.Data.Shape.Coordinates.Length)
.ToList();
var index = ranked.IndexOf(item);
if (index < 0) return false; // the hull itself, or an item that is no longer on this target
var settings = observer.ItemManager.GameplaySettings;
var info = observer.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
var tier = lerp(settings.TargetArmorInfoThreshold, settings.TargetGearInfoThreshold,
(float) index / max(1, ranked.Count - 1));
return info >= tier;
}
// The aiming read, and the only reader of live gear beside the book (rulings visibility-per-capability-reveal,
// quiet-gear-identifiable-hull-layout-known, reveal-ends-on-perception): `observer` can aim at `item` when its own
// book holds a reveal of it (Entity.HasReveal: it saw the item act) and the item is still fitted to its ship and
// not destroyed. The book says what was seen; the live item says whether it can still be aimed at. Derived on
// every read, never cached; watching a ship reveals no gear.
public static bool IsRevealed(Entity observer, EquippedItem item) =>
!item.EquippableItem.IsDestroyed && item.Entity.Equipment.Contains(item) && observer.HasReveal(item.Entity, item);
// Cut 2, Q4: the stats an entity actually fires with. A working targeting system (equipped, online --
// not destroyed, not shut down) supplies its own resolved stats; anything else falls back to the

View file

@ -222,7 +222,6 @@ public class GameplaySettings
public float BearingMemory = 6;
public float TargetInfoDecay;
public float TargetDetectionInfoThreshold;
public float TargetArmorInfoThreshold;
// Also the Identified threshold: the track level at which a ship's hull class, and with it its layout, is known.
public float TargetGearInfoThreshold;
public float ConvergenceMinimumDistance;

View file

@ -105,7 +105,6 @@ public sealed partial class ContactRevealTests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
TargetInfoDecay = .5f,
VisibilityDecay = 100f,
@ -773,8 +772,8 @@ public sealed partial class ContactRevealTests : IDisposable
// Ruling contact-reads-book-aim-reads-live: what an observer knows of a ship comes from its own book. A gun
// destroyed by a third party out of its sight changes none of its levels and stays in its reveal list, while
// the aiming read, which asks about the item now, refuses the dead gun and still serves the live one. No Sense
// runs after the destruction here (the aiming read needs info 1, at which a Sense would perceive it); the
// unseen case with a Sense is AnIdentifiedObserverSeesGearDestroyed's below-threshold case.
// runs after the destruction here; the unseen case with a Sense is AnIdentifiedObserverSeesGearDestroyed's
// below-threshold case.
[Fact]
public void GearDestroyedOutOfSightLeavesWhatTheObserverKnowsAlone()
{
@ -785,7 +784,6 @@ public sealed partial class ContactRevealTests : IDisposable
Fire(e, guns[1]);
var before = o.ContactOf(e);
Assert.True(before.Detected && before.Classified && !before.Tracked, "fixture: a bearing and a class, no track");
o.EntityInfoGathered[e] = 1f; // the aiming read needs info to resolve the target's gear
Assert.True(FireControl.IsRevealed(o, guns[0]) && FireControl.IsRevealed(o, guns[1]), "fixture: both guns are aimable");
guns[0].EquippableItem.Durability = 0f; // destroyed by someone else; o saw nothing

View file

@ -479,7 +479,8 @@ public sealed class FireAuthorityTests : IDisposable
});
var aimedItem = e.Target.Equipment.Single(x => x.EquippableItem == aimedGear);
e.Shooter.EntityInfoGathered[e.Target] = 1f; // fully revealed
e.Shooter.EntityInfoGathered[e.Target] = 1f; // fully tracked
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash);
Assert.True(e.Shooter.TrySelectTargetItem(aimedItem));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);

View file

@ -40,7 +40,6 @@ public sealed class FireControlCut11Tests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,

View file

@ -42,7 +42,6 @@ public sealed class FireControlCut123Tests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,

View file

@ -44,7 +44,6 @@ public sealed partial class FireControlCut124Tests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = commitHorizon,

View file

@ -41,7 +41,6 @@ public sealed class FireControlCut12Tests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,
@ -522,6 +521,7 @@ public sealed class FireControlCut12Tests : IDisposable
var aim = new int2(2, 2);
var e = Build(TestSettings(), SolidShape(6, 6), precision: 1000f, markerCells: new[] { aim });
var aimed = e.Markers[0];
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash);
Assert.True(e.Shooter.TrySelectTargetItem(aimed));
// The aimed item is still equipped (Entity.TryUnequip refuses on an active entity, and reveal/aim
@ -548,6 +548,7 @@ public sealed class FireControlCut12Tests : IDisposable
public void AimingAtAnItemBiggerThanOneCellCentresOnItsCentroid()
{
var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: float2(1, 0), equipBigMarker: true);
e.Shooter.RecordReveal(e.Target, e.BigMarker, SignatureChannel.LaunchFlash);
Assert.True(e.Shooter.TrySelectTargetItem(e.BigMarker));
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
@ -585,6 +586,7 @@ public sealed class FireControlCut12Tests : IDisposable
// the bounding-box centre along the diagonal, not along either grid axis -- fully onto the lateral
// axis, instead of losing most of it to the axis a purely axis-aligned facing would leave unmeasured.
var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: normalize(float2(1, 1)), equipLMarker: true);
e.Shooter.RecordReveal(e.Target, e.LMarker, SignatureChannel.LaunchFlash);
Assert.True(e.Shooter.TrySelectTargetItem(e.LMarker));
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
@ -975,6 +977,7 @@ public sealed class FireControlCut12Tests : IDisposable
var aim = new int2(1, 1);
var e = Build(settings, hullShape, precision, spread: 0, targetFacing: normalize(float2(1f, 1f)),
markerCells: new[] { aim });
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash);
Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0]));
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
@ -1157,6 +1160,7 @@ public sealed class FireControlCut12Tests : IDisposable
var aim = new int2(1, 1);
var e = Build(TestSettings(), shape, precision: .3f, targetFacing: normalize(float2(.8f, .6f)), markerCells: new[] { aim });
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash);
Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0]));
var outcomes = FireMany(e, 1500);
@ -1190,6 +1194,7 @@ public sealed class FireControlCut12Tests : IDisposable
{
var e = Build(settings, hullShape, precision: .6f, targetFacing: facing, markerCells: new[] { aimCell });
var aimed = e.Markers[0];
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash);
Assert.True(e.Shooter.TrySelectTargetItem(aimed));
Assert.Same(aimed, e.Shooter.ResolvedTargetItem);

View file

@ -40,7 +40,6 @@ public sealed class FireControlCut5Tests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,
@ -582,13 +581,13 @@ public sealed class FireControlCut5Tests : IDisposable
return (items, target, bigItem, mediumItem);
}
// A destroyed item drops out of the ranking entirely: it is never revealed regardless of info, and the
// survivor's own tier closes up over the gap rather than leaving one. Mutation: drop the Durability filter
// from IsRevealed's ranking (a destroyed item keeps its old tier and TargetItem keeps aiming at a corpse).
// A destroyed item stops being an aim point: IsRevealed is false for it though the observer's book still
// holds it, and the survivor the observer saw act stays aimable. Mutation: drop the IsDestroyed filter from
// IsRevealed (TargetItem keeps aiming at a corpse).
[Fact]
public void DestroyedItemStopsBeingAimPoint()
{
var settings = TestSettings(); // Armor .2, Gear .8
var settings = TestSettings();
var (items, target, big, medium) = BuildRevealTarget(settings);
var observerHullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
var observer = new Ship(items, target.Zone, new EquippableItem { Data = observerHullRef, Durability = 1000, Lot = 1 }, new EntitySettings());
@ -596,20 +595,17 @@ public sealed class FireControlCut5Tests : IDisposable
observer.Activate();
observer.SetTarget(target);
// With both items alive: Big (index 0) reveals at .2, Medium (index 1) at .8.
observer.EntityInfoGathered[target] = .3f;
observer.RecordReveal(target, big, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, medium, SignatureChannel.LaunchFlash);
Assert.True(FireControl.IsRevealed(observer, big));
Assert.False(FireControl.IsRevealed(observer, medium));
Assert.True(FireControl.IsRevealed(observer, medium));
Assert.True(observer.TrySelectTargetItem(big));
// Destroy Big: it drops out of the ranking outright, so it can never be revealed again regardless of
// info, and it stops being a valid aim point on the very next read -- no clearing loop needed.
big.EquippableItem.Durability = 0f;
Assert.False(FireControl.IsRevealed(observer, big));
Assert.Null(observer.ResolvedTargetItem);
// Medium is now the only survivor, so it inherits index 0's own tier (.2), not its old index-1 tier
// (.8) -- the gap closes rather than leaving Medium stranded behind a tier meant for a corpse.
// Medium was seen too, so it stays aimable; the destroyed neighbour changes nothing about it.
Assert.True(FireControl.IsRevealed(observer, medium));
}

View file

@ -40,7 +40,6 @@ public sealed class FireControlCut6Tests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,

View file

@ -39,7 +39,6 @@ public sealed class FireControlCut6cTests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,

View file

@ -41,7 +41,6 @@ public sealed class FireControlCut6dTests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,
@ -172,6 +171,7 @@ public sealed class FireControlCut6dTests : IDisposable
if (marker != null)
{
var markerItem = target.Equipment.Single(x => x.EquippableItem == marker);
shooter.RecordReveal(target, markerItem, SignatureChannel.LaunchFlash);
Assert.True(shooter.TrySelectTargetItem(markerItem));
Assert.Same(markerItem, shooter.ResolvedTargetItem);
}

View file

@ -35,16 +35,14 @@ public sealed class FireControlCut7Tests : IDisposable
Directory.Delete(_root, true);
}
// Detection well below the armor tier, which is well below the gear tier, so an aimed interior item can
// decay out of its own reveal while the weapon itself (index 0, the armor tier) and general detection
// both stay satisfied -- the shot still fires, only the aim point should be lost.
// General detection stays satisfied when the aimed interior item is destroyed -- the shot still fires, only
// the aim point is lost.
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 = .1f,
TargetArmorInfoThreshold = .3f,
TargetGearInfoThreshold = .9f,
FiringArc = 170,
CommitHorizon = .5f,
@ -66,8 +64,8 @@ public sealed class FireControlCut7Tests : IDisposable
}
// A trimmed copy of FireAuthorityTests.Build: one Sensors-hardpoint weapon plus a Tool-slot targeting
// system on the shooter, a target ship with a matching weapon (index 0 in IsRevealed's ranking) and,
// through beforeActivate, a second Tool item to aim at (index 1 -- the gear tier).
// system on the shooter, a target ship with a matching weapon and,
// through beforeActivate, a second Tool item to aim at.
private Engagement Build(GameplaySettings settings, Action<ItemManager, Ship, Ship> beforeActivate)
{
var hullShape = new Shape(5, 5);
@ -152,13 +150,11 @@ public sealed class FireControlCut7Tests : IDisposable
// source.TargetItem.Value -- the aim point is re-checked against reveal at the instant the trigger is
// pulled, same as every other read of it. Mutation: read TargetItem.Value directly. Must die.
//
// The target's non-hull equipment ranks the hardpoint weapon first (armor tier, index 0 of 2) and the
// interior "Targeting" item last (gear tier, index 1 of 2, i.e. exactly TargetGearInfoThreshold). Info is
// dropped to a level between the two tiers: the weapon stays revealed (so the shot still fires, PBase >
// 0) but the aimed item's own tier is no longer met -- the reveal re-check, not a global blackout, is
// what must drop the aim point.
// The observer saw the target's interior item act and aims at it; the item is then destroyed. The shot is
// still worth taking -- this is no blackout -- but the aim point is re-checked at the instant the trigger is
// pulled and is lost.
[Fact]
public void AimedItemDropsOutWhenRevealDecaysBeforeFire()
public void AimedItemDropsOutWhenItIsDestroyedBeforeFire()
{
EquippableItem aimedGear = null;
var settings = TestSettings();
@ -171,17 +167,16 @@ public sealed class FireControlCut7Tests : IDisposable
});
var aimedItem = e.Target.Equipment.Single(x => x.EquippableItem == aimedGear);
// Fully reveal and select the interior item as the aim point.
// The shooter tracks the target fully and has seen the interior item act; it selects it as the aim point.
e.Shooter.EntityInfoGathered[e.Target] = 1f;
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash);
Assert.True(e.Shooter.TrySelectTargetItem(aimedItem));
Assert.Same(aimedItem, e.Shooter.ResolvedTargetItem);
// Decay to halfway between the armor and gear tiers: the weapon (armor tier) stays revealed, the
// aimed item (gear tier) does not. The raw field still holds the item -- nothing clears it -- but the
// read accessor must now return null.
e.Shooter.EntityInfoGathered[e.Target] = (settings.TargetArmorInfoThreshold + settings.TargetGearInfoThreshold) / 2f;
Assert.Same(aimedItem, e.Shooter.TargetItem.Value); // raw field: untouched by decay
Assert.Null(e.Shooter.ResolvedTargetItem); // read path: re-checked, and drops it
// Destroyed: the raw field still holds the item -- nothing clears it -- but the read accessor returns null.
aimedGear.Durability = 0f;
Assert.Same(aimedItem, e.Shooter.TargetItem.Value);
Assert.Null(e.Shooter.ResolvedTargetItem);
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId);

View file

@ -39,7 +39,6 @@ public sealed class FireControlCut8Tests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,

View file

@ -42,7 +42,6 @@ public sealed class FireControlCut9Tests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,

View file

@ -41,7 +41,6 @@ public sealed class FireControlPerWeaponTests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,

View file

@ -237,6 +237,25 @@ public sealed class FireControlTests : IDisposable
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]

View file

@ -46,7 +46,6 @@ public sealed class LockWarningTests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,

View file

@ -73,7 +73,7 @@ public sealed class MineTests : IDisposable
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f,
TargetDetectionInfoThreshold = .1f, TargetGearInfoThreshold = .8f,
FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f,
UnaidedAccuracy = .05f, UnaidedTracking = 10f, UnaidedPrecision = 1000f, AgentMinHitProbability = 0f
};

View file

@ -66,7 +66,6 @@ public sealed class PaintModeTests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,

View file

@ -48,7 +48,6 @@ public sealed class SignatureTests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetArmorInfoThreshold = .2f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,

View file

@ -36,7 +36,6 @@ public sealed class TargetingSystemTests : IDisposable
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetArmorInfoThreshold = .3f,
TargetGearInfoThreshold = .9f,
UnaidedAccuracy = .1f,
FiringArc = 120
@ -50,9 +49,7 @@ public sealed class TargetingSystemTests : IDisposable
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. Non-hull equipment ends up authored in this order --
// hardpoint weapon, wide interior item, narrow interior item -- but it is FireControl.IsRevealed's own
// ranking, not authoring order, that RevealOrder actually pins.
// 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).
@ -114,12 +111,6 @@ public sealed class TargetingSystemTests : IDisposable
var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 };
var target = new Ship(items, zone, hull, new EntitySettings());
// Cut 7 (docs/fire-control-cut.md, 7.2): equipped in size-ascending, hardpoint-last order -- the
// opposite of IsRevealed's own ranking (hardpoint-mounted first, then size descending) -- so that
// dropping the explicit ranking in FireControl.IsRevealed is actually observable. Equipping in
// rank order let a "drop the ordering, rank by equipment order alone" mutation pass: Entity.Equipment
// already happened to enumerate in the correct final order by sheer coincidence of authoring order,
// so RevealOrder's own assertions never distinguished the ranked read from the raw one.
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));
@ -165,10 +156,10 @@ public sealed class TargetingSystemTests : IDisposable
return observer;
}
// R5 / the selection predicate: TrySelectTargetItem accepts only a revealed item of the current target,
// a Target change nulls the aim point, and decay drops it on read with no loop clearing it.
// Mutation: IsRevealed returns true unconditionally; the Target-change subscription is removed; or
// ResolvedTargetItem returns TargetItem.Value without re-checking.
// 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()
{
@ -176,11 +167,10 @@ public sealed class TargetingSystemTests : IDisposable
var observer = BuildObserver(items, target.Zone);
observer.SetTarget(target);
observer.EntityInfoGathered[target] = .29f; // below the single item's own tier (Armor, N==1)
Assert.False(observer.TrySelectTargetItem(weapon));
Assert.False(observer.TrySelectTargetItem(weapon)); // nothing seen yet
Assert.Null(observer.TargetItem.Value);
observer.EntityInfoGathered[target] = .3f; // at tier: revealed
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
Assert.True(observer.TrySelectTargetItem(weapon));
Assert.Same(weapon, observer.TargetItem.Value);
Assert.Same(weapon, observer.ResolvedTargetItem);
@ -189,58 +179,105 @@ public sealed class TargetingSystemTests : IDisposable
observer.SetTarget(TargetRef.None);
Assert.Null(observer.TargetItem.Value);
// Decay: re-select, then let info fall back below tier without touching TargetItem.Value at all --
// the raw field still holds the item (no loop cleared it), but the read accessor drops it.
// 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.
observer.SetTarget(target);
Assert.True(observer.TrySelectTargetItem(weapon));
observer.EntityInfoGathered[target] = .1f;
weapon.EquippableItem.Durability = 0f;
Assert.Same(weapon, observer.TargetItem.Value);
Assert.Null(observer.ResolvedTargetItem);
}
// The ranking: hardpoint-mounted gear before interior Tool gear, larger before smaller within a group.
// Mutation: drop the ordering (e.g. rank by equipment order alone).
// 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 RevealOrder()
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);
observer.EntityInfoGathered[target] = .35f; // above Armor (.3, index 0), below the mid tier (.6, index 1)
Assert.True(FireControl.IsRevealed(observer, weapon));
Assert.False(FireControl.IsRevealed(observer, wideItem));
Assert.False(FireControl.IsRevealed(observer, narrowItem));
observer.EntityInfoGathered[target] = .65f; // above the mid tier, below Gear (.9, index 2)
Assert.True(FireControl.IsRevealed(observer, weapon));
Assert.True(FireControl.IsRevealed(observer, wideItem));
Assert.False(FireControl.IsRevealed(observer, narrowItem));
observer.EntityInfoGathered[target] = .95f; // above Gear: everything revealed
Assert.True(FireControl.IsRevealed(observer, weapon));
Assert.True(FireControl.IsRevealed(observer, wideItem));
Assert.True(FireControl.IsRevealed(observer, narrowItem));
Assert.True(observer.TrySelectTargetItem(weapon));
Assert.False(observer.TrySelectTargetItem(wideItem));
Assert.False(observer.TrySelectTargetItem(narrowItem));
}
// The tiers really are the two authored settings: the first item reveals exactly at
// TargetArmorInfoThreshold, the last exactly at TargetGearInfoThreshold. Mutation: swap the two
// settings, or use TargetDetectionInfoThreshold instead.
// 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 RevealSpansBothThresholds()
public void WatchingRevealsNoGear()
{
var settings = TestSettings();
var (items, target, weapon, wideItem, narrowItem, _) = BuildTarget(settings);
var (items, target, weapon, wideItem, narrowItem, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
observer.EntityInfoGathered[target] = 1f;
observer.EntityInfoGathered[target] = settings.TargetArmorInfoThreshold;
Assert.True(FireControl.IsRevealed(observer, weapon));
Assert.False(FireControl.IsRevealed(observer, wideItem));
Assert.False(FireControl.IsRevealed(observer, narrowItem));
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));
}
observer.EntityInfoGathered[target] = settings.TargetGearInfoThreshold;
// 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));
Assert.True(FireControl.IsRevealed(observer, wideItem));
Assert.True(FireControl.IsRevealed(observer, narrowItem));
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

View file

@ -63,7 +63,7 @@ public sealed class VapourCloudTests : IDisposable
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = Threshold, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f,
TargetDetectionInfoThreshold = Threshold, TargetGearInfoThreshold = .8f,
TargetInfoDecay = .5f, VisibilityDecay = 0f,
FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 1f
};

View file

@ -1147,7 +1147,7 @@ public static class Program
// Durability 50, Mass 75, Price 75000, heat response 200-500/276.5/36. GameplaySettings.UnaidedAccuracy
// is .05 (AetherDb settings) and GameplaySettings.UnaidedTracking will be 10 once Cut 5 lands (not yet on
// this branch) -- both designs clear it by 50% or more. Resolution is placed against the live thresholds
// this branch reads from AetherDb settings: TargetDetectionInfoThreshold .1, TargetArmorInfoThreshold .5,
// this branch reads from AetherDb settings: TargetDetectionInfoThreshold .1,
// TargetGearInfoThreshold .8. FireControl.HitProbability reads
// pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info)), so a HIGHER Resolution
// needs MORE gathered info before sensor state stops limiting hits -- it is a cost, not a bonus. The