Merge eureka/aetheria-release-bearing-markers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-14 20:32:03 +02:00
commit a17dab3677
5 changed files with 500 additions and 15 deletions

View file

@ -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<Entity, VisibleTargetIndicator> _visibleHostileIndicators = new Dictionary<Entity, VisibleTargetIndicator>();
private Dictionary<Entity, VisibleTargetIndicator> _visibleFriendlyIndicators = new Dictionary<Entity, VisibleTargetIndicator>();
// 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<Entity, VisibleTargetIndicator> _bearingMarkers = new Dictionary<Entity, VisibleTargetIndicator>();
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<Entity> _markerScratch = new List<Entity>();
private List<IDisposable> _shipSubscriptions = new List<IDisposable>();
private List<IDisposable> _targetSubscriptions = new List<IDisposable>();
private readonly Dictionary<EquippedItem, ShotOutcome> _debugLastShots = new Dictionary<EquippedItem, ShotOutcome>();
@ -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<Prototype>().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<VisibleTargetIndicator>();
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<Prototype>().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<Prototype>().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<PlaceUIElementWorldspace>();
ApplyLockProgress(indicator, indicator.GetComponent<Rotate>(), 0); // a pooled lock indicator keeps its last tightening
_sweepFlashes.Add((indicator, indicator.GetComponentInChildren<Image>(), 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.

View file

@ -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<Entity> 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<Entity> 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<EquippableItemData> 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)

View file

@ -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<TextMeshProUGUI>();
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;
}
}

View file

@ -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()

View file

@ -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);
}
}