Pin the mount aim's resolved reads, the record's isolation and its Deactivate clear; delete two dead guards

Forecast and Fire drop a mount aim whose gate closed (destroyed, unidentified); a record for one Bay never hides another's mount; Deactivate clears the record. Deleted: the located-or-booked guard in RecordSeenDestroyed and the Entity == subject check in ResolvedAim (the record is read only for layout mounts; a Target change already clears the slot). Comments name AimedAt. Not built or run yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-15 07:14:35 +02:00
commit a54e2f53df
5 changed files with 81 additions and 9 deletions

View file

@ -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<HardpointData>();
seen.Add(item.Hardpoint);
}

View file

@ -321,8 +321,8 @@ public static class FireControl
// GetBehavior<TargetingSystem> calls (Accuracy, Resolution via PSensor, Precision), at ~40 B each -- each
// one walks Entity.Equipment, a ReactiveCollection<T> 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

View file

@ -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
{

View file

@ -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]

View file

@ -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");
}
}