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