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