From 7b12fbe417e4abb64caef11894325819b40b169d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 13:54:43 +0200 Subject: [PATCH 1/8] Bearing markers for remembered hostile contacts, labelled with their claimed classes, and a sweep flash per ping Entity.RememberedHostiles and ClaimedClasses derive from the book; music and the new markers share RememberedHostiles. Markers sit on the remembered bearing, never the subject's position; Search warnings are collected per tick into sweep flashes. Unity code is not compiled in this commit's verification (dotnet tests only). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 106 ++++++++++++++++-- Assets/Scripts/ServerShared/Entity.cs | 9 ++ .../Scripts/UI/HUD/VisibleTargetIndicator.cs | 25 +++++ tests/Aetheria.Shared.Tests/ClaimTests.cs | 25 +++++ .../ContactRevealTests.cs | 44 ++++++++ 5 files changed, 200 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index e9d6ede53..7b332bad1 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -162,6 +162,10 @@ public class ActionGameManager : MonoBehaviour public Prototype LockIndicator; // Presentation tuning: how far from the ship, toward the locker, the incoming-lock indicator sits. public float IncomingLockIndicatorDistance = 30; + // Presentation tuning: how far from the ship, toward a remembered hostile's bearing, its marker sits; how long a + // sweep flash takes to fade. + public float BearingMarkerDistance = 40; + public float SearchFlashSeconds = .6f; public PlaceUIElementWorldspace[] Crosshairs; public GameObject HitMarker; public float HitMarkerDuration; @@ -203,6 +207,13 @@ public class ActionGameManager : MonoBehaviour private (PlaceUIElementWorldspace indicator, Rotate spin) _incomingLockIndicator; private Dictionary _visibleHostileIndicators = new Dictionary(); private Dictionary _visibleFriendlyIndicators = new Dictionary(); + // Display-only: a marker per remembered hostile (shares HostileTargetIndicator's pool), the Search warnings the last + // ticks delivered (bearing, zone time; a per-tick buffer), and the sweep flashes drawn from them. + private readonly Dictionary _bearingMarkers = new Dictionary(); + private readonly List<(float2 bearing, float time)> _pendingSweeps = new List<(float2, float)>(); + private readonly List<(PlaceUIElementWorldspace indicator, Image image, float2 bearing, float time)> _sweepFlashes = + new List<(PlaceUIElementWorldspace, Image, float2, float)>(); + private readonly List _markerScratch = new List(); private List _shipSubscriptions = new List(); private List _targetSubscriptions = new List(); private readonly Dictionary _debugLastShots = new Dictionary(); @@ -1003,6 +1014,12 @@ public class ActionGameManager : MonoBehaviour foreach (var indicator in _visibleFriendlyIndicators) Destroy(indicator.Value.gameObject); _visibleFriendlyIndicators.Clear(); + + foreach (var marker in _bearingMarkers.Values) RetireMarker(marker); + _bearingMarkers.Clear(); + foreach (var flash in _sweepFlashes) RetireFlash(flash.indicator, flash.image); + _sweepFlashes.Clear(); + _pendingSweeps.Clear(); if(_lockingIndicators!=null) foreach(var (_, indicator, _) in _lockingIndicators) indicator.GetComponent().ReturnToPool(); @@ -1268,13 +1285,7 @@ public class ActionGameManager : MonoBehaviour private void UpdateMusic() { var combat = CurrentEntity.VisibleEnemies.Count > 0 || CurrentEntity.IncomingPaints.Count > 0; - if (!combat) - foreach (var subject in CurrentEntity.RevealedSubjects) - if (CurrentEntity.IsHostileTo(subject) && CurrentEntity.ContactOf(subject).Detected) - { - combat = true; - break; - } + combat |= CurrentEntity.RememberedHostiles.Any(); var type = combat ? MusicType.Combat : MusicType.Overworld; if (_music == type) return; _music = type; @@ -1360,7 +1371,15 @@ public class ActionGameManager : MonoBehaviour void EnterNewZone() { if (_zoneEntered) { Clock.Reset(); _zoneEntered = false; } } EnterNewZone(); var steppedZone = Zone; - Clock.Advance(_paused ? 0f : Time.deltaTime, delta => { if (Zone == steppedZone) steppedZone.Update(delta); }); + Clock.Advance(_paused ? 0f : Time.deltaTime, delta => + { + 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) + foreach (var warning in CurrentEntity.IncomingPaints) + if (warning.Mode == PaintMode.Search) _pendingSweeps.Add((warning.Bearing, steppedZone.Time)); + }); EnterNewZone(); } @@ -1476,7 +1495,7 @@ public class ActionGameManager : MonoBehaviour } // The strongest lock on the player's ship, read only from IncomingPaints (never another ship's LockWeapon). - // Only Track paints are locks; Search paints (ping sweeps) are presented by contact-hud, not here. + // Only Track paints are locks; Search paints (ping sweeps) are this class's sweep flashes (UpdateSweepFlashes). // Same curves as the outgoing indicator, driven by the warning's Strength. var warnings = CurrentEntity.IncomingPaints; var strongest = -1; @@ -1491,6 +1510,75 @@ public class ActionGameManager : MonoBehaviour new Vector3(warning.Bearing.x, 0, warning.Bearing.y) * IncomingLockIndicatorDistance; ApplyLockProgress(incomingIndicator, incomingSpin, warning.Strength); } + + UpdateBearingMarkers(); + UpdateSweepFlashes(); + } + + // One marker per remembered hostile (a contact held by a bearing only): at the bearing the book keeps, labelled with the + // classes it claimed. Never at the subject's position, never read from its gear. + private void UpdateBearingMarkers() + { + var remembered = CurrentEntity.RememberedHostiles.ToList(); + _markerScratch.Clear(); + foreach (var subject in _bearingMarkers.Keys) + if (!remembered.Contains(subject)) _markerScratch.Add(subject); + foreach (var subject in _markerScratch) + { + RetireMarker(_bearingMarkers[subject]); + _bearingMarkers.Remove(subject); + } + foreach (var subject in remembered) + { + if (!_bearingMarkers.TryGetValue(subject, out var marker)) + { + marker = HostileTargetIndicator.Instantiate(); + marker.Fill.enabled = false; + _bearingMarkers.Add(subject, marker); + } + marker.gameObject.SetActive(true); + var bearing = CurrentEntity.ContactOf(subject).Bearing; + marker.Place.Target = CurrentEntity.Position.ToUnity() + new Vector3(bearing.x, 0, bearing.y) * BearingMarkerDistance; + marker.ShowLabel(string.Join(", ", CurrentEntity.ClaimedClasses(subject).Select(c => c.Name))); + } + } + + // A pooled indicator goes back as the next hostile indicator will want it: label hidden, fill restored. + private static void RetireMarker(VisibleTargetIndicator marker) + { + marker.ShowLabel(null); + marker.Fill.enabled = true; + marker.GetComponent().ReturnToPool(); + } + + private static void RetireFlash(PlaceUIElementWorldspace indicator, Image image) + { + if (image != null) image.color = new Color(image.color.r, image.color.g, image.color.b, 1); + indicator.GetComponent().ReturnToPool(); + } + + // A ping crossing the player draws one flash toward the pinger, fading over SearchFlashSeconds of zone time. + private void UpdateSweepFlashes() + { + foreach (var (bearing, time) in _pendingSweeps) + { + var indicator = LockIndicator.Instantiate(); + _sweepFlashes.Add((indicator, indicator.GetComponentInChildren(), bearing, time)); + } + _pendingSweeps.Clear(); + for (var i = _sweepFlashes.Count - 1; i >= 0; i--) + { + var (indicator, image, bearing, time) = _sweepFlashes[i]; + var age = Zone.Time - time; + if (age < 0 || age >= SearchFlashSeconds) + { + RetireFlash(indicator, image); + _sweepFlashes.RemoveAt(i); + continue; + } + indicator.Target = CurrentEntity.Position.ToUnity() + new Vector3(bearing.x, 0, bearing.y) * IncomingLockIndicatorDistance; + if (image != null) image.color = new Color(image.color.r, image.color.g, image.color.b, 1 - age / SearchFlashSeconds); + } } // The one lock-indicator curve: outgoing (the player's own lock) and incoming (the warning's strength) both tighten through it. diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 69bef3888..77efe7955 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -999,6 +999,10 @@ public abstract class Entity // contact it has (ContactOf is Detected for a tracked subject or one with a young reveal). 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 }); + // 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 // perceives (ruling reveal-ends-on-perception): the destruction of an item of a ship it identifies, the death, @@ -1016,6 +1020,11 @@ public abstract class Entity RevealsOf(subject).Where(r => r.Claimed != null && r.Claimed.Behaviors.Any(b => b is WeaponData)) .OrderBy(r => FittedIndex(subject, r.Item)).ThenBy(r => r.Item.EquippableItem.Lot); + // The classes `subject` has claimed to this observer, distinct and by name: what a bearing marker is labelled with. Reads + // each reveal's Claimed only, never the item's true data (aetheria-ew, classify-from-belief). + public IEnumerable ClaimedClasses(Entity subject) => + RevealsOf(subject).Select(r => r.Claimed).Where(c => c != null).Distinct().OrderBy(c => c.Name, StringComparer.Ordinal); + // A revealed item's place in the subject's equipment; gear the subject no longer carries follows all fitted gear // (ties among it fall to the lot, a stable id), never ahead of it. private static int FittedIndex(Entity subject, EquippedItem item) diff --git a/Assets/Scripts/UI/HUD/VisibleTargetIndicator.cs b/Assets/Scripts/UI/HUD/VisibleTargetIndicator.cs index f12220bd7..db512492e 100644 --- a/Assets/Scripts/UI/HUD/VisibleTargetIndicator.cs +++ b/Assets/Scripts/UI/HUD/VisibleTargetIndicator.cs @@ -2,10 +2,35 @@ using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; +using TMPro; public class VisibleTargetIndicator : MonoBehaviour { public RectTransform Transform; public Image Fill; public PlaceUIElementWorldspace Place; + public TextMeshProUGUI Label; + + // Null or empty hides the label; the first non-empty text creates it as a child (no scene or prefab edit) and keeps it. + // Pools are shared, so hide the label before the indicator returns to its pool. + public void ShowLabel(string text) + { + var show = !string.IsNullOrEmpty(text); + if (Label == null) + { + if (!show) return; + var child = new GameObject("Label", typeof(RectTransform)); + child.transform.SetParent(transform, false); + Label = child.AddComponent(); + Label.fontSize = 14; + Label.alignment = TextAlignmentOptions.Top; + Label.raycastTarget = false; + var rect = Label.rectTransform; + rect.anchorMin = rect.anchorMax = new Vector2(.5f, 0); + rect.pivot = new Vector2(.5f, 1); + rect.sizeDelta = new Vector2(200, 24); + } + Label.gameObject.SetActive(show); + if (show && Label.text != text) Label.text = text; + } } diff --git a/tests/Aetheria.Shared.Tests/ClaimTests.cs b/tests/Aetheria.Shared.Tests/ClaimTests.cs index c07d5f413..8f0e96545 100644 --- a/tests/Aetheria.Shared.Tests/ClaimTests.cs +++ b/tests/Aetheria.Shared.Tests/ClaimTests.cs @@ -141,6 +141,31 @@ public sealed partial class ContactRevealTests Assert.Same(claimed, reveal.Claimed); } + // What a bearing marker is labelled with: the classes the subject claimed, never the item's true data; one class once. + [Fact] + public void ClaimedClassesNameTheClaim() + { + var locker = Add(float3(0, 0, 0), new[] { "Locker", "Gun" }); + var target = Add(float3(0, 0, 200), new[] { "Eye" }); + var lockItem = locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)); + var gunItem = Guns(locker)[0]; + var claimed = DataOf("Eye"); + locker.Claim(lockItem, claimed); + locker.Claim(gunItem, claimed); + locker.SetIff(target, true); + Fire(locker, gunItem); + locker.EntityInfoGathered[target] = 1f; + locker.SetTarget(target); + locker.Aim = normalize(target.Position - locker.Position); + Tick(.5f); + Tick(.5f); + + var items = target.RevealsOf(locker).Select(r => r.Item).ToArray(); + Assert.True(items.Contains(lockItem) && items.Contains(gunItem), "fixture: two items revealed, both claiming Eye"); + Assert.Same(claimed, Assert.Single(target.ClaimedClasses(locker))); // two reveals of one class list it once + Assert.NotSame(lockItem.Data, claimed); // fixture: the claim is not the truth + } + // The emission a ping makes is stamped by the ship that makes it. Mutation: Emit stamps item.Data. [Fact] public void AHeardPingCarriesTheClaim() diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 72af4bd42..fb8d1ef7e 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1345,6 +1345,50 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(0, o.RevealedSubjects.Count); // counts, not the collection: a failure message would print whole entities } + // The hostile subjects held only by a bearing (what bearing markers and the music show): a hostile that fired and is + // untracked is listed; the same kind of subject tracked is not; a non-hostile that fired is not; one that departs + // unseen is listed exactly until its bearing is BearingMemory old; Deactivate empties it. + [Fact] + public void RememberedHostilesAreBearingOnlyHostiles() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var t = Add(float3(50, 0, 0), new[] { "Gun" }); + var n = Add(float3(40, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + o.SetIff(e, true); + o.SetIff(t, true); + o.SetIff(n, false); + var gun = Guns(e)[0]; + Fire(e, gun); + Fire(n, Guns(n)[0]); + Fire(t, Guns(t)[0]); + Assert.False(o.ContactOf(e).Tracked, "fixture: held by a bearing only"); + Assert.True(o.RevealedSubjects.Contains(n) && o.IsHostileTo(n) == false, "fixture: n is revealed and not hostile"); + + Assert.True(o.RememberedHostiles.Contains(e), "a hostile that fired, untracked"); + Assert.False(o.RememberedHostiles.Contains(n), "a non-hostile that fired"); + Assert.True(o.RememberedHostiles.Contains(t), "fixture: t is untracked so far"); + o.EntityInfoGathered[t] = 1f; + Assert.True(o.ContactOf(t).Tracked, "fixture: t is tracked now"); + Assert.False(o.RememberedHostiles.Contains(t), "the same hostile tracked is a hostile indicator's"); + + 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"); + _zone.Remove(e); + 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"); + o.Deactivate(); + Assert.Equal(0, 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) { From 0debeab1b3391243cf6d6f841c7357f4c2f70add Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 14:08:06 +0200 Subject: [PATCH 2/8] Bearing-marker tests: restate the stance after a departure, bring the claimer into hearing A departure drops the observer's IFF override (it lasts the sitting), so the departed-hostile case re-sets it to stand for faction-derived hostility; the gun reveal in the claim fixture needs the target within the Eye's hearing. The previous commit's two new tests fail. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ClaimTests.cs | 2 +- tests/Aetheria.Shared.Tests/ContactRevealTests.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ClaimTests.cs b/tests/Aetheria.Shared.Tests/ClaimTests.cs index 8f0e96545..ca517bf48 100644 --- a/tests/Aetheria.Shared.Tests/ClaimTests.cs +++ b/tests/Aetheria.Shared.Tests/ClaimTests.cs @@ -146,7 +146,7 @@ public sealed partial class ContactRevealTests public void ClaimedClassesNameTheClaim() { var locker = Add(float3(0, 0, 0), new[] { "Locker", "Gun" }); - var target = Add(float3(0, 0, 200), new[] { "Eye" }); + var target = Add(float3(30, 0, 40), new[] { "Eye" }); var lockItem = locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)); var gunItem = Guns(locker)[0]; var claimed = DataOf("Eye"); diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index fb8d1ef7e..1d51456d4 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1376,6 +1376,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 var memory = _items.GameplaySettings.BearingMemory; var lapsed = false; for (var i = 0; i < 200 && !lapsed; i++) From 2b726a998b59d0ec3e9ad853ec633902ec98e585 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 14:11:56 +0200 Subject: [PATCH 3/8] Pin the claimed-class order: reveals made in reverse name order list by name Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ClaimTests.cs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ClaimTests.cs b/tests/Aetheria.Shared.Tests/ClaimTests.cs index ca517bf48..00448549d 100644 --- a/tests/Aetheria.Shared.Tests/ClaimTests.cs +++ b/tests/Aetheria.Shared.Tests/ClaimTests.cs @@ -166,6 +166,28 @@ public sealed partial class ContactRevealTests Assert.NotSame(lockItem.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() + { + var locker = Add(float3(0, 0, 0), new[] { "Locker", "Gun" }); + var target = Add(float3(30, 0, 40), new[] { "Eye" }); + var lockItem = locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)); + var gunItem = Guns(locker)[0]; + locker.Claim(lockItem, DataOf("Eye")); + locker.Claim(gunItem, DataOf("Shout")); + locker.SetIff(target, true); + Fire(locker, gunItem); // the later name is revealed first + locker.EntityInfoGathered[target] = 1f; + locker.SetTarget(target); + locker.Aim = normalize(target.Position - locker.Position); + Tick(.5f); + Tick(.5f); + + Assert.True(target.RevealsOf(locker).Count() >= 2, "fixture: both items revealed"); + Assert.Equal(new[] { "Eye", "Shout" }, target.ClaimedClasses(locker).Select(c => c.Name)); + } + // The emission a ping makes is stamped by the ship that makes it. Mutation: Emit stamps item.Data. [Fact] public void AHeardPingCarriesTheClaim() From 2ea329e6f129adeda3edcd5242f859d7aea1a71e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 16:12:44 +0200 Subject: [PATCH 4/8] 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) { From 2ba41373bd06b78336df60fbeff4177860a1e16b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 17:53:58 +0200 Subject: [PATCH 5/8] Bearing markers read the observer's live stance; a ship out of the zone holds no warnings The hostility stamp (Reveal.Hostile, Contact.Hostile) is deleted: the observer knows its own stance live. The real fault was the IFF override being dropped when its subject departs while the book still remembers it; the override now goes when the book drops the subject (ReleaseStance), so RememberedHostiles reads IsHostileTo. A ship removed from the zone clears its paint inbox and incoming warnings at the owner, so the Parent == null gate in ActionGameManager is deleted. Tests pin both stance directions after the act, the departed override hostile, the exclusive memory window at the equality, and the empty inbox. Unbuilt until the verify job; Unity side not compiled. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 6 +- Assets/Scripts/ServerShared/Entity.cs | 34 ++++-- Assets/Scripts/ServerShared/Sensing.cs | 15 +-- .../ContactRevealTests.cs | 101 +++++++++++++++++- 4 files changed, 126 insertions(+), 30 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index eb757f2cb..f7c79f131 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1375,10 +1375,8 @@ 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. 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) + // A warning lives one tick, so sweeps are collected per tick, never read per frame. + if (CurrentEntity != null && CurrentEntity.Zone == steppedZone) foreach (var warning in CurrentEntity.IncomingPaints) if (warning.Mode == PaintMode.Search) _pendingSweeps.Add((warning.Bearing, steppedZone.Time)); }); diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index e9a457f36..b0f51a6b7 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -306,9 +306,13 @@ public abstract class Entity VisibleEntities.Remove(remove.Value); VisibleEnemies.Remove(remove.Value); VisibleFriendlies.Remove(remove.Value); - // A stance lasts the sitting: it goes with an entity gone for good (destroyed, warped away, unloaded), not - // with a ship docking, which leaves the zone parented to its dock (TryDock) and rejoins it on undock. - if (remove.Value.Parent == null) _iffOverrides.Remove(remove.Value); + if (remove.Value == this) + { + // A ship out of the zone is no longer published to: it holds no warnings (nothing would ever clear them). + _paintInbox.Clear(); + _incomingPaints.Clear(); + } + ReleaseStance(remove.Value); if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs)) { foreach (var s in grudgeSubs) s.Dispose(); @@ -961,7 +965,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, IsHostileTo(subject)); + book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time, claimed); } // A finished scan (Scanner.Execute; rulings scan-is-an-act, quiet-gear-identifiable-hull-layout-known: quiet gear @@ -985,6 +989,14 @@ public abstract class Entity item.EquippableItem.IsDestroyed && EntityInfoGathered.TryGetValue(subject, out var info) && info >= ItemManager.GameplaySettings.TargetGearInfoThreshold; + // A stance lasts the sitting: it goes with a subject gone for good (destroyed, warped away, unloaded) once the book no + // longer remembers it, not with a ship docking, which leaves the zone parented to its dock (TryDock) and rejoins it on + // undock. Called when the subject leaves the zone and when the book drops it, so the override and the book end together. + private void ReleaseStance(Entity subject) + { + if (subject.Parent == null && !Zone.Entities.Contains(subject) && !_reveals.ContainsKey(subject)) _iffOverrides.Remove(subject); + } + // The book's one way out (caller: Sense): `item` null ends every reveal of `subject`, otherwise that item's. // The subject's key goes with its last entry. private void EndReveals(Entity subject, EquippedItem item) @@ -992,7 +1004,9 @@ public abstract class Entity if (!_reveals.TryGetValue(subject, out var book)) return; if (item != null) book.Remove(item); else book.Clear(); - if (book.Count == 0) _reveals.Remove(subject); + if (book.Count != 0) return; + _reveals.Remove(subject); + ReleaseStance(subject); } // The subjects this observer holds any reveal of, departed ones included until the prune: with VisibleEntities, every @@ -1000,10 +1014,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). 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 }); + // the music show). Hostile is the observer's own live stance (IsHostileTo): the observer always knows its own decision, + // and an override outlives a departed subject exactly as long as the book remembers it (ReleaseStance). 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 }); // 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 @@ -1067,7 +1081,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, any && freshest.Hostile); + return new Contact(detected, tracked, detected && any, identified, bearing, info, identified ? subject.ClaimedHull : null); } // 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 3ad4a8078..da1144a75 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -103,9 +103,7 @@ 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. `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. +// whose `Claimed` is the subject's claim for the item. public readonly struct Reveal { public readonly EquippedItem Item; @@ -113,11 +111,9 @@ 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, bool hostile) + public Reveal(EquippedItem item, SignatureChannel? channel, float2 bearing, float time, EquippableItemData claimed) { - Hostile = hostile; Item = item; Claimed = claimed; Channel = channel; @@ -174,8 +170,7 @@ 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. Hostile: the stance that freshest reveal was made under (false with -// none), the one hostility a remembered contact has once its subject is gone. +// bearing while tracked, else the freshest reveal's. public readonly struct Contact { public readonly bool Detected; @@ -185,11 +180,9 @@ 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, bool hostile) + public Contact(bool detected, bool tracked, bool classified, bool identified, float2 bearing, float info, HullData profile) { - Hostile = hostile; Profile = profile; Detected = detected; Tracked = tracked; diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 342dd688c..12a424001 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1054,10 +1054,10 @@ 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. + // A ship out of the zone (docked, destroyed, warped) is no longer published to, so it holds no warnings: the one it + // held when it left is gone, and every reader sees an empty inbox. [Fact] - public void ADockedShipKeepsItsLastWarningStale() + public void AShipOutOfTheZoneHoldsNoWarnings() { var p = Add(float3(0, 0, 100), new[] { "Eye" }); var o = Add(float3(0, 0, 0)); @@ -1071,7 +1071,7 @@ public sealed partial class ContactRevealTests : IDisposable for (var tick = 0; tick < 5; tick++) { Tick(.1f); - Assert.Equal(PaintMode.Search, Assert.Single(o.IncomingPaints).Mode); + Assert.Empty(o.IncomingPaints); } } @@ -1397,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); - Assert.False(o.IsHostileTo(e), "fixture: the departure dropped the stance the override gave; nothing restates it"); + Assert.True(o.IsHostileTo(e), "the override outlives the departure while the book remembers the subject; nothing restates it"); var memory = _items.GameplaySettings.BearingMemory; var lapsed = false; for (var i = 0; i < 200 && !lapsed; i++) @@ -1407,6 +1407,7 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(!lapsed, o.RememberedHostiles.Contains(e)); // exactly while the bearing is remembered } Assert.True(lapsed, "fixture: the bearing lapsed"); + Assert.False(o.IsHostileTo(e), "the book dropped the subject, and the override went with it"); o.Deactivate(); Assert.Equal(0, o.RememberedHostiles.Count()); } @@ -1461,6 +1462,96 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(3, o.RememberedHostiles.Count()); } + // The observer's stance is its own live decision, not something it saw: toggled after the last act, while the subject is + // remembered and untracked, the listing follows at once, in both directions. + [Fact] + public void AStanceToggledAfterTheLastActShowsAtOnce() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var q = Add(float3(40, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + o.SetIff(e, true); + o.SetIff(q, false); + Fire(e, Guns(e)[0]); + Fire(q, Guns(q)[0]); + ShowOnly(e, 1f, Guns(e)[0]); + ShowOnly(q, 1f, Guns(q)[0]); + Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: e unseen"); + Assert.True(q.Visibility * o.PassiveInfo(q.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: q unseen"); + Assert.True(o.RememberedHostiles.Contains(e) && !o.RememberedHostiles.Contains(q), "fixture: stances as made"); + + o.SetIff(e, false); + o.SetIff(q, true); + Tick(.1f); + Assert.True(o.ContactOf(e).Detected && !o.ContactOf(e).Tracked && o.ContactOf(q).Detected && !o.ContactOf(q).Tracked, "fixture: both still remembered by bearing"); + Assert.False(o.RememberedHostiles.Contains(e), "hostile to neutral after the act"); + Assert.True(o.RememberedHostiles.Contains(q), "neutral to hostile after the act"); + } + + // A hostile held only by the observer's override that departs keeps its marker until the bearing lapses and not a tick + // longer, and its override is gone with the book entry; a stance for a subject the book never held goes at once. + [Fact] + public void ADepartedOverrideHostileKeepsItsMarkerThenLosesItsStance() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var never = Add(float3(40, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + o.SetIff(e, true); + o.SetIff(never, true); + Fire(e, Guns(e)[0]); + var freshest = Freshest(o, e); + ShowOnly(e, 1f, Guns(e)[0]); + _zone.Remove(never); + Assert.False(o.IsHostileTo(never), "a subject the book never held loses its stance on leaving"); + _zone.Remove(e); + Assert.True(o.RememberedHostiles.Contains(e), "marker kept on departure"); + 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)); + Assert.Equal(!lapsed, o.IsHostileTo(e)); + } + Assert.True(lapsed, "fixture: the bearing lapsed"); + } + + // The memory window is exclusive: at Zone.Time - freshest == BearingMemory the bearing has lapsed. The sighting is on a + // coarse float time (.5) so that freshest + memory is representable and a step can land on it exactly. + [Fact] + public void TheMemoryWindowIsExclusiveAtTheEquality() + { + 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]; + Tick((float) (.5 - (double) .1f)); + Fire(e, gun); + var freshest = Freshest(o, e); + Assert.Equal(.5f, freshest); + ShowOnly(e, 1f, gun); + Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: unseen"); + var memory = _items.GameplaySettings.BearingMemory; + for (var i = 0; i < 1000 && _zone.Time - freshest + .3f < memory; i++) Tick(.1f); + Assert.True(o.RememberedHostiles.Contains(e), "fixture: still listed"); + var exact = _zone.ExactTime; + var d = (float) (memory - (exact - freshest)); + var found = false; + for (var k = 0; k < 400 && !found; k++) + foreach (var sign in new[] { 1, -1 }) + { + var c = d; + for (var j = 0; j < k; j++) c = sign > 0 ? MathF.BitIncrement(c) : MathF.BitDecrement(c); + if ((float) (exact + c) - freshest == memory) { d = c; found = true; break; } + } + Assert.True(found, "fixture: a step landing exactly on the window edge exists"); + Tick(d); + Assert.Equal(memory, _zone.Time - freshest); + Assert.False(o.RememberedHostiles.Contains(e)); + Assert.False(o.ContactOf(e).Detected); + } + // 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) { From 10aaa8a6ade3a4ccefb666add464af01bc6110ee Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 19:02:39 +0200 Subject: [PATCH 6/8] Pin the book's edge for a departed subject and the stance of one still in the zone Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 12a424001..c5b3ced75 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1517,8 +1517,9 @@ public sealed partial class ContactRevealTests : IDisposable Assert.True(lapsed, "fixture: the bearing lapsed"); } - // The memory window is exclusive: at Zone.Time - freshest == BearingMemory the bearing has lapsed. The sighting is on a - // coarse float time (.5) so that freshest + memory is representable and a step can land on it exactly. + // The memory window is exclusive: at Zone.Time - freshest == BearingMemory the bearing has lapsed, and for a subject that + // has departed the book drops it (and the stance with it) on that tick. The sighting is on a coarse float time (.5) so + // that freshest + memory is representable and a step can land on it exactly. [Fact] public void TheMemoryWindowIsExclusiveAtTheEquality() { @@ -1532,6 +1533,7 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(.5f, freshest); ShowOnly(e, 1f, gun); Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: unseen"); + _zone.Remove(e); var memory = _items.GameplaySettings.BearingMemory; for (var i = 0; i < 1000 && _zone.Time - freshest + .3f < memory; i++) Tick(.1f); Assert.True(o.RememberedHostiles.Contains(e), "fixture: still listed"); @@ -1550,6 +1552,29 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(memory, _zone.Time - freshest); Assert.False(o.RememberedHostiles.Contains(e)); Assert.False(o.ContactOf(e).Detected); + Assert.DoesNotContain(e, o.RevealedSubjects); + Assert.False(o.IsHostileTo(e), "the book dropped the departed subject at the edge, and the stance with it"); + } + + // A stance lasts the sitting: a hostile still in the zone whose every revealed item the observer perceives destroyed + // leaves the book, and the observer's decision about it stays. + [Fact] + public void AStanceOutlastsTheBookEntryOfASubjectStillInTheZone() + { + var e = Add(float3(0, 0, 60), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + o.SetIff(e, true); + var gun = Guns(e)[0]; + Fire(e, gun); + Assert.Single(o.RevealsOf(e)); + o.EntityInfoGathered[e] = 1f; + gun.EquippableItem.Durability = 0f; + + Tick(.01f); + + Assert.Empty(o.RevealsOf(e)); // fixture: o perceived the destruction and the book dropped the subject + Assert.Contains(e, _zone.Entities); + Assert.True(o.IsHostileTo(e)); } // The first field path from `root` to `target`, not entering the zone or the item manager, or null. From 818eee278159ca36c41ffff7a714a58477ca4f22 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 19:09:15 +0200 Subject: [PATCH 7/8] Memory-window edge test runs with the subject staying and departing Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index c5b3ced75..acf064dfc 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1517,11 +1517,13 @@ public sealed partial class ContactRevealTests : IDisposable Assert.True(lapsed, "fixture: the bearing lapsed"); } - // The memory window is exclusive: at Zone.Time - freshest == BearingMemory the bearing has lapsed, and for a subject that - // has departed the book drops it (and the stance with it) on that tick. The sighting is on a coarse float time (.5) so + // The memory window is exclusive: at Zone.Time - freshest == BearingMemory the bearing has lapsed (Detected, whether the + // subject stays or departs), and a departed subject's book entry (and the stance with it) goes on that tick. The sighting is on a coarse float time (.5) so // that freshest + memory is representable and a step can land on it exactly. - [Fact] - public void TheMemoryWindowIsExclusiveAtTheEquality() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TheMemoryWindowIsExclusiveAtTheEquality(bool departs) { var e = Add(float3(0, 0, 100), new[] { "Gun" }); var o = Add(float3(0, 0, 0), new[] { "Eye" }); @@ -1533,7 +1535,7 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(.5f, freshest); ShowOnly(e, 1f, gun); Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: unseen"); - _zone.Remove(e); + if (departs) _zone.Remove(e); var memory = _items.GameplaySettings.BearingMemory; for (var i = 0; i < 1000 && _zone.Time - freshest + .3f < memory; i++) Tick(.1f); Assert.True(o.RememberedHostiles.Contains(e), "fixture: still listed"); @@ -1552,8 +1554,11 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(memory, _zone.Time - freshest); Assert.False(o.RememberedHostiles.Contains(e)); Assert.False(o.ContactOf(e).Detected); - Assert.DoesNotContain(e, o.RevealedSubjects); - Assert.False(o.IsHostileTo(e), "the book dropped the departed subject at the edge, and the stance with it"); + if (departs) + { + Assert.DoesNotContain(e, o.RevealedSubjects); + Assert.False(o.IsHostileTo(e), "the book dropped the departed subject at the edge, and the stance with it"); + } } // A stance lasts the sitting: a hostile still in the zone whose every revealed item the observer perceives destroyed From abe0c848bed796ba3372bf85465d4bc905e7ad2e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 19:55:10 +0200 Subject: [PATCH 8/8] Bearing-marker tests: assertions over entities report booleans, and the docked and inbox clauses are pinned A failing Assert.DoesNotContain/Contains over a collection of Entities made xunit format the Entity graph and overflow the stack, aborting the host; the five such assertions now assert a bool. Adds a docked-subject stance test and a pending-paint-on-leaving test. Builds: yes (test-only). Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index acf064dfc..1c2f1a629 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -667,7 +667,7 @@ public sealed partial class ContactRevealTests : IDisposable case "death": e.Hull.Durability = 0f; e.HullDamage.OnNext(1f); break; default: throw new Exception("fixture: " + how); } - Assert.DoesNotContain(e, zone.Entities); + Assert.False(zone.Entities.Contains(e), "fixture: the subject left the zone"); } // Leaves the ship showing exactly `strength` of light: whatever its guns left glowing is put out first. @@ -1217,7 +1217,7 @@ public sealed partial class ContactRevealTests : IDisposable killerGun.Activate(); for (var ticks = 0; !killed && ticks < 20; ticks++) Tick(.1f); Assert.True(killed, "fixture: the kill lands inside a tick"); - Assert.DoesNotContain(e, _zone.Entities); + Assert.False(_zone.Entities.Contains(e), "fixture: the kill took the subject out of the zone"); Assert.Equal(2, strong.RevealsOf(e).Count()); // this tick's Sense came before the kill @@ -1435,7 +1435,7 @@ public sealed partial class ContactRevealTests : IDisposable 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.True(o.RevealedSubjects.Contains(e), "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)); } @@ -1556,7 +1556,7 @@ public sealed partial class ContactRevealTests : IDisposable Assert.False(o.ContactOf(e).Detected); if (departs) { - Assert.DoesNotContain(e, o.RevealedSubjects); + Assert.False(o.RevealedSubjects.Contains(e), "the departed subject leaves the book at the edge"); Assert.False(o.IsHostileTo(e), "the book dropped the departed subject at the edge, and the stance with it"); } } @@ -1578,7 +1578,7 @@ public sealed partial class ContactRevealTests : IDisposable Tick(.01f); Assert.Empty(o.RevealsOf(e)); // fixture: o perceived the destruction and the book dropped the subject - Assert.Contains(e, _zone.Entities); + Assert.True(_zone.Entities.Contains(e), "fixture: the subject is still in the zone"); Assert.True(o.IsHostileTo(e)); } @@ -1684,4 +1684,50 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(0L, smallest); } + + // A docked subject leaves the zone parented to its dock and rejoins on undock: the observer's decision about it outlives + // the lapse of the bearing, and is still there when it rejoins. + [Fact] + public void ADockedSubjectKeepsTheObserversStanceAfterTheBearingLapses() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var dock = Add(float3(200, 0, 0)); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + o.SetIff(e, true); + var gun = Guns(e)[0]; + Fire(e, gun); + ShowOnly(e, 1f, gun); + Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: unseen"); + e.Parent = dock; + _zone.Remove(e); + for (var i = 0; i < 100 && o.RevealedSubjects.Contains(e); i++) Tick(.1f); + Assert.False(o.RevealedSubjects.Contains(e), "fixture: the book dropped the docked subject"); + Assert.True(o.IsHostileTo(e), "a docked subject keeps the observer's decision"); + e.Parent = null; + _zone.Admit(e, piloted: false); + Assert.True(o.IsHostileTo(e), "and it is still there when the subject rejoins"); + } + + private static int PaintInbox(Entity e) => ((System.Collections.ICollection) typeof(Entity).GetField("_paintInbox", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(e)).Count; + + // A paint still unpublished in an observer's inbox when the observer leaves the zone is gone: it does not arrive as a + // stale burst when the observer is admitted again. + [Fact] + public void AnObserverLeavingTheZoneLosesItsPendingPaints() + { + 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(); + var pending = false; + for (var tick = 0; tick < 400 && !pending; tick++) { Tick(.02f); pending = PaintInbox(o) > 0; } + Assert.True(pending, "fixture: a paint is pending in o's inbox, not yet published"); + _zone.Remove(o); + Assert.Equal(0, PaintInbox(o)); + Tick(.1f); + _zone.Admit(o, piloted: false); + Tick(.02f); + Assert.Equal(0, o.IncomingPaints.Count); + } }