diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index e58abbac3..3738fe896 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -459,7 +459,7 @@ public abstract class Entity var subject = Target.Value.Entity; var aim = AimedAt; if (subject == null) return AimTarget.None; - if (aim.Item != null) return aim.Item.Entity == subject && FireControl.IsRevealed(this, aim.Item) ? aim : AimTarget.None; + if (aim.Item != null) return FireControl.IsRevealed(this, aim.Item) ? aim : AimTarget.None; return MountAimable(subject, aim.Mount) ? aim : AimTarget.None; } } @@ -1055,17 +1055,16 @@ public abstract class Entity if (subject.Parent == null && !Zone.Entities.Contains(subject) && !_reveals.ContainsKey(subject)) _iffOverrides.Remove(subject); } - // This observer perceives `subject`'s gear (ContactOf's Identified gate): each of its destroyed items on a mount of the - // hull this ship believes it to be (LayoutOf, by reference) or in the book leaves its mount in the seen-destroyed - // record. The same PerceivesDestroyed Sense ends reveals by, applied to located mounts. + // This observer perceives `subject`'s gear (ContactOf's Identified gate): each of its destroyed items leaves its mount + // in the seen-destroyed record. The same PerceivesDestroyed Sense ends reveals by, applied to mounts. The record is read + // only for mounts of LayoutOf (MountAimable, AimCycle), so a true mount outside the believed layout is never asked for. private void RecordSeenDestroyed(Entity subject) { - // Indexed, and the layout read only for an item actually perceived destroyed: Sense runs for every pair of ships every tick. + // Indexed: Sense runs for every pair of ships every tick. for (var i = 0; i < subject.Equipment.Count; i++) { var item = subject.Equipment[i]; if (item.Hardpoint == null || !PerceivesDestroyed(subject, item)) continue; - if (!LayoutOf(subject).Contains(item.Hardpoint) && !(_reveals.TryGetValue(subject, out var book) && book.ContainsKey(item))) continue; if (!_seenDestroyed.TryGetValue(subject, out var seen)) _seenDestroyed[subject] = seen = new HashSet(); seen.Add(item.Hardpoint); } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 9724f5975..202ed7d24 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -321,8 +321,8 @@ public static class FireControl // GetBehavior calls (Accuracy, Resolution via PSensor, Precision), at ~40 B each -- each // one walks Entity.Equipment, a ReactiveCollection whose enumerator is heap-allocated per foreach. It is // NOT `Behaviors` (the earlier wording here was wrong): EquippedItem.Behaviors is a plain array, allocates - // nothing to enumerate. With an item aimed at, ResolveAimPoint -> CellsOf also allocates (a List, then - // ToArray) -- about 944 B per HitProbability, on top of the three GetBehavior calls. None of this is + // nothing to enumerate. With an item or a mount aimed at, ResolveAimPoint also allocates: CellsOf (a List, then + // ToArray) about 944 B per HitProbability, measured; MountCells (a LINQ Select, then ToArray) not measured. Both are on top of the three GetBehavior calls. None of this is // Silhouette's or this pooling's cost; it predates Cut 12.2 and belongs to Entity's own collection choice // and to CellsOf, out of this cut's scope. Cut 10's own guard is what actually matters // operationally: HitProbability and CommitProbability both bail before Forecast is ever called for the diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index 9de4fae0a..3a678fde9 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -122,7 +122,7 @@ public readonly struct Reveal } } -// What an observer aims at on its target (Entity.Aim): exactly one of a revealed item or a mount of the hull the observer +// What an observer aims at on its target (Entity.AimedAt): exactly one of a revealed item or a mount of the hull the observer // believes the target to be (Entity.LayoutOf), or nothing. A mount is a believed position, never the true item on it. public readonly struct AimTarget { diff --git a/tests/Aetheria.Shared.Tests/LayoutAimTests.cs b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs index 18336b804..c44c5834a 100644 --- a/tests/Aetheria.Shared.Tests/LayoutAimTests.cs +++ b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs @@ -289,6 +289,40 @@ public sealed partial class ContactRevealTests Assert.Equal(3f, sil.AimPoint.y, 4); } + // ---- AMountAimThatLostItsGateDropsOutOfForecastAndFire ---- + + // The aim slot holds the mount, but what prices and freezes a shot is the aim as it resolves now: a mount the observer has + // since seen destroyed, or whose hull it no longer identifies, is no aim. The forecast prices the unaimed hull and the + // shot is fired aimed at nothing, though nothing wrote the slot at the moment the gate closed. + [Theory] + [InlineData("destroyed")] + [InlineData("unidentified")] + public void AMountAimThatLostItsGateDropsOutOfForecastAndFire(string how) + { + var (shooter, bay, reactor, weapon, gun) = Gunner(); + var aimedPrice = FireControl.Inspect(weapon, shooter, bay).POnHull; + shooter.TrySelectTargetItem(null); + var unaimedPrice = FireControl.Inspect(weapon, shooter, bay).POnHull; + Assert.True(Math.Abs(aimedPrice - unaimedPrice) > 1e-3f, "fixture: the mount aim and no aim are priced apart"); + Assert.True(shooter.TrySelectMount(reactor.Hardpoint), "fixture: the mount is aimed at again"); + + if (how == "destroyed") + { + reactor.EquippableItem.Durability = 0f; + SenseOf(shooter)(); + } + else + { + shooter.EntityInfoGathered[bay] = Identify - .01f; + } + Assert.True(shooter.ResolvedAim.Mount == null, "fixture: the aim no longer resolves"); + + if (how == "destroyed") + Assert.Equal(unaimedPrice, FireControl.Inspect(weapon, shooter, bay).POnHull, 4); + var shot = FireAt(weapon, gun, shooter); + Assert.True(shot.AimedMount == null && shot.Aimed == null, "the shot is aimed at nothing"); + } + // ---- TheShotKeepsTheAimItWasFiredWith ---- [Fact] diff --git a/tests/Aetheria.Shared.Tests/MountCycleTests.cs b/tests/Aetheria.Shared.Tests/MountCycleTests.cs index 3392fd945..6540c9974 100644 --- a/tests/Aetheria.Shared.Tests/MountCycleTests.cs +++ b/tests/Aetheria.Shared.Tests/MountCycleTests.cs @@ -217,4 +217,43 @@ public sealed partial class ContactRevealTests Assert.True(SameMounts(honest.HullData.Hardpoints.ToArray(), MountsOf(observer.AimCycle(liar))), "no claimed mount is recorded destroyed"); } + + // ---- TheRecordIsPerSubject ---- + + // Two ships of one hull share their HardpointData. The reactor seen destroyed on one Bay leaves that Bay's cycle and no + // other: the observer's memory is of the ship it saw, not of the mount. + [Fact] + public void TheRecordIsPerSubject() + { + var (bayA, observer, reactorA) = Identified(); + var bayB = AddBay(float3(100, 0, 100)); + observer.EntityInfoGathered[bayB] = 1f; + var reactorB = Fitted(bayB, "BayReactor"); + Assert.True(ReferenceEquals(reactorA.Hardpoint, reactorB.Hardpoint), "fixture: the two Bays share their mount objects"); + + reactorA.EquippableItem.Durability = 0f; + SenseOf(observer)(); + + Assert.False(MountsOf(observer.AimCycle(bayA)).Any(m => ReferenceEquals(m, reactorA.Hardpoint)), "fixture: seen destroyed on A"); + Assert.True(MountsOf(observer.AimCycle(bayB)).Any(m => ReferenceEquals(m, reactorB.Hardpoint)), "B's reactor mount stays in B's cycle"); + Target(observer, bayB); + Assert.True(observer.TrySelectMount(reactorB.Hardpoint), "and can be aimed at on B"); + } + + // ---- DeactivateClearsTheRecord ---- + + // A ship that warps out forgets what it saw: its fresh book no longer supports the record, so the mount seen destroyed + // is back in the cycle. + [Fact] + public void DeactivateClearsTheRecord() + { + var (bay, observer, reactor) = Identified(); + reactor.EquippableItem.Durability = 0f; + SenseOf(observer)(); + Assert.False(MountsOf(observer.AimCycle(bay)).Any(m => ReferenceEquals(m, reactor.Hardpoint)), "fixture: recorded"); + + observer.Deactivate(); + observer.EntityInfoGathered[bay] = 1f; + Assert.True(MountsOf(observer.AimCycle(bay)).Any(m => ReferenceEquals(m, reactor.Hardpoint)), "the record went with the observer's memory"); + } }