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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-17 06:45:51 +02:00
commit 49423b61fe
8 changed files with 299 additions and 134 deletions

View file

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

View file

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

View file

@ -53,15 +53,21 @@ public abstract class Entity
public readonly ReactiveCollection<EquippedCargoBay> CargoBays = new ReactiveCollection<EquippedCargoBay>();
public readonly ReactiveCollection<EquippedDockingBay> DockingBays = new ReactiveCollection<EquippedDockingBay>();
public readonly ReactiveCollection<Entity> VisibleEntities = new ReactiveCollection<Entity>();
// The cache of IsHostileTo for each zone entity: written only by RefreshStance, refreshed each Update.
public readonly ReactiveDictionary<Entity, bool> EntityHostility = new ReactiveDictionary<Entity, bool>();
public readonly ReactiveCollection<Entity> VisibleEnemies = new ReactiveCollection<Entity>();
public readonly ReactiveCollection<Entity> VisibleFriendlies = new ReactiveCollection<Entity>();
// 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<Entity, bool> _iffOverrides = new ReactiveDictionary<Entity, bool>();
// 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<Entity, Faction> _telemetry = new Dictionary<Entity, Faction>();
public Entity Parent;
public List<Entity> Children = new List<Entity>();
// 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<IDisposable> _subscriptions = new List<IDisposable>();
private Dictionary<Entity, List<IDisposable>> _watchedEntitySubscriptions = new Dictionary<Entity, List<IDisposable>>();
private Dictionary<Entity, List<IDisposable>> _grudgeSubscriptions = new Dictionary<Entity, List<IDisposable>>();
// 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<IDisposable>
{
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)

View file

@ -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.

View file

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

View file

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

View file

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

View file

@ -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]