From 2ba41373bd06b78336df60fbeff4177860a1e16b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 17:53:58 +0200 Subject: [PATCH] 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) {