diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index e9d6ede53..f7c79f131 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,76 @@ 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(); + ApplyLockProgress(indicator, indicator.GetComponent(), 0); // a pooled lock indicator keeps its last tightening + _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..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(); @@ -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,13 +1004,21 @@ 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 // 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). 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 // perceives (ruling reveal-ends-on-perception): the destruction of an item of a ship it identifies, the death, @@ -1016,6 +1036,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..83bf4a832 100644 --- a/tests/Aetheria.Shared.Tests/ClaimTests.cs +++ b/tests/Aetheria.Shared.Tests/ClaimTests.cs @@ -141,6 +141,69 @@ 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(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"); + 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 + } + + // 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() + { + 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() diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 72af4bd42..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. @@ -1054,6 +1054,27 @@ public sealed partial class ContactRevealTests : IDisposable Assert.False(o.ContactOf(p).Detected); } + // 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 AShipOutOfTheZoneHoldsNoWarnings() + { + 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.Empty(o.IncomingPaints); + } + } + // 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] @@ -1196,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 @@ -1345,6 +1366,222 @@ 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); + 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++) + { + 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.False(o.IsHostileTo(e), "the book dropped the subject, and the override went with it"); + o.Deactivate(); + 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.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)); + } + + // 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 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 (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. + [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" }); + 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"); + 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"); + 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); + if (departs) + { + 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"); + } + } + + // 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.True(_zone.Entities.Contains(e), "fixture: the subject is still in the zone"); + Assert.True(o.IsHostileTo(e)); + } + // 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) { @@ -1447,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); + } }