Remember a hostile's stance with its sighting; pin the lapse, claim and distance rules

RememberedHostiles read the stance the freshest reveal was made under (Reveal.Hostile, surfaced as Contact.Hostile) instead of IsHostileTo now, so a hostile held by the observer's override keeps its marker and the music after it dies or warps, until the bearing lapses. The departure test no longer re-sets the override. New tests: the Detected clause for an in-zone lapsed hostile, position independence for far hostiles on three sides, a within-category lie in ClaimedClasses, and the stale warning of a docked ship. The HUD no longer collects sweeps for a docked ship, and a sweep flash resets the pooled lock indicator's motion.

Untested here: the Unity-side changes (ActionGameManager) have no headless test and no compile has run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-14 16:12:44 +02:00
commit 2ea329e6f1
5 changed files with 110 additions and 11 deletions

View file

@ -1375,8 +1375,10 @@ public class ActionGameManager : MonoBehaviour
{
if (Zone != steppedZone) return;
steppedZone.Update(delta);
// A warning lives one tick, so sweeps are collected per tick, never read per frame.
if (CurrentEntity != null && CurrentEntity.Zone == steppedZone)
// A warning lives one tick, so sweeps are collected per tick, never read per frame. A docked ship is out of the
// zone's updates, so its published warnings go stale and are not collected (UpdateTargetIndicators drains only
// while it is free).
if (CurrentEntity != null && CurrentEntity.Zone == steppedZone && CurrentEntity.Parent == null)
foreach (var warning in CurrentEntity.IncomingPaints)
if (warning.Mode == PaintMode.Search) _pendingSweeps.Add((warning.Bearing, steppedZone.Time));
});
@ -1563,6 +1565,7 @@ public class ActionGameManager : MonoBehaviour
foreach (var (bearing, time) in _pendingSweeps)
{
var indicator = LockIndicator.Instantiate<PlaceUIElementWorldspace>();
ApplyLockProgress(indicator, indicator.GetComponent<Rotate>(), 0); // a pooled lock indicator keeps its last tightening
_sweepFlashes.Add((indicator, indicator.GetComponentInChildren<Image>(), bearing, time));
}
_pendingSweeps.Clear();

View file

@ -961,7 +961,7 @@ public abstract class Entity
if (!_active || item == null || !Zone.Entities.Contains(subject) || PerceivesDestroyed(subject, item)) return;
if (!_reveals.TryGetValue(subject, out var book))
_reveals[subject] = book = new Dictionary<EquippedItem, Reveal>();
book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time, claimed);
book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time, claimed, IsHostileTo(subject));
}
// A finished scan (Scanner.Execute; rulings scan-is-an-act, quiet-gear-identifiable-hull-layout-known: quiet gear
@ -1000,8 +1000,10 @@ public abstract class Entity
public IReadOnlyCollection<Entity> RevealedSubjects => _reveals.Keys;
// The hostile subjects this observer holds only by a bearing: revealed, Detected, not Tracked (what bearing markers and
// the music show). Reads the book and ContactOf, never the subject's Position or gear.
public IEnumerable<Entity> RememberedHostiles => RevealedSubjects.Where(s => IsHostileTo(s) && ContactOf(s) is { Detected: true, Tracked: false });
// the music show). Hostile is the stance the freshest reveal was made under (Contact.Hostile), not IsHostileTo now: the
// stance an override gave goes with the subject when it departs, and the marker lasts until the bearing lapses. Reads the
// book and ContactOf, never the subject's Position or gear.
public IEnumerable<Entity> RememberedHostiles => RevealedSubjects.Where(s => ContactOf(s) is { Detected: true, Tracked: false, Hostile: true });
// What this observer has seen `subject`'s items do: each item's latest act, and nothing the subject's present
// gear or Durability says (ruling contact-reads-book-aim-reads-live). A reveal ends only on what this observer
@ -1065,7 +1067,7 @@ public abstract class Entity
var bearing = tracked ? normalize((subject.Position - Position).xz) : remembered ? freshest.Bearing : default;
var identified = info >= settings.TargetGearInfoThreshold;
// Profile is the one derivation of the hull this observer believes (no stored belief until the judge, ew-masquerade).
return new Contact(detected, tracked, detected && any, identified, bearing, info, identified ? subject.ClaimedHull : null);
return new Contact(detected, tracked, detected && any, identified, bearing, info, identified ? subject.ClaimedHull : null, any && freshest.Hostile);
}
// What this observer's instruments receive of other ships' acts this tick (Zone.Update calls it once, after every

View file

@ -103,7 +103,9 @@ public readonly struct PaintWarning
// replaces the earlier one. Position is never carried: only the track gives it. `Item` stays the true item: it is the
// book's key and what aim reads. `Claimed` is the class the act claimed, and is what every classifying reader reads
// (aetheria-ew, classify-from-belief). A null `Channel` is a reveal by the observer's own scan (Entity.RecordInspection),
// whose `Claimed` is the subject's claim for the item.
// whose `Claimed` is the subject's claim for the item. `Hostile` is the observer's stance toward the subject at that act
// (IsHostileTo then): remembered with the sighting, so a subject that departs and takes the observer's stance for it along
// is still the hostile it was seen to be.
public readonly struct Reveal
{
public readonly EquippedItem Item;
@ -111,9 +113,11 @@ public readonly struct Reveal
public readonly SignatureChannel? Channel;
public readonly float2 Bearing;
public readonly float Time;
public readonly bool Hostile;
public Reveal(EquippedItem item, SignatureChannel? channel, float2 bearing, float time, EquippableItemData claimed)
public Reveal(EquippedItem item, SignatureChannel? channel, float2 bearing, float time, EquippableItemData claimed, bool hostile)
{
Hostile = hostile;
Item = item;
Claimed = claimed;
Channel = channel;
@ -170,7 +174,8 @@ public readonly struct Departure
// membership of VisibleEntities. Classified: detected, with any reveal in the book. Identified: the track has reached
// TargetGearInfoThreshold, and holds a hull belief: Profile, the hull class this observer believes the subject is (null
// below Identified; aetheria-ew, classify-from-belief; seen-through arrives with the judge). Bearing is the true
// bearing while tracked, else the freshest reveal's.
// bearing while tracked, else the freshest reveal's. Hostile: the stance that freshest reveal was made under (false with
// none), the one hostility a remembered contact has once its subject is gone.
public readonly struct Contact
{
public readonly bool Detected;
@ -180,9 +185,11 @@ public readonly struct Contact
public readonly float2 Bearing;
public readonly float Info;
public readonly HullData Profile;
public readonly bool Hostile;
public Contact(bool detected, bool tracked, bool classified, bool identified, float2 bearing, float info, HullData profile)
public Contact(bool detected, bool tracked, bool classified, bool identified, float2 bearing, float info, HullData profile, bool hostile)
{
Hostile = hostile;
Profile = profile;
Detected = detected;
Tracked = tracked;

View file

@ -166,6 +166,22 @@ public sealed partial class ContactRevealTests
Assert.NotSame(lockItem.Data, claimed); // fixture: the claim is not the truth
}
// A lie within a category is the ordinary lie: a gun that claims another weapon class is labelled with the claim.
[Fact]
public void ClaimedClassesNameTheClaimWithinACategory()
{
var e = Add(float3(0, 0, 0), new[] { "Gun" });
var o = Add(float3(30, 0, 40), new[] { "Eye" });
var gun = Guns(e)[0];
var claimed = DataOf("Beam");
Assert.True(gun.Data.Behaviors.Any(b => b is WeaponData) && claimed.Behaviors.Any(b => b is WeaponData), "fixture: truth and claim are both weapons");
e.Claim(gun, claimed);
Fire(e, gun);
Assert.Same(claimed, Assert.Single(o.ClaimedClasses(e)));
Assert.NotSame(gun.Data, claimed); // fixture: the claim is not the truth
}
// The label's order is the claimed names', never the order the reveals were made in.
[Fact]
public void ClaimedClassesAreOrderedByName()

View file

@ -1054,6 +1054,27 @@ public sealed partial class ContactRevealTests : IDisposable
Assert.False(o.ContactOf(p).Detected);
}
// A ship out of the zone (docked) is no longer published to: the Search warning it held when it left stays in
// IncomingPaints tick after tick, which is why the HUD's per-tick sweep collection must not read a docked ship.
[Fact]
public void ADockedShipKeepsItsLastWarningStale()
{
var p = Add(float3(0, 0, 100), new[] { "Eye" });
var o = Add(float3(0, 0, 0));
var sensor = (Sensor) p.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor)).Behaviors.Single(b => b is Sensor);
Tick(.01f);
sensor.Ping();
for (var tick = 0; tick < 100 && o.IncomingPaints.Count == 0; tick++) Tick(.1f);
Assert.Single(o.IncomingPaints); // fixture: the ping crossed o
_zone.Remove(o);
for (var tick = 0; tick < 5; tick++)
{
Tick(.1f);
Assert.Equal(PaintMode.Search, Assert.Single(o.IncomingPaints).Mode);
}
}
// An observer's own gear is not another ship's act: a ship that fires its own gun, with its own sensor at the
// very place of the light, records nothing of itself, while a neighbour does record the gun.
[Fact]
@ -1376,7 +1397,7 @@ public sealed partial class ContactRevealTests : IDisposable
ShowOnly(e, 1f, gun);
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its last light is below o's gate");
_zone.Remove(e);
o.SetIff(e, true); // a departure drops the stance (it lasts the sitting); this stands for hostility the faction rule keeps
Assert.False(o.IsHostileTo(e), "fixture: the departure dropped the stance the override gave; nothing restates it");
var memory = _items.GameplaySettings.BearingMemory;
var lapsed = false;
for (var i = 0; i < 200 && !lapsed; i++)
@ -1390,6 +1411,56 @@ public sealed partial class ContactRevealTests : IDisposable
Assert.Equal(0, o.RememberedHostiles.Count());
}
// A hostile that fired and stayed in the zone, dark: the book keeps it (an in-zone subject is never pruned), so only
// ContactOf's Detected ends its listing, exactly when its bearing is BearingMemory old.
[Fact]
public void ALapsedHostileInTheZoneIsNotRemembered()
{
var e = Add(float3(0, 0, 100), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
o.SetIff(e, true);
var gun = Guns(e)[0];
Fire(e, gun);
var freshest = Freshest(o, e);
ShowOnly(e, 1f, gun);
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its last light is below o's gate");
Assert.True(o.RememberedHostiles.Contains(e), "fixture: listed while young");
var memory = _items.GameplaySettings.BearingMemory;
var lapsed = false;
for (var i = 0; i < 200 && !lapsed; i++)
{
Tick(.1f);
lapsed = _zone.Time - freshest >= memory;
Assert.Equal(!lapsed, o.RememberedHostiles.Contains(e)); // exactly while the bearing is remembered
}
Assert.True(lapsed, "fixture: the bearing lapsed");
Assert.Contains(e, o.RevealedSubjects); // fixture: the in-zone subject is still in the book
Assert.False(o.ContactOf(e).Detected, "fixture: the contact is no longer Detected");
Assert.False(o.RememberedHostiles.Contains(e));
}
// A remembered hostile is decided by the book, never by where the subject stands: loud pingers 500 to 600 away on three
// sides are heard (Roar: 300 over .5) and listed, each at the bearing it was heard on.
[Fact]
public void RememberedHostilesDoNotDependOnDistance()
{
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var spots = new[] { float3(550, 0, 0), float3(-520, 0, 100), float3(0, 0, 590) };
var pingers = spots.Select(spot => Add(spot, new[] { "Roar" })).ToArray();
foreach (var p in pingers) o.SetIff(p, true);
foreach (var p in pingers) PingAt(p);
foreach (var p in pingers)
{
Assert.False(o.ContactOf(p).Tracked, "fixture: held by a bearing only");
Assert.True(length(p.Position - o.Position) > 500f, "fixture: far from the observer");
Assert.True(o.RememberedHostiles.Contains(p));
var toward = normalize((p.Position - o.Position).xz);
AssertBearing(toward.x, toward.y, o.ContactOf(p).Bearing);
}
Assert.Equal(3, o.RememberedHostiles.Count());
}
// The first field path from `root` to `target`, not entering the zone or the item manager, or null.
private static string PathFrom(object root, object target)
{