From eebf6f375ab78f4e5095645619523ba847e84193 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 02:01:20 +0200 Subject: [PATCH] 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 --- Aetheria.Shared/Aetheria.Shared.csproj | 4 + Assets/Resources/Settings.asset | 4 +- Assets/Scripts/ServerShared/Entity.cs | 4 + Assets/Scripts/ServerShared/FireControl.cs | 36 +---- Assets/Scripts/ServerShared/Settings.cs | 1 - .../ContactRevealTests.cs | 6 +- .../FireAuthorityTests.cs | 3 +- .../FireControlCut11Tests.cs | 1 - .../FireControlCut123Tests.cs | 1 - .../FireControlCut124Tests.cs | 1 - .../FireControlCut12Tests.cs | 7 +- .../FireControlCut5Tests.cs | 20 ++- .../FireControlCut6Tests.cs | 1 - .../FireControlCut6cTests.cs | 1 - .../FireControlCut6dTests.cs | 2 +- .../FireControlCut7Tests.cs | 33 ++--- .../FireControlCut8Tests.cs | 1 - .../FireControlCut9Tests.cs | 1 - .../FireControlPerWeaponTests.cs | 1 - .../Aetheria.Shared.Tests/FireControlTests.cs | 19 +++ .../Aetheria.Shared.Tests/LockWarningTests.cs | 1 - tests/Aetheria.Shared.Tests/MineTests.cs | 2 +- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 1 - tests/Aetheria.Shared.Tests/SignatureTests.cs | 1 - .../TargetingSystemTests.cs | 131 +++++++++++------- .../Aetheria.Shared.Tests/VapourCloudTests.cs | 2 +- tools/AetherDb/Program.cs | 2 +- 27 files changed, 156 insertions(+), 131 deletions(-) diff --git a/Aetheria.Shared/Aetheria.Shared.csproj b/Aetheria.Shared/Aetheria.Shared.csproj index 43d1c257d..a8a4c97bc 100644 --- a/Aetheria.Shared/Aetheria.Shared.csproj +++ b/Aetheria.Shared/Aetheria.Shared.csproj @@ -23,6 +23,10 @@ + + + + diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index e1b1933e3..50124154b 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:070f5797cb93cad308c38f3c87e05a729cfb21b87004fcdc2e146dba336045d6 -size 13073 +oid sha256:ffc1465cae49e57dbfd25e6c685b30388e9e208ea81ae50a4c560764a12ba016 +size 13039 diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 42d58cc2a..dc0573c76 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -952,6 +952,10 @@ public abstract class Entity public IEnumerable RevealsOf(Entity subject) => _reveals.TryGetValue(subject, out var book) ? book.Values : Enumerable.Empty(); + // 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) { diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 83fad8b59..6a1f4e3ba 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -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 diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 1601a5280..c6ffe93d9 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -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; diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index a9698eae4..956f67aaa 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -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 diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 9512be458..c1777109a 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -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); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index ed352a535..237c39851 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -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, diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index f935c2edf..be01188b2 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -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, diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index a1d5445e8..880f34c77 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -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, diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 2c0623273..fb1fa19fc 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -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); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index 327b6e9e9..a21e4bef3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -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(items.ItemData.GetByName("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)); } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 8f581a33d..888124dd2 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -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, diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs index 586c658b4..c5f9584a7 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs @@ -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, diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs index fdf54d5f2..12857f0cf 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs @@ -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); } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs index 56b5cc2be..d08db8c65 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs @@ -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 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); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index cb81bbe68..5a112de4e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -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, diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 2a94080ed..50d17bf88 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -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, diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 5617eaa0b..488d2f58e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -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, diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 8b0f16e9f..0e3876f1b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -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] diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index 3b2710660..e876bbf15 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -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, diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index f132de9d2..74541bdac 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -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 }; diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 09a421db6..7ab47da79 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -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, diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index f68fa163c..4f818e4a8 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -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, diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs index b621f7b87..a5120c646 100644 --- a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -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(items.ItemData.GetByName("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 diff --git a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs index e35b311b4..3bd0ec7d1 100644 --- a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs +++ b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs @@ -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 }; diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 2185ca077..fec27f55f 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -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