From 2ea329e6f129adeda3edcd5242f859d7aea1a71e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 16:12:44 +0200 Subject: [PATCH] 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 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 7 +- Assets/Scripts/ServerShared/Entity.cs | 10 ++- Assets/Scripts/ServerShared/Sensing.cs | 15 +++- tests/Aetheria.Shared.Tests/ClaimTests.cs | 16 ++++ .../ContactRevealTests.cs | 73 ++++++++++++++++++- 5 files changed, 110 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 7b332bad1..eb757f2cb 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -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(); + ApplyLockProgress(indicator, indicator.GetComponent(), 0); // a pooled lock indicator keeps its last tightening _sweepFlashes.Add((indicator, indicator.GetComponentInChildren(), bearing, time)); } _pendingSweeps.Clear(); diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 77efe7955..e9a457f36 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -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(); - 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 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 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 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 diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index da1144a75..3ad4a8078 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -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; diff --git a/tests/Aetheria.Shared.Tests/ClaimTests.cs b/tests/Aetheria.Shared.Tests/ClaimTests.cs index 00448549d..83bf4a832 100644 --- a/tests/Aetheria.Shared.Tests/ClaimTests.cs +++ b/tests/Aetheria.Shared.Tests/ClaimTests.cs @@ -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() diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 1d51456d4..342dd688c 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -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) {