From 49423b61fef3549c45d66f8391aafd9990e94d7c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 06:45:51 +0200 Subject: [PATCH 1/6] EW 3: allegiance is the observer's belief; telemetry with the track, grudges from acts, one stance writer Cuts WatchForGrudge, PerceivedStanceOf, the recursive IsHostileTo and the inline compare-and-write; adds _telemetry (ReceiveTelemetry at track entry), Provoke (Track paint, committed shot), RefreshStance, BelievesTrespassing. Not yet verified: unbuilt, tests unrun. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Agents/Flight.cs | 2 +- Assets/Scripts/ServerShared/Doctrine.cs | 2 +- Assets/Scripts/ServerShared/Entity.cs | 153 ++++++++--------- Assets/Scripts/ServerShared/FireControl.cs | 2 + .../ContactRevealTests.cs | 82 ++++++++- .../FireAuthorityTests.cs | 16 ++ tests/Aetheria.Shared.Tests/FlightTests.cs | 19 +++ .../IffAndCombatTests.cs | 161 +++++++++++++----- 8 files changed, 301 insertions(+), 136 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index 1fe54844b..b96250ad8 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -85,7 +85,7 @@ public class Flight return grudge || _members.Any(m => m.Agent.Ship.EntityInfoGathered.TryGetValue(entity, out var info) && info >= _settings.TargetGearInfoThreshold); case EngageOn.Provoked: return grudge; - case EngageOn.Trespass: return grudge || !(entity.PresencePermitted?.Value ?? true); + case EngageOn.Trespass: return grudge || _members.Any(m => m.Agent.Ship.BelievesTrespassing(entity)); default: return false; } } diff --git a/Assets/Scripts/ServerShared/Doctrine.cs b/Assets/Scripts/ServerShared/Doctrine.cs index 4a9b636ed..586e4b8d8 100644 --- a/Assets/Scripts/ServerShared/Doctrine.cs +++ b/Assets/Scripts/ServerShared/Doctrine.cs @@ -15,7 +15,7 @@ public enum EngageOn Identified, // Only a hostile that a member holds a hostile IFF override on (a grudge). Provoked, - // A hostile whose presence the zone's owner does not permit (Entity.PresencePermitted, the security of the zone it is in), whichever flight sees it. + // A hostile a member believes the zone's owner does not permit (Entity.BelievesTrespassing). Trespass, // Never, not even for a grudge. Never diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 01b5509d5..ab505685b 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -53,15 +53,21 @@ public abstract class Entity public readonly ReactiveCollection CargoBays = new ReactiveCollection(); public readonly ReactiveCollection DockingBays = new ReactiveCollection(); public readonly ReactiveCollection VisibleEntities = new ReactiveCollection(); + // The cache of IsHostileTo for each zone entity: written only by RefreshStance, refreshed each Update. public readonly ReactiveDictionary EntityHostility = new ReactiveDictionary(); public readonly ReactiveCollection VisibleEnemies = new ReactiveCollection(); public readonly ReactiveCollection VisibleFriendlies = new ReactiveCollection(); - // Runtime-only IFF overrides for testing/manual marking and grudges; not persisted in packs. - // When set for another entity, it decides IsHostileTo outright, ahead of the derived faction rules. - // Reactive so a change can update EntityHostility and dependent grudges immediately, not on the next tick. + // Runtime-only IFF overrides: this entity's own decisions (player input, scenario stance, Provoke); not persisted in packs. + // When set for another entity, it decides IsHostileTo outright, ahead of the belief branch. + // Reactive so a change reaches EntityHostility at once (RefreshStance), not on the next tick. private readonly ReactiveDictionary _iffOverrides = new ReactiveDictionary(); + // The observer's belief of each contact's allegiance (aetheria-ew belief row, allegiance part): the faction its telemetry + // claimed when received (ruling ew-telemetry-with-the-track); absent = unknown. A null value is a subject that claims no + // faction (the player). Written only by ReceiveTelemetry; ends with the contact. + private readonly Dictionary _telemetry = new Dictionary(); + public Entity Parent; public List Children = new List(); // Mining Cut 3 (docs/mining-cut-refresh.md): the one target slot -- an entity, a chunk, or none. Read-only from @@ -272,7 +278,6 @@ public abstract class Entity private List _subscriptions = new List(); private Dictionary> _watchedEntitySubscriptions = new Dictionary>(); - private Dictionary> _grudgeSubscriptions = new Dictionary>(); // Grudges are a non-player behavior only: a player's own stance is always the operator's explicit call. private bool IsPlayerControlled => this is Ship {IsPlayerShip: true}; @@ -298,14 +303,14 @@ public abstract class Entity foreach(var entity in Zone.Entities) { EntityInfoGathered[entity] = 0; - EntityHostility[entity] = IsHostileTo(entity); - if (!IsPlayerControlled) WatchForGrudge(entity); + EntityHostility[entity] = false; + RefreshStance(entity); } _subscriptions.Add(Zone.Entities.ObserveAdd().Subscribe(add => { EntityInfoGathered[add.Value] = 0; - EntityHostility[add.Value] = IsHostileTo(add.Value); - if (!IsPlayerControlled) WatchForGrudge(add.Value); + EntityHostility[add.Value] = false; + RefreshStance(add.Value); })); _subscriptions.Add(Zone.Entities.ObserveRemove().Subscribe(remove => { @@ -322,23 +327,14 @@ public abstract class Entity _incomingPaints.Clear(); } ReleaseStance(remove.Value); - if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs)) - { - foreach (var s in grudgeSubs) s.Dispose(); - _grudgeSubscriptions.Remove(remove.Value); - } + if (!_reveals.ContainsKey(remove.Value)) _telemetry.Remove(remove.Value); })); // An override changing updates this entity's hostility toward that entity immediately, - // rather than waiting for the next per-tick derived-hostility refresh. - void RefreshHostilityFromOverride(Entity other) - { - if (EntityHostility.ContainsKey(other)) - EntityHostility[other] = IsHostileTo(other); - } - _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key))); - _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key))); - _subscriptions.Add(_iffOverrides.ObserveRemove().Subscribe(remove => RefreshHostilityFromOverride(remove.Key))); + // rather than waiting for the next per-tick refresh. + _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshStance(add.Key))); + _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshStance(replace.Key))); + _subscriptions.Add(_iffOverrides.ObserveRemove().Subscribe(remove => RefreshStance(remove.Key))); _subscriptions.Add(VisibleEnemies.ObserveRemove().Subscribe(remove => { if (Target.Value.Entity == remove.Value) SetTarget(TargetRef.None); @@ -350,6 +346,9 @@ public abstract class Entity if (replace.OldValue < ItemManager.GameplaySettings.TargetDetectionInfoThreshold && replace.NewValue > ItemManager.GameplaySettings.TargetDetectionInfoThreshold) { + // Tracked: the contact's telemetry arrives with the track and the stance is rated on it before the contact is filed. + ReceiveTelemetry(replace.Key); + RefreshStance(replace.Key); VisibleEntities.Add(replace.Key); if(EntityHostility[replace.Key]) VisibleEnemies.Add(replace.Key); @@ -896,51 +895,13 @@ public abstract class Entity return Position.xz + local.x * right + local.y * forward; } - // Another entity's stance toward THIS one, as far as this entity can perceive it: unknown (null) - // until this entity detects them, mirroring the existing detection model (VisibleEntities, driven - // by EntityInfoGathered crossing TargetDetectionInfoThreshold). Detected but not yet hostility-rated - // by the other entity also reads as unknown. - public bool? PerceivedStanceOf(Entity other) => - VisibleEntities.Contains(other) && other.EntityHostility.TryGetValue(this, out var hostile) - ? hostile - : (bool?) null; - - // Non-player entities hold a grudge: once another entity's stance toward THIS one turns hostile - // (its own override or its derived rule) WHILE this entity detects it, this entity sets a sticky - // hostile override on it back, event-driven off that entity's own EntityHostility changes and off - // this entity's own detection (VisibleEntities) picking up an already-hostile entity. It never - // forgives on its own; only a future utility-evaluation pass is meant to lift a grudge. Leaving the - // zone clears overrides (see the Zone.Entities removal subscription above), so a grudge is also - // cleared then -- a known gap, flagged as a follow-up rather than solved here. - private void WatchForGrudge(Entity other) + // The act owner of a grudge (ruling ew-hostility-from-belief-ruled: a contact becomes hostile when it fires at or locks the + // observer's side): this ship and, given a faction, every entity in the zone of it hold a hostile override on `by`. A player's + // stance is always the operator's call, and an override already held stands. It never forgives on its own. + internal void Provoke(Entity by) { - void CheckGrudge(bool otherIsHostileToThis) - { - if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other)) - SetIff(other, true); - } - - var subscriptions = new List - { - other.EntityHostility.ObserveAdd() - .Where(add => add.Key == this) - .Subscribe(add => CheckGrudge(add.Value)), - other.EntityHostility.ObserveReplace() - .Where(replace => replace.Key == this) - .Subscribe(replace => CheckGrudge(replace.NewValue)), - // Detecting an entity whose stance toward this one is already hostile grudges it immediately. - VisibleEntities.ObserveAdd() - .Where(add => add.Value == other) - .Subscribe(_ => - { - if (other.EntityHostility.TryGetValue(this, out var hostile)) - CheckGrudge(hostile); - }) - }; - _grudgeSubscriptions[other] = subscriptions; - - if (other.EntityHostility.TryGetValue(this, out var currentlyHostile)) - CheckGrudge(currentlyHostile); + foreach (var entity in Zone.Entities.Where(e => e == this || (Faction != null && e.Faction == Faction)).ToList()) + if (!entity.IsPlayerControlled && !entity._iffOverrides.ContainsKey(by)) entity.SetIff(by, true); } public virtual void Deactivate() @@ -950,8 +911,6 @@ public abstract class Entity _subscriptions.Clear(); foreach(var ss in _watchedEntitySubscriptions.Values) foreach(var s in ss) s.Dispose(); _watchedEntitySubscriptions.Clear(); - foreach(var ss in _grudgeSubscriptions.Values) foreach(var s in ss) s.Dispose(); - _grudgeSubscriptions.Clear(); _active = false; EntityInfoGathered.Clear(); VisibleEntities.Clear(); @@ -965,6 +924,7 @@ public abstract class Entity _reveals.Clear(); _seenDestroyed.Clear(); _itemClaims.Clear(); + _telemetry.Clear(); } // Zone.Update runs this on every entity before any entity updates, so a paint made during tick N is published @@ -978,6 +938,7 @@ public abstract class Entity { _incomingPaints.Add(paint.Warning); RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission, paint.Warning.EmitterClass); + if (paint.Warning.Mode == PaintMode.Track) Provoke(paint.Emitter); } _paintInbox.Clear(); _pingReturns.Clear(); @@ -1084,6 +1045,7 @@ public abstract class Entity if (book.Count != 0) return; _reveals.Remove(subject); ReleaseStance(subject); + if (!VisibleEntities.Contains(subject)) _telemetry.Remove(subject); } // The subjects this observer holds any reveal of, departed ones included until the prune: with VisibleEntities, every @@ -1289,24 +1251,45 @@ public abstract class Entity // The override this entity holds on another, or null when it holds none (the derived rule decides). public bool? IffOverride(Entity other) => _iffOverrides.TryGetValue(other, out var hostile) ? hostile : (bool?) null; - public bool IsHostileTo(Entity other, bool recursive = false) + // This observer's stance toward `other`: its own override, else its belief. Allies (the observer's own non-null faction) are + // known by datalink; any other contact is believed by the telemetry received with its track, and unknown (never hostile) + // without it. In a zone owned by a faction a contact is hostile when its claimed faction is the owner's and the owner does + // not permit its presence, or when this is the owner's ship and believes the contact trespasses. + public bool IsHostileTo(Entity other) { - // An override decides only the stance of the entity that holds it. The recursive=true calls - // below are the derived rule asking "is the other entity hostile to me" purely to compute ITS - // OWN reciprocal stance; skipping the override check there stops one entity's override (e.g. a - // player going neutral) from leaking into another entity's derived hostility. - if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile)) - return overrideHostile; + if (_iffOverrides.TryGetValue(other, out var overrideHostile)) return overrideHostile; + if (Faction != null && other.Faction == Faction) return false; + if (!_telemetry.TryGetValue(other, out var claimed)) return false; + var owner = Zone.GalaxyZone.Owner; + if (owner == null) return false; + return (claimed == owner && !(other.PresencePermitted?.Value ?? true)) || (Faction == owner && BelievesTrespassing(other)); + } - if (Faction == null) - return !recursive && other.Faction != null && other.IsHostileTo(this, true); + // The faction `subject`'s telemetry claimed when this observer first tracked it; false while the contact is unknown. + public bool TryGetTelemetry(Entity subject, out Faction faction) => _telemetry.TryGetValue(subject, out faction); - // TODO: Inter-faction hostility - // When the entity faction owns the zone, they are hostile to trespassers or those hostile to them - if (Faction == Zone.GalaxyZone.Owner) - return recursive ? !(other.PresencePermitted?.Value ?? true) : !(other.PresencePermitted?.Value ?? true)|| other.IsHostileTo(this, true); + // Honest telemetry carries the subject's registry, so its standing with the zone's owner is the subject's own + // (until cut ew-masquerade). + public bool BelievesTrespassing(Entity subject) => _telemetry.ContainsKey(subject) && !(subject.PresencePermitted?.Value ?? true); - return !recursive && other.IsHostileTo(this, true); + // Honest until cut ew-masquerade gives the subject a ClaimedFaction to read here. + private void ReceiveTelemetry(Entity subject) => _telemetry[subject] = subject.Faction; + + // The one writer of EntityHostility: only on change, since the filing subscription adds on every Replace. + private void RefreshStance(Entity other) + { + if (EntityHostility.TryGetValue(other, out var current) && current != IsHostileTo(other)) + EntityHostility[other] = !current; + } + + // A contact ends when it is neither tracked nor remembered by a reveal younger than BearingMemory; its telemetry goes with it. + private bool HoldsContact(Entity subject) + { + if (VisibleEntities.Contains(subject)) return true; + if (!_reveals.TryGetValue(subject, out var book)) return false; + foreach (var reveal in book.Values) + if (Zone.Time - reveal.Time < ItemManager.GameplaySettings.BearingMemory) return true; + return false; } // TODO: Inter-faction relationships @@ -1891,10 +1874,8 @@ public abstract class Entity { foreach (var entity in Zone.Entities) { - var previousHostility = EntityHostility[entity]; - var newHostility = IsHostileTo(entity); - if (newHostility != previousHostility) - EntityHostility[entity] = newHostility; + if (_telemetry.ContainsKey(entity) && !HoldsContact(entity)) _telemetry.Remove(entity); + RefreshStance(entity); } if(Cockpit != null) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index c94caded8..5e0a0dcc5 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -1123,6 +1123,8 @@ public static class FireControl // centre lane (see the fuse-point comment on the Contact/Delayed branch below). private static void Apply(Zone zone, PendingShot shot) { + // A committed shot at a ship is an act on its side (ruling ew-hostility-from-belief-ruled): the target provokes the shooter, hit or miss. + if (shot.Target != null && shot.Source != null && shot.Target != shot.Source) shot.Target.Provoke(shot.Source); // A Burst outcome detonates at the point Commit decided (its BurstPoint): a fused round with no target, // which has nothing to hit or miss, and a proximity round, which detonates whether its roll hit or not. // Detonate is still the one blast path. diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index d2f306217..e2a4603be 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1739,13 +1739,9 @@ public sealed partial class ContactRevealTests : IDisposable 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" }); - // The observer's faction owns the zone and the subject's presence is not permitted, so the stance derived for an - // undecided subject is hostile: a neutral decision (false) that is dropped would read hostile, and is seen to be kept. - _zone.GalaxyZone.Owner = o.Faction; - e.CurrentSecurityLevel.Value = SecurityLevel.Critical; - Assert.True(o.IsHostileTo(e), "fixture: an undecided subject derives hostile"); + // The decision is the observer's own override, read back as itself (no telemetry decides here: the subject is never tracked). o.SetIff(e, hostile); - Assert.Equal(hostile, o.IsHostileTo(e)); // fixture: the decision is stored + Assert.Equal(hostile, o.IffOverride(e)); // fixture: the decision is stored var gun = Guns(e)[0]; Fire(e, gun); ShowOnly(e, 1f, gun); @@ -1754,12 +1750,84 @@ public sealed partial class ContactRevealTests : IDisposable _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.Equal(hostile, o.IsHostileTo(e)); + Assert.Equal(hostile, o.IffOverride(e)); e.Parent = null; _zone.Admit(e, piloted: false); + Assert.Equal(hostile, o.IffOverride(e)); Assert.Equal(hostile, o.IsHostileTo(e)); } + // Ruling ew-telemetry-with-the-track: a contact held by a bearing only has no allegiance; it gets one at its first track. + [Fact] + public void ABearingOnlyContactHasNoAllegiance() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + _zone.GalaxyZone.Owner = o.Faction; + e.CurrentSecurityLevel.Value = SecurityLevel.Critical; + var gun = Guns(e)[0]; + Fire(e, gun); + ShowOnly(e, 1f, gun); + Tick(.1f); + Assert.True(o.RevealedSubjects.Contains(e) && !o.ContactOf(e).Tracked, "fixture: held by a bearing only"); + + Assert.False(o.IsHostileTo(e)); + Assert.False(o.TryGetTelemetry(e, out _)); + Assert.DoesNotContain(e, o.RememberedHostiles); + + o.EntityInfoGathered[e] = 1f; + + Assert.True(o.TryGetTelemetry(e, out var claimed) && claimed == e.Faction); + Assert.True(o.IsHostileTo(e)); + Assert.Equal(1, o.VisibleEnemies.Count(x => x == e)); + } + + // The belief outlives the track only as long as a reveal younger than BearingMemory, and goes with the book. + [Fact] + public void TelemetryEndsWhenTheBearingIsForgotten() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + _zone.GalaxyZone.Owner = o.Faction; + e.CurrentSecurityLevel.Value = SecurityLevel.Critical; + var gun = Guns(e)[0]; + Fire(e, gun); + ShowOnly(e, 1f, gun); + o.EntityInfoGathered[e] = 1f; + Assert.True(o.IsHostileTo(e), "fixture: tracked, believed hostile"); + + o.EntityInfoGathered[e] = 0f; // the track is lost; the reveal is young + Tick(.01f); + Assert.False(o.ContactOf(e).Tracked, "fixture"); + Assert.True(o.IsHostileTo(e), "a young reveal keeps the belief"); + Assert.Contains(e, o.RememberedHostiles); + + for (var i = 0; i < 100 && o.RevealedSubjects.Contains(e); i++) Tick(.1f); + Assert.False(o.RevealedSubjects.Contains(e), "fixture: the book dropped the subject"); + Assert.False(o.TryGetTelemetry(e, out _)); + Assert.False(o.IsHostileTo(e)); + } + + [Fact] + public void TelemetryGoesWithADepartedSubjectsBook() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + _zone.GalaxyZone.Owner = o.Faction; + e.CurrentSecurityLevel.Value = SecurityLevel.Critical; + var gun = Guns(e)[0]; + Fire(e, gun); + ShowOnly(e, 1f, gun); + o.EntityInfoGathered[e] = 1f; + Assert.True(o.TryGetTelemetry(e, out _), "fixture"); + + _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 departed subject"); + Assert.False(o.TryGetTelemetry(e, out _)); + } + 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 diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 752d046d1..fb919c2ec 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -219,6 +219,22 @@ public sealed class FireAuthorityTests : IDisposable Assert.Equal(a, b); } + // A committed shot is an act on the target's side (ruling ew-hostility-from-belief-ruled): the target holds a hostile + // override on the shooter whether the shot hits or misses. + [Theory] + [InlineData(0f)] + [InlineData(1f)] + public void AShotProvokes(float accuracy) + { + var e = Build(TestSettings(), accuracy: accuracy, precision: .5f); + Assert.Null(e.Target.IffOverride(e.Shooter)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + FireControl.Step(e.Zone, 0f); + + Assert.True(e.Target.IffOverride(e.Shooter)); + } + // FireControl.HitProbability, R1: zero with no draw when undetected or out of range; rises with info // toward Resolution; falls with Spread; caps at UnaidedAccuracy with no targeting system equipped. // Mutations: drop pSensor (probability stops depending on info); drop the range gate (probability nonzero diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index d2a035ed7..263f9a086 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -823,6 +823,25 @@ public sealed class FlightTests : IDisposable Assert.Same(track, member.Target.Value.Entity); } + // Trespass is the member's belief: a forbidden track it lists as an enemy without ever receiving its telemetry + // (no track entry) is left alone; once it tracks it, the telemetry carries the registry and the doctrine engages. + [Fact] + public void TrespassReadsBelief() + { + var member = Pilot(WithDoctrine(EngageOn.Trespass)); + var trespasser = Track(120); + Forbid(trespasser); + member.VisibleEnemies.Add(trespasser); + + Tick(.1f); + Assert.True(member.Target.Value.IsNone); + + member.EntityInfoGathered[trespasser] = 1; + Tick(.1f); + + Assert.Same(trespasser, member.Target.Value.Entity); + } + // A track with no presence value is permitted: a Trespass doctrine leaves it alone. [Fact] public void TrespassLeavesATrackWithNoPresenceValueAlone() diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 97a1f9435..bf527941c 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -3,12 +3,13 @@ using System.IO; using System.Linq; using CultMath; using GameCult.Caching; +using UniRx; using Xunit; using static CultMath.math; // Hand-testing combat needs entities to target and mark. This pins the IFF override rules on Entity -// (SetIff/IsHostileTo), the event-driven grudge behaviour for non-player entities, detection-gating of -// perceived stance, the shooter-stance weapon fire gate, and the neutral-wanderer faction pool in +// (SetIff/IsHostileTo), grudges from acts (a lock), belief from telemetry received with the track, +// the shooter-stance weapon fire gate, and the neutral-wanderer faction pool in // ZoneGenerator. No Zone/Galaxy save-load is exercised here; RunSaveTests covers that separately. public sealed class IffAndCombatTests : IDisposable { @@ -156,56 +157,59 @@ public sealed class IffAndCombatTests : IDisposable Assert.False(a.EntityHostility[b]); } - [Fact] - public void NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker() + // A lock: the locker paints the victim in Track mode and the victim's inbox is published, the production path a lock takes. + private static void Lock(Entity locker, Entity victim) { - var (items, zone) = BuildWorld(); - var player = NewShip(items, zone, new Faction { Name = "Player" }, isPlayerShip: true); - var npc = NewShip(items, zone, new Faction { Name = "Npc" }); - - player.SetIff(npc, true); // player declares hostile toward the npc - Assert.True(player.IsHostileTo(npc)); - - // The npc does not yet detect the player: the hostile stance must not grudge yet. - Assert.False(npc.IsHostileTo(player)); - - // Detecting the already-hostile player triggers the grudge immediately, event-driven, with no - // Update() tick between detection and the assertion. - Detect(npc, player); - - Assert.True(npc.IsHostileTo(player)); - Assert.True(npc.EntityHostility[player]); + victim.ReceivePaint(locker, null, PaintMode.Track, 1f); + victim.PublishInbox(); } [Fact] - public void GrudgeTriggersImmediatelyWhenAlreadyDetectedAndMarkerTurnsHostile() + public void ALockProvokesASearchPingDoesNot() { var (items, zone) = BuildWorld(); - var player = NewShip(items, zone, new Faction { Name = "Player" }, isPlayerShip: true); + var locker = NewShip(items, zone, new Faction { Name = "Locker" }); + var pinger = NewShip(items, zone, new Faction { Name = "Pinger" }); var npc = NewShip(items, zone, new Faction { Name = "Npc" }); - Detect(npc, player); // npc already sees the player, who is not yet hostile - Assert.False(npc.IsHostileTo(player)); + npc.ReceivePaint(pinger, null, PaintMode.Search, 1f); + npc.PublishInbox(); + Assert.Null(npc.IffOverride(pinger)); - player.SetIff(npc, true); // no Update() tick called before the assertion below + Lock(locker, npc); - Assert.True(npc.IsHostileTo(player)); + Assert.True(npc.IffOverride(locker)); + Assert.True(npc.IsHostileTo(locker)); + Assert.True(npc.EntityHostility[locker]); // the cache follows at once } [Fact] - public void GrudgeIsStickyAndDoesNotForgiveWhenMarkerGoesNeutral() + public void NoActNoGrudge() { var (items, zone) = BuildWorld(); var player = NewShip(items, zone, new Faction { Name = "Player" }, isPlayerShip: true); var npc = NewShip(items, zone, new Faction { Name = "Npc" }); + player.SetIff(npc, true); // the player's own stance toward the npc Detect(npc, player); - player.SetIff(npc, true); - Assert.True(npc.IsHostileTo(player)); + for (var i = 0; i < 5; i++) zone.Update(.1f); - player.SetIff(npc, false); // marker goes neutral + Assert.Null(npc.IffOverride(player)); + Assert.False(npc.IsHostileTo(player)); + } - Assert.True(npc.IsHostileTo(player)); // grudge never forgives on its own + [Fact] + public void AGrudgeIsStickyAndDoesNotForgive() + { + var (items, zone) = BuildWorld(); + var locker = NewShip(items, zone, new Faction { Name = "Locker" }); + var npc = NewShip(items, zone, new Faction { Name = "Npc" }); + + Lock(locker, npc); + for (var i = 0; i < 5; i++) zone.Update(.1f); // the lock is over: no more paints + + Assert.True(npc.IffOverride(locker)); + Assert.True(npc.IsHostileTo(locker)); } [Fact] @@ -215,26 +219,101 @@ public sealed class IffAndCombatTests : IDisposable var attacker = NewShip(items, zone, new Faction { Name = "Attacker" }); var player = NewShip(items, zone, new Faction { Name = "Player" }, isPlayerShip: true); - Detect(player, attacker); - attacker.SetIff(player, true); + Lock(attacker, player); - Assert.False(player.IsHostileTo(attacker)); // no grudge subscription is ever wired for a player ship + Assert.Null(player.IffOverride(attacker)); // a player's stance is the operator's call + Assert.False(player.IsHostileTo(attacker)); } [Fact] - public void PerceivedStanceIsUnknownUntilDetectedThenReadsTheActualStance() + public void ProvocationReachesTheSide() { var (items, zone) = BuildWorld(); - var a = NewShip(items, zone, new Faction { Name = "A" }); - var b = NewShip(items, zone, new Faction { Name = "B" }); + var side = new Faction { Name = "Side" }; + var locker = NewShip(items, zone, new Faction { Name = "Locker" }); + var victim = NewShip(items, zone, side); + var mate = NewShip(items, zone, side); + var friend = NewShip(items, zone, side); + var player = NewShip(items, zone, side, isPlayerShip: true); + var stranger = NewShip(items, zone, new Faction { Name = "Stranger" }); + friend.SetIff(locker, false); - b.SetIff(a, true); // B is hostile to A, but A hasn't detected B yet + Lock(locker, victim); - Assert.Null(a.PerceivedStanceOf(b)); + Assert.True(victim.IffOverride(locker)); + Assert.True(mate.IffOverride(locker)); + Assert.False(friend.IffOverride(locker)); // an override already held stands + Assert.Null(player.IffOverride(locker)); + Assert.Null(stranger.IffOverride(locker)); + } - Detect(a, b); + // A zone owned by `owner`, a patrol of the owner, and a stranger of another faction the owner does not permit. + private (ItemManager items, Zone zone, Ship patrol, Ship stranger) Owned(Faction strangerFaction) + { + var (items, zone) = BuildWorld(); + var owner = new Faction { Name = "Owner" }; + zone.GalaxyZone.Owner = owner; + var patrol = NewShip(items, zone, owner); + var stranger = NewShip(items, zone, strangerFaction); + stranger.PresencePermitted = UniRx.Observable.Return(false).ToReadOnlyReactiveProperty(); + return (items, zone, patrol, stranger); + } - Assert.Equal(true, a.PerceivedStanceOf(b)); + [Fact] + public void TelemetryIsReceivedWithTheTrack() + { + var x = new Faction { Name = "X" }; + var (items, zone, patrol, stranger) = Owned(x); + var unaffiliated = NewShip(items, zone, null); + unaffiliated.PresencePermitted = UniRx.Observable.Return(false).ToReadOnlyReactiveProperty(); + + zone.Update(.1f); + Assert.False(patrol.TryGetTelemetry(stranger, out _), "unknown before the track"); + Assert.False(patrol.IsHostileTo(stranger), "unknown is never hostile"); + Assert.Empty(patrol.VisibleEnemies); + + Detect(patrol, stranger); + Detect(patrol, unaffiliated); + + Assert.True(patrol.TryGetTelemetry(stranger, out var claimed)); + Assert.Same(x, claimed); + Assert.True(patrol.TryGetTelemetry(unaffiliated, out var none)); + Assert.Null(none); // received, and it claims no faction + Assert.True(patrol.IsHostileTo(stranger)); + Assert.Equal(1, patrol.VisibleEnemies.Count(e => e == stranger)); // filed hostile at once, listed once + Assert.DoesNotContain(stranger, patrol.VisibleFriendlies); + } + + [Fact] + public void AlliesAreKnownAsTruth() + { + var (items, zone, patrol, _) = Owned(new Faction { Name = "X" }); + var ally = NewShip(items, zone, patrol.Faction); + ally.PresencePermitted = UniRx.Observable.Return(false).ToReadOnlyReactiveProperty(); + + Detect(patrol, ally); // telemetry would read a trespasser + + Assert.False(patrol.IsHostileTo(ally)); + Assert.Contains(ally, patrol.VisibleFriendlies); + } + + [Fact] + public void TelemetryEndsWithTheContact() + { + var (items, zone, patrol, stranger) = Owned(new Faction { Name = "X" }); + Detect(patrol, stranger); + Assert.True(patrol.IsHostileTo(stranger), "fixture"); + + patrol.EntityInfoGathered[stranger] = 0; // the track is lost and no reveal remembers it + zone.Update(.1f); + + Assert.False(patrol.TryGetTelemetry(stranger, out _)); + Assert.False(patrol.IsHostileTo(stranger)); + + Detect(patrol, stranger); + Assert.True(patrol.TryGetTelemetry(stranger, out _), "fixture: received again at the next track"); + patrol.Deactivate(); + Assert.False(patrol.TryGetTelemetry(stranger, out _)); } [Fact] From 8a779b3aa8a9dc2859c02dd29fc1122cf0248925 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 07:04:46 +0200 Subject: [PATCH 2/6] EW 3 tests: forbid by direct PresencePermitted (Update re-derives the security level); listed-once after a tick Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ContactRevealTests.cs | 7 ++++--- tests/Aetheria.Shared.Tests/IffAndCombatTests.cs | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index e2a4603be..65676bfd4 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -9,6 +9,7 @@ using GameCult.Caching.MessagePack; using MessagePack; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using UniRx; using Xunit; using static CultMath.math; using float2 = CultMath.float2; @@ -1764,7 +1765,7 @@ public sealed partial class ContactRevealTests : IDisposable var e = Add(float3(0, 0, 100), new[] { "Gun" }); var o = Add(float3(0, 0, 0), new[] { "Eye" }); _zone.GalaxyZone.Owner = o.Faction; - e.CurrentSecurityLevel.Value = SecurityLevel.Critical; + Forbid(e); var gun = Guns(e)[0]; Fire(e, gun); ShowOnly(e, 1f, gun); @@ -1789,7 +1790,7 @@ public sealed partial class ContactRevealTests : IDisposable var e = Add(float3(0, 0, 100), new[] { "Gun" }); var o = Add(float3(0, 0, 0), new[] { "Eye" }); _zone.GalaxyZone.Owner = o.Faction; - e.CurrentSecurityLevel.Value = SecurityLevel.Critical; + Forbid(e); var gun = Guns(e)[0]; Fire(e, gun); ShowOnly(e, 1f, gun); @@ -1814,7 +1815,7 @@ public sealed partial class ContactRevealTests : IDisposable var e = Add(float3(0, 0, 100), new[] { "Gun" }); var o = Add(float3(0, 0, 0), new[] { "Eye" }); _zone.GalaxyZone.Owner = o.Faction; - e.CurrentSecurityLevel.Value = SecurityLevel.Critical; + Forbid(e); var gun = Guns(e)[0]; Fire(e, gun); ShowOnly(e, 1f, gun); diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index bf527941c..0ee403886 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -281,6 +281,8 @@ public sealed class IffAndCombatTests : IDisposable Assert.Null(none); // received, and it claims no faction Assert.True(patrol.IsHostileTo(stranger)); Assert.Equal(1, patrol.VisibleEnemies.Count(e => e == stranger)); // filed hostile at once, listed once + zone.Update(.01f); + Assert.Equal(1, patrol.VisibleEnemies.Count(e => e == stranger)); // still listed once after the per-tick refresh Assert.DoesNotContain(stranger, patrol.VisibleFriendlies); } From 1ddd1bef31d62b22ec1100fd5a423b3217d90331 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 07:05:45 +0200 Subject: [PATCH 3/6] EW 3 tests: reciprocity test comment no longer names the deleted recursive branch Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/IffAndCombatTests.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 0ee403886..0de789ff1 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -147,9 +147,7 @@ public sealed class IffAndCombatTests : IDisposable var a = NewShip(items, zone, new Faction { Name = "A" }); var b = NewShip(items, zone, new Faction { Name = "B" }); - // B declares hostile toward A via override. Rule (b) (reciprocity-on-marked-hostile) is deleted: - // A's own derived hostility toward B must not mirror B's override through the recursive - // "other.IsHostileTo(this, true)" reciprocity check baked into the derived rule. + // B declares hostile toward A via override. A's own stance toward B is its own: it never reads B's mind. b.SetIff(a, true); Assert.True(b.IsHostileTo(a)); // B's own stance: override decides From 5a7ab7944b7670d626e3b71535ab72a1d4c807d1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 07:10:54 +0200 Subject: [PATCH 4/6] EW 3 tests: add the Forbid helper the previous commit referenced (8a779b3a does not build) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ContactRevealTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 65676bfd4..69ec819ad 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1758,6 +1758,9 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(hostile, o.IsHostileTo(e)); } + // The zone owner does not permit `e` here. Set directly: Update re-derives the security level from the ship's location. + private static void Forbid(Entity e) => e.PresencePermitted = UniRx.Observable.Return(false).ToReadOnlyReactiveProperty(); + // Ruling ew-telemetry-with-the-track: a contact held by a bearing only has no allegiance; it gets one at its first track. [Fact] public void ABearingOnlyContactHasNoAllegiance() From cbb4526070dd25a828ce906b544892c3ad682fe5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 07:32:45 +0200 Subject: [PATCH 5/6] EW 3 tests: a live quiet subject's reveal stays in the book, so the belief ends by age, not by the book dropping Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ContactRevealTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 69ec819ad..3018f3741 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1786,7 +1786,7 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(1, o.VisibleEnemies.Count(x => x == e)); } - // The belief outlives the track only as long as a reveal younger than BearingMemory, and goes with the book. + // The belief outlives the track only as long as a reveal younger than BearingMemory. [Fact] public void TelemetryEndsWhenTheBearingIsForgotten() { @@ -1806,8 +1806,8 @@ public sealed partial class ContactRevealTests : IDisposable Assert.True(o.IsHostileTo(e), "a young reveal keeps the belief"); Assert.Contains(e, o.RememberedHostiles); - for (var i = 0; i < 100 && o.RevealedSubjects.Contains(e); i++) Tick(.1f); - Assert.False(o.RevealedSubjects.Contains(e), "fixture: the book dropped the subject"); + for (var i = 0; i < 25; i++) Tick(.1f); // 2.5 s: past BearingMemory (2 s); the live quiet subject's reveal stays in the book, old + Assert.False(o.ContactOf(e).Detected, "fixture: the bearing is forgotten"); Assert.False(o.TryGetTelemetry(e, out _)); Assert.False(o.IsHostileTo(e)); } From ac5cbcc51ba3442c56cfc063c2e6401247252ecc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 08:10:29 +0200 Subject: [PATCH 6/6] EW 3 fix: a contact is hostile on the observer's own permission, with tests for trespass through Zone.Update Entity.IsHostileTo read other.PresencePermitted in its claimed-owner term where the spec and map S2 say the observer's own, so a trespasser never held the owner's patrol hostile. Tests: the trespasser (player or not) holds the patrol hostile and its guns are not safed, a permitted neutral does not, a ship tracked in open space turns hostile on entering the Critical area through ticks (pins the per-tick stance refresh), and a third party does not treat a trespasser as hostile (pins the owner gate). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 2 +- .../ContactRevealTests.cs | 118 ++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index ab505685b..034e211bb 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1262,7 +1262,7 @@ public abstract class Entity if (!_telemetry.TryGetValue(other, out var claimed)) return false; var owner = Zone.GalaxyZone.Owner; if (owner == null) return false; - return (claimed == owner && !(other.PresencePermitted?.Value ?? true)) || (Faction == owner && BelievesTrespassing(other)); + return (claimed == owner && !(PresencePermitted?.Value ?? true)) || (Faction == owner && BelievesTrespassing(other)); } // The faction `subject`'s telemetry claimed when this observer first tracked it; false while the contact is unknown. diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 3018f3741..6ea4af3da 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1854,4 +1854,122 @@ public sealed partial class ContactRevealTests : IDisposable Tick(.02f); Assert.Equal(0, o.IncomingPaints.Count); } + + // Trespass through Zone.Update: the owner's Critical station and the ship's location derive its security level each tick. + private void OwnedGalaxy(Faction owner) + { + var g = (System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(Galaxy))) as Galaxy; + typeof(Galaxy).GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(g, new Dictionary { [owner] = FactionRelationship.Neutral }); + typeof(Zone).GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(_zone, g); + _zone.GalaxyZone.Owner = owner; + var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Rack")); + var station = new OrbitalEntity(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = _nextLot++ }, default, new EntitySettings()) + { Faction = owner, SecurityLevel = SecurityLevel.Critical, SecurityRadius = 1000 }; + _zone.Admit(station, piloted: false); + station.Position = float3(0, 0, -50); + } + + // A ship (the player's, with no faction, when `player`) of the zone's neighbourhood: an Eye and a Gun. + private Ship Visitor(float3 position, bool player) + { + var visitor = Add(position, new[] { "Eye", "Gun" }); + if (player) { visitor.Faction = null; visitor.IsPlayerShip = true; } + return visitor; + } + + private static bool GunSafed(Ship shooter, Entity at) + { + shooter.SetTarget(at); + return !Guns(shooter)[0].Behaviors.OfType().Single().StanceAllowsFire; + } + + // The trespasser's own permission decides its belief about the owner's patrol, so she holds the patrol hostile at once + // (map S2 reciprocity) and her guns are not safed against it. The owner's patrol holds her hostile as well. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ATrespasserHoldsTheOwnersPatrolHostile(bool player) + { + var patrol = Add(float3(0, 0, 0), new[] { "Eye", "Gun" }); + var intruder = Visitor(float3(0, 0, 60), player); + OwnedGalaxy(patrol.Faction); + Tick(.05f); + Assert.False(intruder.PresencePermitted.Value); + Assert.True(patrol.PresencePermitted.Value); + + patrol.EntityInfoGathered[intruder] = 1f; + intruder.EntityInfoGathered[patrol] = 1f; + Tick(.05f); + + Assert.Null(intruder.IffOverride(patrol)); + Assert.True(intruder.IsHostileTo(patrol)); + Assert.Contains(patrol, intruder.VisibleEnemies); + Assert.False(GunSafed(intruder, patrol)); + Assert.True(patrol.IsHostileTo(intruder)); + Assert.Contains(intruder, patrol.VisibleEnemies); + } + + // A permitted neutral that tracks the owner's patrol holds it as no enemy: the observer's own standing decides. + [Fact] + public void APermittedNeutralDoesNotHoldTheOwnersPatrolHostile() + { + var patrol = Add(float3(0, 0, 0), new[] { "Eye", "Gun" }); + var neutral = Visitor(float3(0, 0, 2000), player: false); + OwnedGalaxy(patrol.Faction); + Tick(.05f); + Assert.True(neutral.PresencePermitted.Value); + + neutral.EntityInfoGathered[patrol] = 1f; + Tick(.05f); + + Assert.True(neutral.VisibleEntities.Contains(patrol)); + Assert.False(neutral.IsHostileTo(patrol)); + Assert.DoesNotContain(patrol, neutral.VisibleEnemies); + } + + // The operator's path: tracked in open space, then flies into the Critical area. Only the per-tick refresh sees it. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AShipTrackedInOpenSpaceTurnsHostileOnceItTrespasses(bool player) + { + var patrol = Add(float3(0, 0, 0), new[] { "Eye", "Gun" }); + var intruder = Visitor(float3(0, 0, 2000), player); + OwnedGalaxy(patrol.Faction); + Tick(.05f); + patrol.EntityInfoGathered[intruder] = 1f; + Tick(.05f); + Assert.True(patrol.VisibleEntities.Contains(intruder)); + Assert.False(patrol.IsHostileTo(intruder)); + Assert.DoesNotContain(intruder, patrol.VisibleEnemies); + + for (var i = 0; i < 5; i++) { intruder.Position = float3(0, 0, 60); patrol.EntityInfoGathered[intruder] = 1f; Tick(.05f); } + + Assert.False(intruder.PresencePermitted.Value); + Assert.True(patrol.IsHostileTo(intruder)); + Assert.Equal(1, patrol.VisibleEnemies.Count(x => x == intruder)); + Assert.DoesNotContain(intruder, patrol.VisibleFriendlies); + } + + // Only the owner's ships treat a trespasser as hostile: a third party that tracks her holds her no enemy. + [Fact] + public void OnlyTheOwnersShipsTreatATrespasserAsHostile() + { + var patrol = Add(float3(0, 0, 0), new[] { "Eye" }); + var bystander = Add(float3(0, 0, 100), new[] { "Eye" }); + var intruder = Visitor(float3(0, 0, 60), player: true); + OwnedGalaxy(patrol.Faction); + Tick(.05f); + Assert.False(intruder.PresencePermitted.Value); + + patrol.EntityInfoGathered[intruder] = 1f; + bystander.EntityInfoGathered[intruder] = 1f; + Tick(.05f); + + Assert.True(patrol.IsHostileTo(intruder)); + Assert.True(bystander.VisibleEntities.Contains(intruder)); + Assert.False(bystander.IsHostileTo(intruder)); + Assert.DoesNotContain(intruder, bystander.VisibleEnemies); + } }