From 6894c2093e6ea446b1301077f88f74c820d92e61 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 18:25:57 +0200 Subject: [PATCH 01/17] Entity records reveals and derives Contact; ChunkInfo reads PassiveInfoPerSignature The observer's Entity owns a reveal book keyed (subject, item). Sense records a light act when its strength times PassiveInfoPerSignature clears the detection threshold; PublishInbox records an emitter whose paint arrived. ReceivePaint takes the emitter and its item. ContactOf derives Detected, Tracked, Classified and Identified from track, reveals, the zone clock and GameplaySettings.BearingMemory. ChunkInfo's arithmetic moves into PassiveInfoPerSignature; ActiveSensors fills one buffer instead of allocating per call. Not yet built or tested: verify follows with the tests. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/LockWeapon.cs | 2 +- .../Scripts/ServerShared/Behaviors/Sensor.cs | 2 +- Assets/Scripts/ServerShared/Entity.cs | 143 ++++++++++++++++-- Assets/Scripts/ServerShared/Sensing.cs | 43 ++++++ Assets/Scripts/ServerShared/Settings.cs | 3 + 5 files changed, 176 insertions(+), 17 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index ac067a1ad..ee01e6172 100644 --- a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs @@ -102,7 +102,7 @@ public class LockWeapon : InstantWeapon { var lerp = 1 - unlerp(0, 90, degrees); _lock = saturate(_lock + pow(lerp, DirectionImpact) * dt * LockSpeed * pow(Entity.EntityInfoGathered[target], SensorImpact)); - target.ReceivePaint(Entity.Position, Item?.Data, PaintMode.Track, Lock); + target.ReceivePaint(Entity, Item, PaintMode.Track, Lock); } else _lock = saturate(_lock - dt * Decay); } diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index f55017fc5..9b83c0330 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -198,7 +198,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer if (sweeping && dist < _pingRadius && _reached.Add(entity) && InBeam(diff, dist)) { heard = true; - entity.ReceivePaint(Entity.Position, Item?.Data, PaintMode.Search, 0); + entity.ReceivePaint(Entity, Item, PaintMode.Search, 0); credit += Gain(entity.Visibility, sensitivity, response, dist, dt, true, obscuration); var pingReturn = new PingReturn(entity, dist, obscuration, Evaluate(_data.PingRange)); foreach (var ally in Entity.Zone.Entities) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index b12eb6c7f..e9c8fef96 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -189,7 +189,22 @@ public abstract class Entity // sweeping this ship (Sensor.Execute); a track paint is a lock (LockWeapon.Execute). Each reports into the // inbox the tick it paints; PublishInbox publishes the inbox once, so a warning lives exactly one tick after // its last paint. - private readonly List _paintInbox = new List(); + // The inbox keeps who painted and with what beside the warning: the warning is all a reader of IncomingPaints + // gets, the emitter and item are for PublishInbox, which records the emitter's reveal. + private readonly struct PendingPaint + { + public readonly PaintWarning Warning; + public readonly Entity Emitter; + public readonly EquippedItem Item; + + public PendingPaint(PaintWarning warning, Entity emitter, EquippedItem item) + { + Warning = warning; + Emitter = emitter; + Item = item; + } + } + private readonly List _paintInbox = new List(); private readonly List _incomingPaints = new List(); public IReadOnlyList IncomingPaints => _incomingPaints; // An ally's sensor posts a return on the tick its ping crosses a subject; published with paints, read by this @@ -197,6 +212,9 @@ public abstract class Entity private readonly List _returnInbox = new List(); private readonly List _pingReturns = new List(); internal IReadOnlyList PingReturns => _pingReturns; + // What this observer has seen each ship's items do (ruling visibility-per-capability-reveal), keyed (subject, + // item). Written only by Sense and PublishInbox through RecordReveal; memory of the sitting, never decayed. + private readonly Dictionary> _reveals = new Dictionary>(); public ReactiveProperty CurrentSecurityLevel = new ReactiveProperty(SecurityLevel.Open); public ReadOnlyReactiveProperty PresencePermitted; @@ -247,6 +265,7 @@ public abstract class Entity { if (Target.Value.Entity == remove.Value) SetTarget(TargetRef.None); EntityInfoGathered.Remove(remove.Value); + _reveals.Remove(remove.Value); EntityHostility.Remove(remove.Value); VisibleEntities.Remove(remove.Value); VisibleEnemies.Remove(remove.Value); @@ -395,22 +414,30 @@ public abstract class Entity // Mining Cut 3 (Q14 A): this entity's info on a chunk, computed when asked and stored nowhere. It is the value // the per-tick sensor rule (Sensor.Execute, through Sensor.Gain) would settle at for a target this bright, - // here, now. Each active sensor applies x <- saturate(x + G_i dt)(1 - k dt) every tick, so with n sensors the - // info settles where the gain balances the decay: sum(G_i) / (n k), capped at 1. A ping adds nothing that - // lasts, so only the passive gain enters. + // here, now (PassiveInfoPerSignature, scaled by the brightness), capped at 1. public float ChunkInfo(ChunkId chunk) { var visibility = Zone.ChunkVisibility(chunk); if (visibility <= 0f) return 0f; - var position = Zone.ChunkPose(chunk.Field, chunk.Index).xy; + return saturate(visibility * PassiveInfoPerSignature(Zone.ChunkPose(chunk.Field, chunk.Index).xy)); + } + + // The info a target of unit signature at `position` would settle at under this entity's passive sensing, before + // the cap at 1. Each active sensor applies x <- saturate(x + G_i dt)(1 - k dt) every tick, so with n sensors the + // info settles where the gain balances the decay: sum(G_i) / (n k). A ping adds nothing that lasts, so only the + // passive gain enters. It is linear in the signature below saturation, which is what lets one value answer for + // every signature at a place: ChunkInfo scales it by a chunk's brightness, Sense by each act's strength. Zero + // with no active sensor. + public float PassiveInfoPerSignature(float2 position) + { var rate = 0f; var sensors = 0; foreach (var sensor in ActiveSensors()) { - rate += sensor.PassiveRate(visibility, position); + rate += sensor.PassiveRate(1f, position); sensors++; } - return sensors == 0 ? 0f : saturate(rate / (sensors * ItemManager.GameplaySettings.TargetInfoDecay)); + return sensors == 0 ? 0f : rate / (sensors * ItemManager.GameplaySettings.TargetInfoDecay); } // Mining Cut 3: the detection threshold, read through the chunk path. A chunk is visible exactly as an entity @@ -637,10 +664,25 @@ public abstract class Entity return max(gain, 0f) / (sensors * ItemManager.GameplaySettings.TargetInfoDecay * threshold) * 1.0001f; } - // Every sensor behaviour that runs this tick: those on active equipment, and those of active consumables. - private IEnumerable ActiveSensors() => - Equipment.Where(item => item.Active.Value).SelectMany(item => item.Behaviors).OfType() - .Concat(_activeConsumables.SelectMany(effect => effect.Behaviors).OfType()); + // Every sensor behaviour that runs this tick: those on active equipment, and those of active consumables. The + // list is this entity's one buffer, refilled per call (Sense asks once per other ship per tick, allocating + // nothing), so callers use it before asking again. + private readonly List _activeSensors = new List(); + private List ActiveSensors() + { + _activeSensors.Clear(); + for (var i = 0; i < Equipment.Count; i++) + { + var item = Equipment[i]; + if (!item.Active.Value) continue; + foreach (var behavior in item.Behaviors) + if (behavior is Sensor sensor) _activeSensors.Add(sensor); + } + foreach (var effect in _activeConsumables) + foreach (var behavior in effect.Behaviors) + if (behavior is Sensor sensor) _activeSensors.Add(sensor); + return _activeSensors; + } // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of // what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so @@ -811,6 +853,7 @@ public abstract class Entity _incomingPaints.Clear(); _returnInbox.Clear(); _pingReturns.Clear(); + _reveals.Clear(); } // Zone.Update runs this on every entity before any entity updates, so a paint made during tick N is published @@ -819,7 +862,11 @@ public abstract class Entity internal void PublishInbox() { _incomingPaints.Clear(); - _incomingPaints.AddRange(_paintInbox); + foreach (var paint in _paintInbox) + { + _incomingPaints.Add(paint.Warning); + RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission); + } _paintInbox.Clear(); _pingReturns.Clear(); _pingReturns.AddRange(_returnInbox); @@ -833,11 +880,75 @@ public abstract class Entity } // The callers are Sensor.Execute (a Search paint, Strength 0) and LockWeapon.Execute (a Track paint, Strength - // the emitter's own Lock at that paint). - internal void ReceivePaint(float3 emitterPosition, EquippableItemData emitterClass, PaintMode mode, float strength) + // the emitter's own Lock at that paint). The emitter's class is the item's data (null with no item: a + // consumable's effect, which has no item and so is never revealed). + internal void ReceivePaint(Entity emitter, EquippedItem item, PaintMode mode, float strength) { if (!_active) return; - _paintInbox.Add(new PaintWarning(normalize((emitterPosition - Position).xz), emitterClass, mode, strength)); + _paintInbox.Add(new PendingPaint(new PaintWarning(normalize((emitter.Position - Position).xz), item?.Data, mode, strength), emitter, item)); + } + + // The one writer of the reveal book (callers: Sense and PublishInbox, both in this file): this observer saw + // `item` of `subject` act on `channel`, now, from this bearing. An item-less act (hull radiation, a consumable) + // reveals nothing. A later act of the same item replaces the earlier one. + internal void RecordReveal(Entity subject, EquippedItem item, SignatureChannel channel) + { + if (!_active || item == null) return; + if (!_reveals.TryGetValue(subject, out var book)) + _reveals[subject] = book = new Dictionary(); + book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time); + } + + // What this observer has seen `subject`'s items do: each item's latest act, filtered on read to items still on + // the subject and not destroyed (Durability >= .01, as FireControl ranks gear). + public IEnumerable RevealsOf(Entity subject) + { + if (!_reveals.TryGetValue(subject, out var book)) yield break; + foreach (var reveal in book.Values) + if (reveal.Item.Entity == subject && subject.Equipment.Contains(reveal.Item) && reveal.Item.EquippableItem.Durability >= .01f) + yield return reveal; + } + + // The one derivation of how much this observer holds of `subject`: see Contact. + public Contact ContactOf(Entity subject) + { + var settings = ItemManager.GameplaySettings; + var info = EntityInfoGathered.TryGetValue(subject, out var gathered) ? gathered : 0f; + var tracked = VisibleEntities.Contains(subject); + var any = false; + var freshest = default(Reveal); + foreach (var reveal in RevealsOf(subject)) + if (!any || reveal.Time > freshest.Time) + { + any = true; + freshest = reveal; + } + var remembered = any && Zone.Time - freshest.Time < settings.BearingMemory; + var detected = tracked || remembered; + var bearing = tracked ? normalize((subject.Position - Position).xz) : remembered ? freshest.Bearing : default; + return new Contact(detected, tracked, detected && any, info >= settings.TargetGearInfoThreshold, bearing, info); + } + + // What this observer's instruments receive of other ships' acts this tick (Update calls it once, after the + // equipment loop): every light signature of an item that would alone settle this observer's passive info above + // TargetDetectionInfoThreshold is revealed (ruling visibility-per-capability-reveal). Hull radiation names no + // item and only feeds the track; an Emission entry is never light. One PassiveInfoPerSignature per ship. + internal void Sense() + { + var threshold = ItemManager.GameplaySettings.TargetDetectionInfoThreshold; + var entities = Zone.Entities; + for (var i = 0; i < entities.Count; i++) + { + var other = entities[i]; + if (other == this) continue; + var perSignature = -1f; + foreach (var signature in other._visibilitySources.Values) + { + if (signature.Item == null || signature.Channel == SignatureChannel.Emission) continue; + if (perSignature < 0f) perSignature = PassiveInfoPerSignature(other.Position.xz); + if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel); + } + } } public Entity(ItemManager itemManager, Zone zone, EquippableItem hull, EntitySettings settings) @@ -1544,6 +1655,8 @@ public abstract class Entity equippedItem.Update(delta); } + Sense(); + foreach (var message in Messages.Keys.ToArray()) { Messages[message] = Messages[message] - delta; diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index bed29a429..9c846a694 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -73,3 +73,46 @@ public readonly struct PaintWarning Strength = strength; } } + +// What an observer has seen `Item` do (ruling visibility-per-capability-reveal): the channel it showed on, the +// bearing at that act and the zone clock then. Held per (subject, item) in the observer's Entity; the latest act +// replaces the earlier one. Position is never carried: only the track gives it. +public readonly struct Reveal +{ + public readonly EquippedItem Item; + public readonly SignatureChannel Channel; + public readonly float2 Bearing; + public readonly float Time; + + public Reveal(EquippedItem item, SignatureChannel channel, float2 bearing, float time) + { + Item = item; + Channel = channel; + Bearing = bearing; + Time = time; + } +} + +// How much an observer holds of one subject, derived on read by Entity.ContactOf from its track, its reveals and +// the zone clock. Detected: tracked, or a reveal younger than GameplaySettings.BearingMemory. Tracked: exactly +// membership of VisibleEntities. Classified: detected, with any live reveal. Identified: the track has reached +// TargetGearInfoThreshold. Bearing is the true bearing while tracked, else the freshest reveal's. +public readonly struct Contact +{ + public readonly bool Detected; + public readonly bool Tracked; + public readonly bool Classified; + public readonly bool Identified; + public readonly float2 Bearing; + public readonly float Info; + + public Contact(bool detected, bool tracked, bool classified, bool identified, float2 bearing, float info) + { + Detected = detected; + Tracked = tracked; + Classified = classified; + Identified = identified; + Bearing = bearing; + Info = info; + } +} diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 9c4bd459f..a983b97f3 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -213,9 +213,12 @@ public class GameplaySettings public ExponentialLerp LockSpinSpeed; public float TorqueMultiplier; public float VisibilityDecay; + // Seconds a reveal's bearing keeps a ship Detected after its last act with no track (Entity.ContactOf). + public float BearingMemory = 6; public float TargetInfoDecay; public float TargetDetectionInfoThreshold; public float TargetArmorInfoThreshold; + // Also the Identified threshold: the track level at which a ship's hull class, and with it its layout, is known. public float TargetGearInfoThreshold; public float ConvergenceMinimumDistance; public float FiringArc = 120; From e0758fdf1c682bf04998466d62ae1ead73d3b2c9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 18:26:00 +0200 Subject: [PATCH 02/17] ContactRevealTests: reveals by act, by paint, contact levels, lifetime and Sense allocation Does not build or pass yet unverified: first verify run follows. Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 528 ++++++++++++++++++ 1 file changed, 528 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/ContactRevealTests.cs diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs new file mode 100644 index 000000000..582907810 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -0,0 +1,528 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Visibility cut 3 (rulings visibility-per-capability-reveal, quiet-gear-identifiable-hull-layout-known, +// vapour-cloud-obscuration, ally-echo-bistatic-oval): an observer records what each ship's items have shown it, item +// by item with a bearing, and derives the contact's level from its track and those reveals. Every fixture drives +// the production path: real ships with real guns, thrusters, radiators and launchers, ticking inside Zone.Update. +// The numbers a rule is a function of are authored here and the expectations are their arithmetic. The observer's +// one passive sensor has sensitivity 2 and a flat curve, so a signature of strength S at planar distance d settles +// its info at S x 2 / (d x TargetInfoDecay), and is revealed exactly when that exceeds TargetDetectionInfoThreshold +// (.1). Signatures decay at once (VisibilityDecay 100), so an act is a single tick's light and builds no track. +// Subjects are added before the observer: they update first, so the observer senses the light on the tick it is made. +public sealed class ContactRevealTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-contact-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + private CultCache _openCache; + private ItemManager _items; + private Zone _zone; + private CultRecordRef _maker; + private int _nextLot = 1000; + + public ContactRevealTests() + { + Directory.CreateDirectory(_root); + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" }); + var solid = new Shape(5, 5); + foreach (var cell in solid.AllCoordinates) solid[cell] = true; + cache.Upsert(new HullData + { + Name = "Rack", HullType = HullType.Ship, Shape = solid, Durability = 1000000, Mass = 1000, + Hardpoints = + { + new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() }, + new HardpointData { Type = HardpointType.Sensors, Position = new int2(1, 0), Shape = new Shape() }, + new HardpointData { Type = HardpointType.Sensors, Position = new int2(2, 0), Shape = new Shape() } + } + }); + cache.Upsert(Eye("Eye", sensitivity: 2f)); + cache.Upsert(Eye("Ear", sensitivity: 4f)); + cache.Upsert(Tool("Reactor", new ReactorData { Charge = Const(1000), Efficiency = Const(1), OverloadEfficiency = Const(1), ThrottlingFactor = Const(2) })); + cache.Upsert(Tool("Targeting", new TargetingSystemData { Accuracy = Const(1), Resolution = Const(1000f), Precision = Const(1000f), Tracking = Const(1000000f) })); + cache.Upsert(Tool("Thruster", new ThrusterData { Thrust = Const(100), Visibility = Const(10), Heat = Const(0), EnergyUsage = Const(10) })); + cache.Upsert(Tool("Radiator", new RadiatorData + { + Emissivity = Const(3), PumpedHeat = Const(0), TemperatureFloor = 0, WasteHeat = Const(0), EnergyUsage = Const(0), ThermalMass = Const(1000) + })); + var gun = new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(1) }; + gun.Damage = Const(100); + gun.Range = Const(40); + gun.MinRange = Const(0); + gun.Velocity = Const(0); + gun.Spread = Const(0); + gun.DamageSpread = Const(0); + gun.Penetration = Const(0); + gun.Energy = Const(50); + gun.Heat = Const(0); + gun.Visibility = Const(5); + gun.MagazineSize = 5; + gun.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; + cache.Upsert(new WeaponItemData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = WeaponFuse.Proximity, BlastRadius = 30f, + Behaviors = { gun } + }); + cache.Upsert(new GearData + { + Name = "Locker", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = + { + new LauncherData + { + Damage = Const(0), Range = Const(1000), MinRange = Const(0), Velocity = Const(0), + Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1), + BurstTime = Const(0), Cooldown = Const(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, + LockSpeed = Const(.6f), SensorImpact = Const(1), LockAngle = Const(30), DirectionImpact = Const(1), Decay = Const(.1f) + } + } + }); + cache.Upsert(new ConsumableItemData + { + Name = "Pinger", Duration = 30f, Shape = new Shape(), + Behaviors = { SensorBehavior(sensitivity: 2f) } + }); + cache.FlushAsync().Wait(); + } + + _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); + var ledger = new ProvenanceLedger(); + for (var lot = 1000; lot < 2000; lot++) ledger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 }; + _items = new ItemManager(_openCache, ledger, new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + TargetInfoDecay = .5f, + VisibilityDecay = 100f, + BearingMemory = 2f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 2f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 1000f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f, + HeatRadiationMultiplier = 0f, // a hull radiates nothing unless a test says so + HeatRadiationExponent = 1f + }, _ => { }); + _maker = _openCache.RefOf(_openCache.GetByName("Maker")); + _zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + } + + public void Dispose() + { + _openCache?.Dispose(); + Directory.Delete(_root, true); + } + + private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v }; + + private static SensorData SensorBehavior(float sensitivity) => new SensorData + { + Sensitivity = Const(sensitivity), + SensitivityCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, // the same gain at every bearing + PingBoost = Const(.0001f), PingEnergy = Const(0), PingVisibility = Const(5), PingRange = Const(300), PingCooldown = Const(4) + }; + + private static GearData Eye(string name, float sensitivity) => new GearData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { SensorBehavior(sensitivity) } + }; + + private static GearData Tool(string name, BehaviorData behavior) => new GearData + { + Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { behavior } + }; + + private EquippableItem Make(string name) => new EquippableItem + { + Data = _items.ItemData.RefOf(_items.ItemData.GetByName(name)), Durability = 10, Lot = _nextLot++ + }; + + // A ship at `position` with `mounts` on its sensor hardpoints in order, the tools on free cells and a thruster + // on the centre column. A ship with a gun also gets the reactor and targeting a gun needs to fire. + private Ship Add(float3 position, string[] mounts = null, string[] tools = null, bool thruster = false) + { + var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Rack")); + var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = _nextLot++ }, new EntitySettings()) + { Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; + var cell = 0; + foreach (var mount in mounts ?? new string[0]) Assert.True(ship.TryEquip(Make(mount), new int2(cell++, 0))); + if (thruster) Assert.True(ship.TryEquip(Make("Thruster"), new int2(2, 1))); + foreach (var name in (tools ?? new string[0]).Concat(mounts != null && mounts.Contains("Gun") ? new[] { "Targeting", "Reactor" } : new string[0])) + { + var item = Make(name); + Assert.True(ship.TryFindSpace(item, out var at)); + Assert.True(ship.TryEquip(item, at)); + } + _zone.Entities.Add(ship); + ship.Activate(); + ship.Position = position; + ship.Aim = float3(0, 0, 1); + _zone.Update(0f); // brings the gear online + return ship; + } + + private void Tick(float dt) => _zone.Update(dt); + + private static EquippedItem[] Guns(Entity e) => e.Equipment.Where(i => i.Behaviors.Any(b => b is InstantWeapon && !(b is LockWeapon))).ToArray(); + + // Fires the given gun and ticks until its light shows; returns the zone clock of that tick. + private float Fire(Ship ship, EquippedItem item) + { + var gun = (InstantWeapon) item.Behaviors.Single(b => b is InstantWeapon); + var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun); + capacitor.AddCharge(1000f); + ship.SetTarget(TargetRef.None); + gun.Activate(); + for (var i = 0; i < 10; i++) + { + Tick(.1f); + if (ship.VisibilitySources.ContainsKey(gun)) return _zone.Time; + } + throw new Exception("fixture: the gun fires"); + } + + private static float Track(Entity observer, Entity subject) => + observer.EntityInfoGathered.TryGetValue(subject, out var info) ? info : 0f; + + private static void AssertBearing(float x, float y, float2 bearing) + { + Assert.Equal(x, bearing.x, 4); + Assert.Equal(y, bearing.y, 4); + } + + private static VapourCloud Hand(Zone zone, float2 at, float radius, float opacity) + { + var cloud = new VapourCloud { Body = new KinematicBody { Position = at }, Radius = radius, Opacity = opacity, Lifetime = 1000f }; + zone.Vent(cloud); + return cloud; + } + + // ---- AFiredGunIsRevealedWithItsBearing ---- + + [Fact] + public void AFiredGunIsRevealedWithItsBearing() + { + var e = Add(float3(30, 0, 40), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Assert.Empty(o.RevealsOf(e)); // nothing shown yet + + var at = Fire(e, guns[0]); + + var reveal = Assert.Single(o.RevealsOf(e)); + Assert.Same(guns[0], reveal.Item); + Assert.Equal(SignatureChannel.LaunchFlash, reveal.Channel); + AssertBearing(.6f, .8f, reveal.Bearing); // normalize((E - O).xz) + Assert.Equal(at, reveal.Time); + Assert.DoesNotContain(o.RevealsOf(e), r => r.Item == guns[1]); // the unfired second gun + Assert.Empty(e.RevealsOf(o)); // a ship is not told what it shows + } + + // ---- AnActAboveTheReachRevealsNothing ---- + + // Info settles at S x 2 / (d k): with S = 5 the act is revealed inside d = 100 / k and not beyond it, whatever k is. + [Theory] + [InlineData(.5f, 190f, true)] + [InlineData(.5f, 210f, false)] + [InlineData(.25f, 390f, true)] + [InlineData(.25f, 410f, false)] + public void AnActAboveTheReachRevealsNothing(float infoDecay, float distance, bool revealed) + { + _items.GameplaySettings.TargetInfoDecay = infoDecay; + var e = Add(float3(0, 0, distance), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + + Fire(e, Guns(e)[0]); + + Assert.Equal(revealed ? 1 : 0, o.RevealsOf(e).Count()); + } + + // ---- AFogBankHidesTheAct ---- + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AFogBankHidesTheAct(bool fog) + { + var e = Add(float3(0, 0, 150), new[] { "Gun" }); // 5 x 2 / (150 x .5) = .133 clear + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + if (fog) Hand(_zone, float2(0, 75), 5f, .6f); // a bank on the sight line passes .4 of the light: .053 + + Fire(e, Guns(e)[0]); + + Assert.Equal(fog ? 0 : 1, o.RevealsOf(e).Count()); + } + + // ---- ABurnRevealsTheDriveAndOpenRadiatorsRevealThemselves ---- + + [Fact] + public void ABurnRevealsTheDriveAndOpenRadiatorsRevealThemselves() + { + // The hull radiates .25 a border cell at any temperature; the radiator adds .25 x 3. + _items.GameplaySettings.HeatRadiationMultiplier = .25f; + _items.GameplaySettings.HeatRadiationExponent = 0f; + var burner = Add(float3(0, 0, 100), tools: new[] { "Reactor", "Targeting" }, thruster: true); // 10 x 4 / 100 = .4 + var glow = Add(float3(20, 0, 0), tools: new[] { "Reactor", "Radiator" }); // .75 x 4 / 20 = .15 + var bare = Add(float3(0, 0, -10)); // the hull alone, brighter than any act + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var thrusterItem = burner.Equipment.Single(i => i.Behaviors.Any(b => b is Thruster)); + thrusterItem.UpdatePerformance(); + + burner.MovementDirection = float2(0, -1); + Tick(.1f); + + var plume = Assert.Single(o.RevealsOf(burner)); + Assert.Same(thrusterItem, plume.Item); + Assert.Equal(SignatureChannel.DrivePlume, plume.Channel); + var radiator = Assert.Single(o.RevealsOf(glow)); + Assert.Same(glow.Equipment.Single(i => i.Behaviors.Any(b => b is Radiator)), radiator.Item); + Assert.Equal(SignatureChannel.Thermal, radiator.Channel); + Assert.True(bare.Visibility > 1f, "fixture: the bare hull is lit"); + Assert.Empty(o.RevealsOf(bare)); + } + + // ---- APaintRevealsTheEmitterEvenUnseen ---- + + [Fact] + public void APaintRevealsTheEmitterEvenUnseen() + { + var locker = Add(float3(0, 0, 200), new[] { "Locker" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + locker.SetIff(o, true); + locker.EntityInfoGathered[o] = 1f; + locker.SetTarget(o); + locker.Aim = normalize(o.Position - locker.Position); + + Tick(.5f); + Tick(.5f); + + Assert.Single(o.IncomingPaints); // fixture: the lock painted it + Assert.Equal(0f, Track(o, locker)); // unseen + var reveal = Assert.Single(o.RevealsOf(locker)); + Assert.Same(locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)), reveal.Item); + Assert.Equal(SignatureChannel.Emission, reveal.Channel); + AssertBearing(0f, 1f, reveal.Bearing); + Assert.False(o.ContactOf(locker).Tracked); + } + + [Fact] + public void AConsumableBorneEmitterRecordsNothing() + { + var p = Add(float3(0, 0, 100)); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var data = _openCache.GetByName("Pinger"); + p.ActivateConsumable(Assert.IsType(_items.CreateInstance(_items.CreateLot(data, _maker, .5f)))); + var sensor = (Sensor) p.FindActiveConsumable(data).Behaviors.Single(b => b is Sensor); + Tick(.01f); + sensor.Ping(); + + var paints = 0; + for (var tick = 0; tick < 30; tick++) + { + Tick(.1f); + paints += o.IncomingPaints.Count; + } + + Assert.Equal(1, paints); // fixture: the sweep painted the observer + Assert.Empty(o.RevealsOf(p)); + } + + // ---- ALaunchGivesABearingNotATrack ---- + + [Fact] + public void AnActGivesABearingNotATrack() + { + var e = Add(float3(0, 0, 150), new[] { "Gun" }); // 5 x 2 / (150 x .5) = .133: revealed, but one tick of light builds no track + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var gun = Guns(e)[0]; + var at = Fire(e, gun); + e.Position = float3(150, 0, 0); // it slips away: the reveal keeps the bearing of the act + + var seen = o.ContactOf(e); + Assert.True(Track(o, e) < .1f, "fixture: no track"); + Assert.True(seen.Detected); + Assert.True(seen.Classified); + Assert.False(seen.Tracked); + Assert.False(seen.Identified); + AssertBearing(0f, 1f, seen.Bearing); + + // Younger than BearingMemory (2 s): still Detected. Older: not, and the gun is still known. + while (_zone.Time - at < 1.5f) Tick(.1f); + Assert.True(o.ContactOf(e).Detected); + while (_zone.Time - at < 2.5f) Tick(.1f); + var later = o.ContactOf(e); + Assert.False(later.Detected); + Assert.False(later.Classified); + Assert.False(later.Tracked); + Assert.Same(gun, Assert.Single(o.RevealsOf(e)).Item); + } + + [Theory] + [InlineData(1f)] + [InlineData(5f)] + public void TheBearingMemoryIsAGameplaySetting(float memory) + { + _items.GameplaySettings.BearingMemory = memory; + var e = Add(float3(0, 0, 150), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var at = Fire(e, Guns(e)[0]); + + while (_zone.Time - at < memory - .3f) Tick(.1f); + Assert.True(o.ContactOf(e).Detected); + while (_zone.Time - at < memory + .3f) Tick(.1f); + Assert.False(o.ContactOf(e).Detected); + } + + // ---- ContactLevels ---- + + // E fires from (30, 40) and moves to (-50, 0). Tracked is exactly membership of VisibleEntities, Identified + // exactly a track at or above TargetGearInfoThreshold; the bearing is the true one while tracked and the act's + // while the ship is only remembered. + [Theory] + [InlineData(.05f, false, false)] + [InlineData(.1f, false, false)] + [InlineData(.5f, true, false)] + [InlineData(.79f, true, false)] + [InlineData(.8f, true, true)] + [InlineData(1f, true, true)] + public void ContactLevels(float info, bool tracked, bool identified) + { + var e = Add(float3(30, 0, 40), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + Fire(e, Guns(e)[0]); + e.Position = float3(-50, 0, 0); + + o.EntityInfoGathered[e] = info; + + var contact = o.ContactOf(e); + Assert.Equal(o.VisibleEntities.Contains(e), contact.Tracked); + Assert.Equal(tracked, contact.Tracked); + Assert.Equal(identified, contact.Identified); + Assert.True(contact.Detected); + Assert.True(contact.Classified); + Assert.Equal(info, contact.Info); + if (tracked) AssertBearing(-1f, 0f, contact.Bearing); + else AssertBearing(.6f, .8f, contact.Bearing); + } + + [Fact] + public void AShipNothingHasShownIsNotDetected() + { + var e = Add(float3(0, 0, 150)); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + + var contact = o.ContactOf(e); + + Assert.False(contact.Detected); + Assert.False(contact.Tracked); + Assert.False(contact.Classified); + Assert.False(contact.Identified); + } + + // ---- RevealsLeaveWithTheSubjectAndTheObserver ---- + + [Fact] + public void RevealsLeaveWithTheSubject() + { + var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + Assert.Equal(2, o.RevealsOf(e).Count()); + + guns[0].EquippableItem.Durability = 0f; // destroyed + + Assert.Same(guns[1], Assert.Single(o.RevealsOf(e)).Item); + + _zone.Entities.Remove(e); + _zone.Entities.Add(e); + + Assert.Empty(o.RevealsOf(e)); // gone with the subject, and not back with it + } + + [Fact] + public void RevealsLeaveWithTheObserver() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + Fire(e, Guns(e)[0]); + Assert.Single(o.RevealsOf(e)); + + o.Deactivate(); + + Assert.Empty(o.RevealsOf(e)); + } + + // ---- The one rule for info per signature ---- + + [Fact] + public void PassiveInfoPerSignatureAveragesTheActiveSensors() + { + var one = Add(float3(0, 0, 0), new[] { "Eye" }); + var two = Add(float3(500, 0, 0), new[] { "Eye", "Ear" }); + var none = Add(float3(1000, 0, 0)); + + // sum(s) / (d n k): 2 / (100 x .5) with one sensor, (2 + 4) / (100 x 2 x .5) with two. + Assert.Equal(.04f, one.PassiveInfoPerSignature(float2(0, 100)), 5); + Assert.Equal(.06f, two.PassiveInfoPerSignature(float2(500, 100)), 5); + Assert.Equal(0f, none.PassiveInfoPerSignature(float2(1000, 100))); + } + + // ---- SenseCostIsBounded ---- + + [Fact] + public void SenseAllocatesNothingOnceWarm() + { + var gunItems = new List(); + for (var i = 0; i < 19; i++) + { + var ship = Add(float3(i * 3, 0, 10 + i), new[] { "Gun" }); + var item = Guns(ship)[0]; + for (var s = 0; s < 10; s++) ship.SetSignature(new object(), SignatureChannel.Reflection, 1000f, item); + gunItems.Add(item); + } + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var sense = (Action) Delegate.CreateDelegate(typeof(Action), o, typeof(Entity).GetMethod("Sense", BindingFlags.Instance | BindingFlags.NonPublic)); + for (var i = 0; i < 10; i++) sense(); + Assert.Equal(19, _zone.Entities.Where(x => x != o).Count(x => o.RevealsOf(x).Any())); // fixture: every ship is revealed + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 600; i++) sense(); + var after = GC.GetAllocatedBytesForCurrentThread(); + + Assert.Equal(0L, after - before); + } +} From dcc6f0a7d2de0017aaf8c82f48f6e1f589daf1a7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 18:31:13 +0200 Subject: [PATCH 03/17] ContactRevealTests: a null-item signature from a non-hull source reveals nothing Unverified; the running verify job predates it. Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 582907810..1700e6b2d 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -359,6 +359,25 @@ public sealed class ContactRevealTests : IDisposable Assert.Empty(o.RevealsOf(p)); } + // Ruling consumable-signature-null-until-reveal: a consumable-hosted signature names no item (Item null, as + // hull radiation does), so however bright and near it reveals nothing; the reveal cut leaves what it names to + // follow-up reveal-names-consumable-signature. + [Fact] + public void ANullItemSignatureFromASourceThatIsNotTheHullRevealsNothing() + { + var e = Add(float3(0, 0, 20)); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var data = _openCache.GetByName("Pinger"); + e.ActivateConsumable(Assert.IsType(_items.CreateInstance(_items.CreateLot(data, _maker, .5f)))); + var source = e.FindActiveConsumable(data).Behaviors.Single(); + e.SetSignature(source, SignatureChannel.Reflection, 1000f, null); + + Tick(.01f); + + Assert.True(e.VisibilitySources.ContainsKey(source), "fixture: the consumable's light is on"); + Assert.Empty(o.RevealsOf(e)); + } + // ---- ALaunchGivesABearingNotATrack ---- [Fact] From b32729de22ef2461ff673853027340343647fe7b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 18:41:21 +0200 Subject: [PATCH 04/17] ContactRevealTests: an Emission entry is not light; the freshest reveal gives the bearing Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 1700e6b2d..7ed3f962d 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -378,6 +378,38 @@ public sealed class ContactRevealTests : IDisposable Assert.Empty(o.RevealsOf(e)); } + // An Emission entry is never light (R1): a bright, near one reveals nothing through Sense. Emission reaches the + // book only through a published paint. + [Fact] + public void AnEmissionEntryIsNotLight() + { + var e = Add(float3(0, 0, 20), new[] { "Eye" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + e.SetSignature(new object(), SignatureChannel.Emission, 1000f, e.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor))); + + Tick(.01f); + + Assert.Empty(o.RevealsOf(e)); + } + + // Two items revealed at different times: the contact's bearing, while untracked, is the freshest reveal's. + [Fact] + public void TheFreshestRevealGivesTheBearing() + { + var e = Add(float3(30, 0, 40), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Fire(e, guns[0]); + e.Position = float3(-50, 0, 0); + Fire(e, guns[1]); + + var contact = o.ContactOf(e); + + Assert.Equal(2, o.RevealsOf(e).Count()); + Assert.False(contact.Tracked); + AssertBearing(-1f, 0f, contact.Bearing); + } + // ---- ALaunchGivesABearingNotATrack ---- [Fact] From b3c003dc3e3b2206a37b87c45bcc2d1846965a94 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 20:34:58 +0200 Subject: [PATCH 05/17] Contact reads only the observer's book; a stale paint never recreates a reveal; Sense runs after every update; tests for the unpinned rules - RevealsOf returns the book and nothing the subject's gear or Durability says (ruling contact-reads-book-aim-reads-live); FireControl.IsRevealed stays the live aiming read. - PublishInbox records a paint's reveal only for an emitter still in the zone. - Sense moves from Entity.Update to a Zone.Update phase after all entities have updated, so reveals do not depend on list order. - ChunkInfo calls PassiveInfo(position, brightness) in the original multiplication order (PassiveInfoPerSignature becomes PassiveInfo with the signature argument). - Tests: unseen destruction, ghost paint, Search paint reveal, no self-sense, quiet tracked ship levels, list-order independence. Not built or run locally; verified on Yggdrasil after the next commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 46 ++--- Assets/Scripts/ServerShared/Zone.cs | 1 + .../ContactRevealTests.cs | 170 ++++++++++++++++-- 3 files changed, 184 insertions(+), 33 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index e9c8fef96..7fb5d4174 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -414,27 +414,27 @@ public abstract class Entity // Mining Cut 3 (Q14 A): this entity's info on a chunk, computed when asked and stored nowhere. It is the value // the per-tick sensor rule (Sensor.Execute, through Sensor.Gain) would settle at for a target this bright, - // here, now (PassiveInfoPerSignature, scaled by the brightness), capped at 1. + // here, now (PassiveInfo at the chunk's brightness), capped at 1. public float ChunkInfo(ChunkId chunk) { var visibility = Zone.ChunkVisibility(chunk); if (visibility <= 0f) return 0f; - return saturate(visibility * PassiveInfoPerSignature(Zone.ChunkPose(chunk.Field, chunk.Index).xy)); + return saturate(PassiveInfo(Zone.ChunkPose(chunk.Field, chunk.Index).xy, visibility)); } - // The info a target of unit signature at `position` would settle at under this entity's passive sensing, before + // The info a target of `signature` at `position` would settle at under this entity's passive sensing, before // the cap at 1. Each active sensor applies x <- saturate(x + G_i dt)(1 - k dt) every tick, so with n sensors the // info settles where the gain balances the decay: sum(G_i) / (n k). A ping adds nothing that lasts, so only the - // passive gain enters. It is linear in the signature below saturation, which is what lets one value answer for - // every signature at a place: ChunkInfo scales it by a chunk's brightness, Sense by each act's strength. Zero + // passive gain enters. ChunkInfo asks it for a chunk's brightness; Sense asks it for a unit signature once per + // ship, because it is linear in the signature below saturation and so answers for every act at a place. Zero // with no active sensor. - public float PassiveInfoPerSignature(float2 position) + public float PassiveInfo(float2 position, float signature) { var rate = 0f; var sensors = 0; foreach (var sensor in ActiveSensors()) { - rate += sensor.PassiveRate(1f, position); + rate += sensor.PassiveRate(signature, position); sensors++; } return sensors == 0 ? 0f : rate / (sensors * ItemManager.GameplaySettings.TargetInfoDecay); @@ -865,7 +865,9 @@ public abstract class Entity foreach (var paint in _paintInbox) { _incomingPaints.Add(paint.Warning); - RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission); + // An emitter that left the zone (died, warped, docked) since it painted is no longer a subject: its + // warning still lasts its tick, but it is not recorded as a contact. + if (Zone.Entities.Contains(paint.Emitter)) RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission); } _paintInbox.Clear(); _pingReturns.Clear(); @@ -899,15 +901,12 @@ public abstract class Entity book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time); } - // What this observer has seen `subject`'s items do: each item's latest act, filtered on read to items still on - // the subject and not destroyed (Durability >= .01, as FireControl ranks gear). - public IEnumerable RevealsOf(Entity subject) - { - if (!_reveals.TryGetValue(subject, out var book)) yield break; - foreach (var reveal in book.Values) - if (reveal.Item.Entity == subject && subject.Equipment.Contains(reveal.Item) && reveal.Item.EquippableItem.Durability >= .01f) - yield return reveal; - } + // 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): an item destroyed out of this observer's + // sight stays revealed until the subject leaves the zone or this observer deactivates. Whether an item can be + // aimed at now is FireControl.IsRevealed's question, not this one. + public IEnumerable RevealsOf(Entity subject) => + _reveals.TryGetValue(subject, out var book) ? book.Values : Enumerable.Empty(); // The one derivation of how much this observer holds of `subject`: see Contact. public Contact ContactOf(Entity subject) @@ -929,12 +928,15 @@ public abstract class Entity return new Contact(detected, tracked, detected && any, info >= settings.TargetGearInfoThreshold, bearing, info); } - // What this observer's instruments receive of other ships' acts this tick (Update calls it once, after the - // equipment loop): every light signature of an item that would alone settle this observer's passive info above + // What this observer's instruments receive of other ships' acts this tick (Zone.Update calls it once, after every + // entity has updated): every light signature of an item that would alone settle this observer's passive info above // TargetDetectionInfoThreshold is revealed (ruling visibility-per-capability-reveal). Hull radiation names no - // item and only feeds the track; an Emission entry is never light. One PassiveInfoPerSignature per ship. + // item and only feeds the track; an Emission entry is never light. One PassiveInfo per ship. Zone.Update calls it + // for every entity after all of them have updated, so what a ship shows is what every observer receives on that + // tick whatever order the zone lists them in. internal void Sense() { + if (!_active) return; var threshold = ItemManager.GameplaySettings.TargetDetectionInfoThreshold; var entities = Zone.Entities; for (var i = 0; i < entities.Count; i++) @@ -945,7 +947,7 @@ public abstract class Entity foreach (var signature in other._visibilitySources.Values) { if (signature.Item == null || signature.Channel == SignatureChannel.Emission) continue; - if (perSignature < 0f) perSignature = PassiveInfoPerSignature(other.Position.xz); + if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f); if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel); } } @@ -1655,8 +1657,6 @@ public abstract class Entity equippedItem.Update(delta); } - Sense(); - foreach (var message in Messages.Keys.ToArray()) { Messages[message] = Messages[message] - delta; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 4aa2063cc..6ce39a3c3 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -229,6 +229,7 @@ public class Zone var entities = Entities.ToArray(); foreach (var entity in entities) entity.PublishInbox(); foreach (var entity in entities) entity.Update(deltaTime); + foreach (var entity in entities) entity.Sense(); StepMines(deltaTime); StepClouds(deltaTime); diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 7ed3f962d..d2fced007 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -23,7 +23,7 @@ using int2 = CultMath.int2; // one passive sensor has sensitivity 2 and a flat curve, so a signature of strength S at planar distance d settles // its info at S x 2 / (d x TargetInfoDecay), and is revealed exactly when that exceeds TargetDetectionInfoThreshold // (.1). Signatures decay at once (VisibilityDecay 100), so an act is a single tick's light and builds no track. -// Subjects are added before the observer: they update first, so the observer senses the light on the tick it is made. +// The observer senses after every ship has updated, so the zone's list order never decides when an act is revealed. public sealed class ContactRevealTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-contact-" + Guid.NewGuid().ToString("N")); @@ -514,14 +514,163 @@ public sealed class ContactRevealTests : IDisposable Fire(e, guns[1]); Assert.Equal(2, o.RevealsOf(e).Count()); - guns[0].EquippableItem.Durability = 0f; // destroyed - - Assert.Same(guns[1], Assert.Single(o.RevealsOf(e)).Item); - _zone.Entities.Remove(e); _zone.Entities.Add(e); Assert.Empty(o.RevealsOf(e)); // gone with the subject, and not back with it + Assert.Equal(0, RevealBookSubjects(o)); // and the book no longer holds the ship at all + } + + private static int RevealBookSubjects(Entity observer) => + ((System.Collections.IDictionary) typeof(Entity).GetField("_reveals", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(observer)).Count; + + // Ruling contact-reads-book-aim-reads-live: what an observer knows of a ship comes from its own book. A gun + // destroyed by a third party out of its sight changes none of its levels and stays in its reveal list, while + // the aiming read, which asks about the item now, refuses the dead gun and still serves the live one. + [Fact] + public void GearDestroyedOutOfSightLeavesWhatTheObserverKnowsAlone() + { + var e = Add(float3(0, 0, 150), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + var before = o.ContactOf(e); + Assert.True(before.Detected && before.Classified && !before.Tracked, "fixture: a bearing and a class, no track"); + o.EntityInfoGathered[e] = 1f; // the aiming read needs info to resolve the target's gear + Assert.True(FireControl.IsRevealed(o, guns[0]) && FireControl.IsRevealed(o, guns[1]), "fixture: both guns are aimable"); + + guns[0].EquippableItem.Durability = 0f; // destroyed by someone else; o saw nothing + + Assert.Equal(2, o.RevealsOf(e).Count()); + var after = o.ContactOf(e); + Assert.True(after.Detected); + Assert.True(after.Classified); + Assert.Equal(before.Bearing.x, after.Bearing.x); + Assert.Equal(before.Bearing.y, after.Bearing.y); + Assert.False(FireControl.IsRevealed(o, guns[0])); + Assert.True(FireControl.IsRevealed(o, guns[1])); + + guns[1].EquippableItem.Durability = 0f; // the last of its gear dies unseen too + + Assert.Equal(2, o.RevealsOf(e).Count()); + Assert.True(o.ContactOf(e).Detected); + Assert.True(o.ContactOf(e).Classified); + } + + // A paint inboxed in the tick its emitter leaves the zone is published the next tick, and is no longer the + // act of a subject in the zone: it warns for its tick and records no reveal, so the observer holds no ghost. + [Fact] + public void APaintFromAShipThatHasLeftTheZoneRevealsNothing() + { + var locker = Add(float3(0, 0, 200), new[] { "Locker" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + locker.SetIff(o, true); + locker.EntityInfoGathered[o] = 1f; + locker.SetTarget(o); + locker.Aim = normalize(o.Position - locker.Position); + for (var tick = 0; tick < 10 && !o.RevealsOf(locker).Any(); tick++) Tick(.5f); + Assert.Single(o.RevealsOf(locker)); // fixture: the lock paints every tick, and the first one is recorded + + _zone.Entities.Remove(locker); // dies, warps or docks while its latest paint is still in o's inbox + Assert.Empty(o.RevealsOf(locker)); + Tick(.1f); + + Assert.Single(o.IncomingPaints); // fixture: the stale paint was published + Assert.Empty(o.RevealsOf(locker)); + Assert.False(o.ContactOf(locker).Detected); + Assert.Equal(0, RevealBookSubjects(o)); + } + + // Ruling sensor-stat-set 4: a painted target always learns the emitter's class, whichever the mode. A ping + // crossing the observer (a Search paint) reveals the pinger's sensor item, with channel Emission and the + // bearing of the pinger. + [Fact] + public void ASearchPaintRevealsThePingersSensor() + { + var p = Add(float3(0, 0, 100), new[] { "Eye" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var item = p.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor)); + var sensor = (Sensor) item.Behaviors.Single(b => b is Sensor); + Tick(.01f); + sensor.Ping(); + + var modes = new List(); + for (var tick = 0; tick < 30; tick++) + { + Tick(.1f); + modes.AddRange(o.IncomingPaints.Select(paint => paint.Mode)); + } + + Assert.Equal(new[] { PaintMode.Search }, modes); // fixture: the sweep painted o once, as a search + var reveal = Assert.Single(o.RevealsOf(p)); + Assert.Same(item, reveal.Item); + Assert.Equal(SignatureChannel.Emission, reveal.Channel); + AssertBearing(0f, 1f, reveal.Bearing); + } + + // 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] + public void AnObserverNeverRecordsItsOwnGear() + { + var self = Add(float3(0, 0, 0), new[] { "Gun", "Eye" }); + var neighbour = Add(float3(0, 0, 30), new[] { "Eye" }); + + Fire(self, Guns(self)[0]); + + Assert.Single(neighbour.RevealsOf(self)); // fixture: the gun's light reaches a sensor + Assert.Empty(self.RevealsOf(self)); + Assert.False(self.ContactOf(self).Detected); + } + + // The zone's list order is not a rule of the game: two observers equidistant from a gun, one listed before the + // gun's ship and one after, record the same reveal with the same bearing and the same clock. + [Fact] + public void AnActIsRevealedOnItsOwnTickWhateverTheListOrder() + { + var before = Add(float3(-30, 0, 40), new[] { "Eye" }); + var e = Add(float3(30, 0, 40), new[] { "Gun" }); + var after = Add(float3(0, 0, 0), new[] { "Eye" }); + + var at = Fire(e, Guns(e)[0]); + + foreach (var observer in new Entity[] { before, after }) + { + var reveal = Assert.Single(observer.RevealsOf(e)); + Assert.Equal(at, reveal.Time); + var toward = normalize((e.Position - observer.Position).xz); + AssertBearing(toward.x, toward.y, reveal.Bearing); + } + } + + // ---- A quiet ship ---- + + // Ruling quiet-gear-identifiable-hull-layout-known: a ship that has shown nothing is Detected while it is + // tracked and Identified once the track reaches the gear threshold, with no act in any book; with no reveal + // it is not Classified. + [Theory] + [InlineData(.05f, false, false)] + [InlineData(.5f, true, false)] + [InlineData(.79f, true, false)] + [InlineData(.8f, true, true)] + [InlineData(1f, true, true)] + public void AQuietTrackedShipIsDetectedAndIdentifiedByTheTrackAlone(float info, bool tracked, bool identified) + { + var e = Add(float3(30, 0, 40), new[] { "Gun" }); // armed, but it never fires + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + + o.EntityInfoGathered[e] = info; + + Assert.Empty(o.RevealsOf(e)); + var contact = o.ContactOf(e); + Assert.Equal(tracked, o.VisibleEntities.Contains(e)); + Assert.Equal(tracked, contact.Tracked); + Assert.Equal(tracked, contact.Detected); + Assert.False(contact.Classified); + Assert.Equal(identified, contact.Identified); + Assert.Equal(info, contact.Info); + if (tracked) AssertBearing(.6f, .8f, contact.Bearing); } [Fact] @@ -540,16 +689,17 @@ public sealed class ContactRevealTests : IDisposable // ---- The one rule for info per signature ---- [Fact] - public void PassiveInfoPerSignatureAveragesTheActiveSensors() + public void PassiveInfoAveragesTheActiveSensors() { var one = Add(float3(0, 0, 0), new[] { "Eye" }); var two = Add(float3(500, 0, 0), new[] { "Eye", "Ear" }); var none = Add(float3(1000, 0, 0)); - // sum(s) / (d n k): 2 / (100 x .5) with one sensor, (2 + 4) / (100 x 2 x .5) with two. - Assert.Equal(.04f, one.PassiveInfoPerSignature(float2(0, 100)), 5); - Assert.Equal(.06f, two.PassiveInfoPerSignature(float2(500, 100)), 5); - Assert.Equal(0f, none.PassiveInfoPerSignature(float2(1000, 100))); + // sum(s) / (d n k): 2 / (100 x .5) with one sensor, (2 + 4) / (100 x 2 x .5) with two; linear in the signature. + Assert.Equal(.04f, one.PassiveInfo(float2(0, 100), 1f), 5); + Assert.Equal(.12f, one.PassiveInfo(float2(0, 100), 3f), 5); + Assert.Equal(.06f, two.PassiveInfo(float2(500, 100), 1f), 5); + Assert.Equal(0f, none.PassiveInfo(float2(1000, 100), 1f)); } // ---- SenseCostIsBounded ---- From c86f28e93f46125879642090bfa3455f4213708b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 20:34:59 +0200 Subject: [PATCH 06/17] MiningCut3Tests: ChunkInfo equals the bits recorded at base 12dfcef5 Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 289abb1bf..ea29e418b 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -313,6 +313,32 @@ public sealed partial class MiningCut3Tests : IDisposable $"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})"); } + // ChunkInfo's value is not changed by moving its arithmetic under PassiveInfo: six observers at different + // places and bearings, one with a second sensor so the average is live, read the same chunk, and every value + // matches the bits recorded at base 12dfcef5, before the extraction (a different order of the multiplication + // by the chunk's brightness moves them in the last bits). + private static readonly uint[] ChunkInfoAtBase = new uint[] { 1045120030u, 1039274086u, 1030543422u, 1029120970u, 1036363351u, 1035122917u }; + + [Fact] + public void ChunkInfoIsBitIdenticalToItsValueBeforeTheExtraction() + { + var kind = Kind("Asteroid", 1f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var places = new[] { float2(50, -150), float2(-80, -60), float2(10, 200), float2(-300, 40), float2(120, 120), float2(0, -400) }; + var values = new List(); + for (var i = 0; i < places.Length; i++) + { + var observer = SpawnShip(s, places[i], sensor: true, secondSensor: i == 2); + Tick((observer, places[i])); // brings the sensors online + values.Add(observer.ChunkInfo(chunk)); + } + + Assert.All(values, v => Assert.InRange(v, 1e-4f, .99f)); // a live, unsaturated reading at every place + var actual = values.Select(Bits).ToArray(); + Assert.True(actual.SequenceEqual(ChunkInfoAtBase), "ChunkInfo bits: " + string.Join(",", actual)); + } + // A chunk outside every sun's light is dark: no visibility, no info, and it cannot be picked, while a lit // chunk of the same belt and kind can. [Fact] From b6c2a2368b8c022b8988572eb4efe68681c0906e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 22:03:20 +0200 Subject: [PATCH 07/17] Reveal book: one gate, one removal counterpart; departure ends reveals on perception; departed subjects age out RecordReveal gates on zone membership and EndReveals is the only way out; the observer's Zone.Entities handler no longer wipes the book and PublishInbox no longer decides membership. Zone records each removal as a one-tick Departure that each observer's Sense judges under the light-act gate; a departed subject's entries are pruned once older than BearingMemory. Unverified build at this commit (first test run follows). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 69 ++++- Assets/Scripts/ServerShared/Sensing.cs | 19 +- Assets/Scripts/ServerShared/Zone.cs | 7 + .../ContactRevealTests.cs | 266 +++++++++++++++++- 4 files changed, 335 insertions(+), 26 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 7fb5d4174..80bce7a01 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -213,8 +213,11 @@ public abstract class Entity private readonly List _pingReturns = new List(); internal IReadOnlyList PingReturns => _pingReturns; // What this observer has seen each ship's items do (ruling visibility-per-capability-reveal), keyed (subject, - // item). Written only by Sense and PublishInbox through RecordReveal; memory of the sitting, never decayed. + // item). Written only by RecordReveal (an act enters) and EndReveals (an end the observer perceives, or a departed + // subject's entries aged past BearingMemory), cleared by Deactivate (ruling reveal-ends-on-perception). private readonly Dictionary> _reveals = new Dictionary>(); + private readonly List _endedItems = new List(); + private readonly List _agedSubjects = new List(); public ReactiveProperty CurrentSecurityLevel = new ReactiveProperty(SecurityLevel.Open); public ReadOnlyReactiveProperty PresencePermitted; @@ -265,7 +268,6 @@ public abstract class Entity { if (Target.Value.Entity == remove.Value) SetTarget(TargetRef.None); EntityInfoGathered.Remove(remove.Value); - _reveals.Remove(remove.Value); EntityHostility.Remove(remove.Value); VisibleEntities.Remove(remove.Value); VisibleEnemies.Remove(remove.Value); @@ -865,9 +867,7 @@ public abstract class Entity foreach (var paint in _paintInbox) { _incomingPaints.Add(paint.Warning); - // An emitter that left the zone (died, warped, docked) since it painted is no longer a subject: its - // warning still lasts its tick, but it is not recorded as a contact. - if (Zone.Entities.Contains(paint.Emitter)) RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission); + RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission); } _paintInbox.Clear(); _pingReturns.Clear(); @@ -890,21 +890,35 @@ public abstract class Entity _paintInbox.Add(new PendingPaint(new PaintWarning(normalize((emitter.Position - Position).xz), item?.Data, mode, strength), emitter, item)); } - // The one writer of the reveal book (callers: Sense and PublishInbox, both in this file): this observer saw - // `item` of `subject` act on `channel`, now, from this bearing. An item-less act (hull radiation, a consumable) - // reveals nothing. A later act of the same item replaces the earlier one. + // The reveal book's one way in and its one membership gate (callers: Sense and PublishInbox, both in this file): + // this observer saw `item` of `subject` act on `channel`, now, from this bearing. An item-less act (hull + // radiation, a consumable) reveals nothing, and neither does a subject that is not in the zone: a departed + // subject's entries are never created or refreshed, from any caller. A later act of the same item replaces the + // earlier one. internal void RecordReveal(Entity subject, EquippedItem item, SignatureChannel channel) { - if (!_active || item == null) return; + if (!_active || item == null || !Zone.Entities.Contains(subject)) return; if (!_reveals.TryGetValue(subject, out var book)) _reveals[subject] = book = new Dictionary(); book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time); } + // 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) + { + if (!_reveals.TryGetValue(subject, out var book)) return; + if (item != null) book.Remove(item); + else book.Clear(); + if (book.Count == 0) _reveals.Remove(subject); + } + // 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): an item destroyed out of this observer's - // sight stays revealed until the subject leaves the zone or this observer deactivates. Whether an item can be - // aimed at now is FireControl.IsRevealed's question, not this one. + // 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, + // dock or warp of a subject its instruments could see leave, or, for a subject that has left the zone, the + // freshest entry aging past BearingMemory; Deactivate clears it all. Whether an item can be aimed at now is + // FireControl.IsRevealed's question, not this one. public IEnumerable RevealsOf(Entity subject) => _reveals.TryGetValue(subject, out var book) ? book.Values : Enumerable.Empty(); @@ -950,7 +964,38 @@ public abstract class Entity if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f); if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel); } + // The observer resolves this ship's gear (ContactOf's own Identified gate), so it perceives an item of it + // destroyed. Ending after recording: a destroyed item's lingering flash cannot re-enter the book. + if (EntityInfoGathered.TryGetValue(other, out var info) && info >= ItemManager.GameplaySettings.TargetGearInfoThreshold + && _reveals.TryGetValue(other, out var book)) + { + _endedItems.Clear(); + foreach (var item in book.Keys) + if (item.EquippableItem.Durability < .01f) _endedItems.Add(item); + for (var j = 0; j < _endedItems.Count; j++) EndReveals(other, _endedItems[j]); + } } + // A subject that left the zone (death, dock, warp, any removal) is perceived gone under the light-act gate: as + // bright as its non-Emission signature was at removal, from where it was. A re-admitted subject did not leave. + var departures = Zone.Departures; + for (var i = 0; i < departures.Count; i++) + { + var departure = departures[i]; + if (!_reveals.ContainsKey(departure.Subject) || entities.Contains(departure.Subject)) continue; + if (departure.Signature * PassiveInfo(departure.Position, 1f) > threshold) EndReveals(departure.Subject, null); + } + // What stays of a departed subject is memory: pruned once its freshest entry is as old as ContactOf stops + // remembering it. + var memory = ItemManager.GameplaySettings.BearingMemory; + _agedSubjects.Clear(); + foreach (var pair in _reveals) + { + if (entities.Contains(pair.Key)) continue; + var freshest = float.MinValue; + foreach (var reveal in pair.Value.Values) freshest = Math.Max(freshest, reveal.Time); + if (Zone.Time - freshest >= memory) _agedSubjects.Add(pair.Key); + } + for (var i = 0; i < _agedSubjects.Count; i++) EndReveals(_agedSubjects[i], null); } public Entity(ItemManager itemManager, Zone zone, EquippableItem hull, EntitySettings settings) diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index 9c846a694..129513dda 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -93,9 +93,26 @@ public readonly struct Reveal } } +// A subject's removal from a zone as the zone recorded it (death, dock, warp, any removal): where it was and how +// bright its light was (its signature outside the Emission channel). Judged by each observer's Sense under the +// light-act gate; notification only, one tick long, it decides nothing. +public readonly struct Departure +{ + public readonly Entity Subject; + public readonly float2 Position; + public readonly float Signature; + + public Departure(Entity subject, float2 position, float signature) + { + Subject = subject; + Position = position; + Signature = signature; + } +} + // How much an observer holds of one subject, derived on read by Entity.ContactOf from its track, its reveals and // the zone clock. Detected: tracked, or a reveal younger than GameplaySettings.BearingMemory. Tracked: exactly -// membership of VisibleEntities. Classified: detected, with any live reveal. Identified: the track has reached +// membership of VisibleEntities. Classified: detected, with any reveal in the book. Identified: the track has reached // TargetGearInfoThreshold. Bearing is the true bearing while tracked, else the freshest reveal's. public readonly struct Contact { diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 6ce39a3c3..97554a9ad 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -72,6 +72,9 @@ public class Zone return false; } + private readonly List _departures = new List(); + internal IReadOnlyList Departures => _departures; + public float Time { get => (float) _time; @@ -109,6 +112,9 @@ public class Zone // every join, whichever caller admits the entity (deserialization below, a jump, a spawned turret) -- // ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone. Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); })); + // The one record of a removal for observers (ruling reveal-ends-on-perception): read by the next Sense pass. + Entities.ObserveRemove().Subscribe(remove => _departures.Add(new Departure(remove.Value, remove.Value.Position.xz, + remove.Value.Visibility - remove.Value.SignatureOn(SignatureChannel.Emission)))); Targets.Add(new EntityTargets()); @@ -230,6 +236,7 @@ public class Zone foreach (var entity in entities) entity.PublishInbox(); foreach (var entity in entities) entity.Update(deltaTime); foreach (var entity in entities) entity.Sense(); + _departures.Clear(); StepMines(deltaTime); StepClouds(deltaTime); diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index d2fced007..6099af42c 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -502,23 +502,173 @@ public sealed class ContactRevealTests : IDisposable Assert.False(contact.Identified); } - // ---- RevealsLeaveWithTheSubjectAndTheObserver ---- + // ---- A subject leaves ---- + private Action SenseOf(Entity observer) => + (Action) Delegate.CreateDelegate(typeof(Action), observer, typeof(Entity).GetMethod("Sense", BindingFlags.Instance | BindingFlags.NonPublic)); + + // The ways a ship leaves a zone: removed by anything, the warp path (Deactivate, then Remove) and death. + private static void Leave(Zone zone, Entity e, string how) + { + switch (how) + { + case "remove": zone.Entities.Remove(e); break; + case "warp": e.Deactivate(); zone.Entities.Remove(e); break; + case "death": e.Hull.Durability = 0f; e.HullDamage.OnNext(1f); break; + default: throw new Exception("fixture: " + how); + } + Assert.DoesNotContain(e, zone.Entities); + } + + private static float Freshest(Entity observer, Entity subject) => observer.RevealsOf(subject).Max(r => r.Time); + + // Ruling reveal-ends-on-perception: a ship leaving the zone does not by itself empty any book. Out of this + // observer's sight (the light it showed at removal is below the observer's gate) its reveals stay, unrefreshed, + // and the contact stays Detected on the freshest bearing, exactly until that bearing is BearingMemory old; then + // the book drops the ship. A live quiet ship's old reveal is another matter: it stays in the book. [Fact] - public void RevealsLeaveWithTheSubject() + public void AnUnseenDepartureKeepsTheContactUntilTheBearingLapses() { var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var q = Add(float3(40, 0, 100), new[] { "Gun" }); var guns = Guns(e); Fire(e, guns[0]); Fire(e, guns[1]); + Fire(q, Guns(q)[0]); + var memory = _items.GameplaySettings.BearingMemory; + var times = o.RevealsOf(e).ToDictionary(r => r.Item, r => r.Time); + var freshest = times.Values.Max(); + var bearing = o.ContactOf(e).Bearing; + e.SetSignature(new object(), SignatureChannel.Reflection, 1f, guns[0]); + Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its last light is below o's gate"); + Assert.Equal(2, times.Count); + + _zone.Entities.Remove(e); Assert.Equal(2, o.RevealsOf(e).Count()); + var lapsed = false; + var ticks = 0; + while (!lapsed && ticks++ < 100) + { + Tick(.1f); + var within = _zone.Time - freshest < memory; + lapsed = !within; + Assert.Equal(within, o.RevealsOf(e).Count() == 2); // exactly while the bearing is remembered + Assert.Equal(within, o.ContactOf(e).Detected); + Assert.Equal(within ? 1 : 0, RevealBookSubjects(o) - 1); // the quiet live ship's key stays either way + if (!within) continue; + var contact = o.ContactOf(e); + Assert.True(contact.Classified && !contact.Tracked); + AssertBearing(bearing.x, bearing.y, contact.Bearing); + foreach (var reveal in o.RevealsOf(e)) Assert.Equal(times[reveal.Item], reveal.Time); // never refreshed + } + + Assert.True(lapsed, "fixture: the bearing lapsed"); + Assert.Empty(o.RevealsOf(e)); + Assert.False(o.ContactOf(e).Detected); + Assert.Equal(1, RevealBookSubjects(o)); // only the live ship's key + + for (var i = 0; i < 30; i++) Tick(.2f); // q, live and quiet, was revealed 6 s ago, far more than twice the memory + Assert.Single(o.RevealsOf(q)); + Assert.False(o.ContactOf(q).Detected); + } + + // The same departure seen: every way a ship leaves is one departure fact, judged when the observer next senses. + // A strong ear (sensitivity 4) and a weak eye (2) at the same place, listed on either side of the ship, both hold + // both reveals right after the removal; after the next tick only the observer whose gate the ship's light at + // removal passes (signature 2 x info 8/d = .16 against .08) has let them go. + [Theory] + [InlineData("remove", false)] + [InlineData("remove", true)] + [InlineData("warp", false)] + [InlineData("death", false)] + [InlineData("death", true)] + public void ASeenDepartureEndsEveryRevealOfTheSubject(string how, bool strongFirst) + { + Entity strong = null, weak = null; + if (strongFirst) strong = Add(float3(-1, 0, 0), new[] { "Ear" }); + else weak = Add(float3(1, 0, 0), new[] { "Eye" }); + var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); + if (strongFirst) weak = Add(float3(1, 0, 0), new[] { "Eye" }); + else strong = Add(float3(-1, 0, 0), new[] { "Ear" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + foreach (var observer in new[] { strong, weak }) Assert.Equal(2, observer.RevealsOf(e).Count()); + e.SetSignature(new object(), SignatureChannel.Reflection, 2f, guns[0]); + var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; + Assert.True(e.Visibility * strong.PassiveInfo(e.Position.xz, 1f) > threshold, "fixture: the ear passes the gate"); + Assert.True(e.Visibility * weak.PassiveInfo(e.Position.xz, 1f) <= threshold, "fixture: the eye fails it"); + + Leave(_zone, e, how); + + Assert.Equal(2, strong.RevealsOf(e).Count()); // nothing ends at the removal itself + Assert.Equal(2, weak.RevealsOf(e).Count()); + Tick(.1f); + + Assert.Empty(strong.RevealsOf(e)); + Assert.Equal(0, RevealBookSubjects(strong)); + Assert.False(strong.ContactOf(e).Detected); + Assert.Equal(2, weak.RevealsOf(e).Count()); + Assert.True(weak.ContactOf(e).Detected); + } + + // The gate at its edge, at one fixed distance: the light at removal a hair above the observer's threshold ends + // the reveals, a hair below does not. The factor is the production gate's own: threshold over the observer's + // passive info on the spot where the ship was. + [Theory] + [InlineData(1.01f, true)] + [InlineData(.99f, false)] + public void ADepartureEndsRevealsOnlyAboveTheLightActGate(float against, bool ended) + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + Fire(e, Guns(e)[0]); + Assert.Single(o.RevealsOf(e)); + var gate = _items.GameplaySettings.TargetDetectionInfoThreshold / o.PassiveInfo(e.Position.xz, 1f); + e.SetSignature(new object(), SignatureChannel.Reflection, gate * against, Guns(e)[0]); + Assert.Equal(gate * against, e.Visibility, 3); + + _zone.Entities.Remove(e); + Tick(.1f); + + Assert.Equal(ended ? 0 : 1, o.RevealsOf(e).Count()); + } + + // A signature on the Emission channel (paint strength, a ping) is not light: it does not make a departure seen. + [Fact] + public void AnEmissionSignatureDoesNotMakeADepartureSeen() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + Fire(e, Guns(e)[0]); + e.SetSignature(new object(), SignatureChannel.Emission, 1000f, Guns(e)[0]); + + _zone.Entities.Remove(e); + Tick(.1f); + + Assert.Single(o.RevealsOf(e)); + } + + // A ship removed and put back before anyone senses has not left: its reveals stand, and its next act refreshes them. + [Fact] + public void AReAdmittedSubjectHasNotDeparted() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var gun = Guns(e)[0]; + var at = Fire(e, gun); + e.SetSignature(new object(), SignatureChannel.Reflection, 1000f, gun); // bright enough for any gate + _zone.Entities.Remove(e); _zone.Entities.Add(e); + Tick(.1f); - Assert.Empty(o.RevealsOf(e)); // gone with the subject, and not back with it - Assert.Equal(0, RevealBookSubjects(o)); // and the book no longer holds the ship at all + Assert.Equal(at, Assert.Single(o.RevealsOf(e)).Time); + var later = Fire(e, gun); + Assert.True(later > at); + Assert.Equal(later, Assert.Single(o.RevealsOf(e)).Time); } private static int RevealBookSubjects(Entity observer) => @@ -526,7 +676,9 @@ public sealed class ContactRevealTests : IDisposable // Ruling contact-reads-book-aim-reads-live: what an observer knows of a ship comes from its own book. A gun // destroyed by a third party out of its sight changes none of its levels and stays in its reveal list, while - // the aiming read, which asks about the item now, refuses the dead gun and still serves the live one. + // the aiming read, which asks about the item now, refuses the dead gun and still serves the live one. No Sense + // runs after the destruction here (the aiming read needs info 1, at which a Sense would perceive it); the + // unseen case with a Sense is AnIdentifiedObserverSeesGearDestroyed's below-threshold case. [Fact] public void GearDestroyedOutOfSightLeavesWhatTheObserverKnowsAlone() { @@ -558,8 +710,60 @@ public sealed class ContactRevealTests : IDisposable Assert.True(o.ContactOf(e).Classified); } - // A paint inboxed in the tick its emitter leaves the zone is published the next tick, and is no longer the - // act of a subject in the zone: it warns for its tick and records no reveal, so the observer holds no ghost. + // Ruling reveal-ends-on-perception: an observer that resolves a ship's gear (its track at TargetGearInfoThreshold) + // perceives an item of it destroyed, and the reveal of that item ends in the same Sense pass. One that does not + // resolve it perceives nothing. + [Theory] + [InlineData(.8f, 1, 1, 1)] + [InlineData(.8f, 2, 0, 0)] + [InlineData(.79f, 1, 2, 1)] + [InlineData(.79f, 2, 2, 1)] + public void AnIdentifiedObserverSeesGearDestroyed(float info, int destroyed, int remaining, int classifiedKeys) + { + var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + o.VisibleEntities.Add(e); // tracked, so what the track holds is not the question + o.EntityInfoGathered[e] = info; + var sense = SenseOf(o); + for (var i = 0; i < destroyed; i++) guns[i].EquippableItem.Durability = 0f; + + sense(); + + Assert.Equal(remaining, o.RevealsOf(e).Count()); + var contact = o.ContactOf(e); + Assert.True(contact.Detected); + Assert.Equal(remaining > 0, contact.Classified); + Assert.Equal(remaining > 0 ? classifiedKeys : 0, RevealBookSubjects(o)); + if (remaining == 1 && info >= .8f) Assert.Same(guns[1], o.RevealsOf(e).Single().Item); + } + + // The same through a real tick: a gun destroyed while its flash still shows, seen by an observer that identifies + // the ship, is not in the book after the tick. + [Fact] + public void ADestroyedGunStillFlashingIsNotRecordedByAnIdentifyingObserver() + { + var e = Add(float3(0, 0, 60), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + e.SetSignature(new object(), SignatureChannel.Reflection, 1000f, guns[0]); // the dying gun's glare outlasts the tick + guns[0].EquippableItem.Durability = 0f; + for (var i = 0; i < 20 && Track(o, e) < .8f; i++) Tick(.01f); + Assert.True(Track(o, e) >= .8f, "fixture: o identifies e"); + + Tick(.01f); + + Assert.DoesNotContain(o.RevealsOf(e), r => r.Item == guns[0]); + Assert.Contains(o.RevealsOf(e), r => r.Item == guns[1]); + } + + // A paint inboxed in the tick its emitter leaves the zone is published the next tick, and is no longer the act of + // a subject in the zone: it warns for its tick and neither creates nor refreshes a reveal. The reveal the + // locker's earlier paint made stays what it was, as a departure out of sight (below the observer's gate) leaves it. [Fact] public void APaintFromAShipThatHasLeftTheZoneRevealsNothing() { @@ -570,16 +774,18 @@ public sealed class ContactRevealTests : IDisposable locker.SetTarget(o); locker.Aim = normalize(o.Position - locker.Position); for (var tick = 0; tick < 10 && !o.RevealsOf(locker).Any(); tick++) Tick(.5f); - Assert.Single(o.RevealsOf(locker)); // fixture: the lock paints every tick, and the first one is recorded + var first = Assert.Single(o.RevealsOf(locker)); // fixture: the lock paints every tick, and the first one is recorded + Assert.True(locker.Visibility * o.PassiveInfo(locker.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its departure is below o's gate"); _zone.Entities.Remove(locker); // dies, warps or docks while its latest paint is still in o's inbox - Assert.Empty(o.RevealsOf(locker)); + var inboxed = ((System.Collections.IList) typeof(Entity).GetField("_paintInbox", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(o)).Count; + Assert.True(inboxed > 0 || o.IncomingPaints.Count > 0, "fixture: a paint is in flight"); Tick(.1f); Assert.Single(o.IncomingPaints); // fixture: the stale paint was published - Assert.Empty(o.RevealsOf(locker)); - Assert.False(o.ContactOf(locker).Detected); - Assert.Equal(0, RevealBookSubjects(o)); + var after = Assert.Single(o.RevealsOf(locker)); + Assert.Equal(first.Time, after.Time); + Assert.Equal(1, RevealBookSubjects(o)); } // Ruling sensor-stat-set 4: a painted target always learns the emitter's class, whichever the mode. A ping @@ -609,6 +815,30 @@ public sealed class ContactRevealTests : IDisposable AssertBearing(0f, 1f, reveal.Bearing); } + // A search paint (a ping crossing the observer) from a pinger that has left the zone before the paint is + // published warns once and creates no reveal and no key. + [Fact] + public void ASearchPaintFromAPingerThatHasLeftCreatesNoReveal() + { + var p = Add(float3(0, 0, 100), new[] { "Eye" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + 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 inbox = (System.Collections.IList) typeof(Entity).GetField("_paintInbox", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(o); + for (var tick = 0; tick < 100 && inbox.Count == 0; tick++) Tick(.1f); + Assert.NotEmpty(inbox); // fixture: the ping crossed o and its paint waits to be published + + _zone.Entities.Remove(p); + Tick(.1f); + + Assert.Single(o.IncomingPaints); + Assert.Equal(PaintMode.Search, o.IncomingPaints.Single().Mode); + Assert.Empty(o.RevealsOf(p)); + Assert.Equal(0, RevealBookSubjects(o)); + Assert.False(o.ContactOf(p).Detected); + } + // 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] @@ -716,9 +946,19 @@ public sealed class ContactRevealTests : IDisposable gunItems.Add(item); } var o = Add(float3(0, 0, 0), new[] { "Eye" }); - var sense = (Action) Delegate.CreateDelegate(typeof(Action), o, typeof(Entity).GetMethod("Sense", BindingFlags.Instance | BindingFlags.NonPublic)); + var sense = SenseOf(o); for (var i = 0; i < 10; i++) sense(); Assert.Equal(19, _zone.Entities.Where(x => x != o).Count(x => o.RevealsOf(x).Any())); // fixture: every ship is revealed + // One of them is identified by o (its destroyed gun is ended in the pass), and one has left, quiet, so its + // entries await the prune and its departure sits in the zone's list. + var identified = gunItems.Select(g => _zone.Entities.Single(x => x.Equipment.Contains(g))).First(); + o.EntityInfoGathered[identified] = 1f; + var departed = _zone.Entities.Single(x => x.Equipment.Contains(gunItems[18])); + foreach (var source in departed.VisibilitySources.Keys.ToArray()) departed.SetSignature(source, SignatureChannel.Reflection, 0f, gunItems[18]); + _zone.Entities.Remove(departed); + Assert.Single((System.Collections.IList) typeof(Zone).GetProperty("Departures", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(_zone)); + sense(); + Assert.Single(o.RevealsOf(departed)); // fixture: awaiting its prune var before = GC.GetAllocatedBytesForCurrentThread(); for (var i = 0; i < 600; i++) sense(); From 22296ae4ebf9355bdf32ef5884e421275d2fac35 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 22:04:34 +0200 Subject: [PATCH 08/17] ContactRevealTests: settle the last flash before the departure signature; identified-observer tick fixture Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ContactRevealTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 6099af42c..3fc559823 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -595,6 +595,7 @@ public sealed class ContactRevealTests : IDisposable var guns = Guns(e); Fire(e, guns[0]); Fire(e, guns[1]); + Tick(.01f); // the last flash decays at once: only the signature set below is left at removal foreach (var observer in new[] { strong, weak }) Assert.Equal(2, observer.RevealsOf(e).Count()); e.SetSignature(new object(), SignatureChannel.Reflection, 2f, guns[0]); var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; @@ -625,6 +626,7 @@ public sealed class ContactRevealTests : IDisposable var e = Add(float3(0, 0, 100), new[] { "Gun" }); var o = Add(float3(0, 0, 0), new[] { "Eye" }); Fire(e, Guns(e)[0]); + Tick(.01f); Assert.Single(o.RevealsOf(e)); var gate = _items.GameplaySettings.TargetDetectionInfoThreshold / o.PassiveInfo(e.Position.xz, 1f); e.SetSignature(new object(), SignatureChannel.Reflection, gate * against, Guns(e)[0]); @@ -643,6 +645,7 @@ public sealed class ContactRevealTests : IDisposable var e = Add(float3(0, 0, 100), new[] { "Gun" }); var o = Add(float3(0, 0, 0), new[] { "Eye" }); Fire(e, Guns(e)[0]); + Tick(.01f); e.SetSignature(new object(), SignatureChannel.Emission, 1000f, Guns(e)[0]); _zone.Entities.Remove(e); @@ -659,6 +662,7 @@ public sealed class ContactRevealTests : IDisposable var o = Add(float3(0, 0, 0), new[] { "Eye" }); var gun = Guns(e)[0]; var at = Fire(e, gun); + Tick(1f); // the gun cools down e.SetSignature(new object(), SignatureChannel.Reflection, 1000f, gun); // bright enough for any gate _zone.Entities.Remove(e); @@ -752,11 +756,12 @@ public sealed class ContactRevealTests : IDisposable Fire(e, guns[1]); e.SetSignature(new object(), SignatureChannel.Reflection, 1000f, guns[0]); // the dying gun's glare outlasts the tick guns[0].EquippableItem.Durability = 0f; - for (var i = 0; i < 20 && Track(o, e) < .8f; i++) Tick(.01f); - Assert.True(Track(o, e) >= .8f, "fixture: o identifies e"); + o.EntityInfoGathered[e] = 1f; // o's sensor keeps it at about 1 across the tick (decay .5 x dt) Tick(.01f); + Assert.True(Track(o, e) >= .8f, "fixture: o still identifies e"); + Assert.DoesNotContain(o.RevealsOf(e), r => r.Item == guns[0]); Assert.Contains(o.RevealsOf(e), r => r.Item == guns[1]); } From d03b85ea34d83ea9181387dc898a23b5ceba8a3e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 22:05:16 +0200 Subject: [PATCH 09/17] ContactRevealTests: departure fixtures put the ship's other light out and show exactly the authored signature Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 3fc559823..1c36f7a7a 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -520,6 +520,14 @@ public sealed class ContactRevealTests : IDisposable Assert.DoesNotContain(e, zone.Entities); } + // Leaves the ship showing exactly `strength` of light: whatever its guns left glowing is put out first. + private static void ShowOnly(Entity e, float strength, EquippedItem item) + { + foreach (var source in e.VisibilitySources.Keys.ToArray()) e.SetSignature(source, SignatureChannel.Reflection, 0f, item); + e.SetSignature(new object(), SignatureChannel.Reflection, strength, item); + Assert.Equal(strength, e.Visibility, 4); + } + private static float Freshest(Entity observer, Entity subject) => observer.RevealsOf(subject).Max(r => r.Time); // Ruling reveal-ends-on-perception: a ship leaving the zone does not by itself empty any book. Out of this @@ -540,7 +548,7 @@ public sealed class ContactRevealTests : IDisposable var times = o.RevealsOf(e).ToDictionary(r => r.Item, r => r.Time); var freshest = times.Values.Max(); var bearing = o.ContactOf(e).Bearing; - e.SetSignature(new object(), SignatureChannel.Reflection, 1f, guns[0]); + ShowOnly(e, 1f, guns[0]); Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its last light is below o's gate"); Assert.Equal(2, times.Count); @@ -595,9 +603,8 @@ public sealed class ContactRevealTests : IDisposable var guns = Guns(e); Fire(e, guns[0]); Fire(e, guns[1]); - Tick(.01f); // the last flash decays at once: only the signature set below is left at removal foreach (var observer in new[] { strong, weak }) Assert.Equal(2, observer.RevealsOf(e).Count()); - e.SetSignature(new object(), SignatureChannel.Reflection, 2f, guns[0]); + ShowOnly(e, 2f, guns[0]); var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; Assert.True(e.Visibility * strong.PassiveInfo(e.Position.xz, 1f) > threshold, "fixture: the ear passes the gate"); Assert.True(e.Visibility * weak.PassiveInfo(e.Position.xz, 1f) <= threshold, "fixture: the eye fails it"); @@ -629,8 +636,7 @@ public sealed class ContactRevealTests : IDisposable Tick(.01f); Assert.Single(o.RevealsOf(e)); var gate = _items.GameplaySettings.TargetDetectionInfoThreshold / o.PassiveInfo(e.Position.xz, 1f); - e.SetSignature(new object(), SignatureChannel.Reflection, gate * against, Guns(e)[0]); - Assert.Equal(gate * against, e.Visibility, 3); + ShowOnly(e, gate * against, Guns(e)[0]); _zone.Entities.Remove(e); Tick(.1f); @@ -646,6 +652,7 @@ public sealed class ContactRevealTests : IDisposable var o = Add(float3(0, 0, 0), new[] { "Eye" }); Fire(e, Guns(e)[0]); Tick(.01f); + ShowOnly(e, 0f, Guns(e)[0]); e.SetSignature(new object(), SignatureChannel.Emission, 1000f, Guns(e)[0]); _zone.Entities.Remove(e); @@ -663,7 +670,7 @@ public sealed class ContactRevealTests : IDisposable var gun = Guns(e)[0]; var at = Fire(e, gun); Tick(1f); // the gun cools down - e.SetSignature(new object(), SignatureChannel.Reflection, 1000f, gun); // bright enough for any gate + ShowOnly(e, 1000f, gun); // bright enough for any gate _zone.Entities.Remove(e); _zone.Entities.Add(e); From 49ace47f3497dc3b971563ce547f96cb8a5f241f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 22:06:24 +0200 Subject: [PATCH 10/17] Leave perceived item destruction to batch B: drop the Identified item pass and its tests from this cut Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 18 +----- .../ContactRevealTests.cs | 61 +------------------ 2 files changed, 5 insertions(+), 74 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 80bce7a01..9415ff7f3 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -216,7 +216,6 @@ public abstract class Entity // item). Written only by RecordReveal (an act enters) and EndReveals (an end the observer perceives, or a departed // subject's entries aged past BearingMemory), cleared by Deactivate (ruling reveal-ends-on-perception). private readonly Dictionary> _reveals = new Dictionary>(); - private readonly List _endedItems = new List(); private readonly List _agedSubjects = new List(); public ReactiveProperty CurrentSecurityLevel = new ReactiveProperty(SecurityLevel.Open); public ReadOnlyReactiveProperty PresencePermitted; @@ -915,10 +914,9 @@ public abstract class Entity // 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, - // dock or warp of a subject its instruments could see leave, or, for a subject that has left the zone, the - // freshest entry aging past BearingMemory; Deactivate clears it all. Whether an item can be aimed at now is - // FireControl.IsRevealed's question, not this one. + // perceives (ruling reveal-ends-on-perception): the death, dock or warp of a subject its instruments could see + // leave, or, for a subject that has left the zone, the freshest entry aging past BearingMemory; Deactivate + // clears it all. Whether an item can be aimed at now is FireControl.IsRevealed's question, not this one. public IEnumerable RevealsOf(Entity subject) => _reveals.TryGetValue(subject, out var book) ? book.Values : Enumerable.Empty(); @@ -964,16 +962,6 @@ public abstract class Entity if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f); if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel); } - // The observer resolves this ship's gear (ContactOf's own Identified gate), so it perceives an item of it - // destroyed. Ending after recording: a destroyed item's lingering flash cannot re-enter the book. - if (EntityInfoGathered.TryGetValue(other, out var info) && info >= ItemManager.GameplaySettings.TargetGearInfoThreshold - && _reveals.TryGetValue(other, out var book)) - { - _endedItems.Clear(); - foreach (var item in book.Keys) - if (item.EquippableItem.Durability < .01f) _endedItems.Add(item); - for (var j = 0; j < _endedItems.Count; j++) EndReveals(other, _endedItems[j]); - } } // A subject that left the zone (death, dock, warp, any removal) is perceived gone under the light-act gate: as // bright as its non-Emission signature was at removal, from where it was. A re-admitted subject did not leave. diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 1c36f7a7a..3a214b888 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -687,9 +687,7 @@ public sealed class ContactRevealTests : IDisposable // Ruling contact-reads-book-aim-reads-live: what an observer knows of a ship comes from its own book. A gun // destroyed by a third party out of its sight changes none of its levels and stays in its reveal list, while - // the aiming read, which asks about the item now, refuses the dead gun and still serves the live one. No Sense - // runs after the destruction here (the aiming read needs info 1, at which a Sense would perceive it); the - // unseen case with a Sense is AnIdentifiedObserverSeesGearDestroyed's below-threshold case. + // the aiming read, which asks about the item now, refuses the dead gun and still serves the live one. [Fact] public void GearDestroyedOutOfSightLeavesWhatTheObserverKnowsAlone() { @@ -721,58 +719,6 @@ public sealed class ContactRevealTests : IDisposable Assert.True(o.ContactOf(e).Classified); } - // Ruling reveal-ends-on-perception: an observer that resolves a ship's gear (its track at TargetGearInfoThreshold) - // perceives an item of it destroyed, and the reveal of that item ends in the same Sense pass. One that does not - // resolve it perceives nothing. - [Theory] - [InlineData(.8f, 1, 1, 1)] - [InlineData(.8f, 2, 0, 0)] - [InlineData(.79f, 1, 2, 1)] - [InlineData(.79f, 2, 2, 1)] - public void AnIdentifiedObserverSeesGearDestroyed(float info, int destroyed, int remaining, int classifiedKeys) - { - var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); - var o = Add(float3(0, 0, 0), new[] { "Eye" }); - var guns = Guns(e); - Fire(e, guns[0]); - Fire(e, guns[1]); - o.VisibleEntities.Add(e); // tracked, so what the track holds is not the question - o.EntityInfoGathered[e] = info; - var sense = SenseOf(o); - for (var i = 0; i < destroyed; i++) guns[i].EquippableItem.Durability = 0f; - - sense(); - - Assert.Equal(remaining, o.RevealsOf(e).Count()); - var contact = o.ContactOf(e); - Assert.True(contact.Detected); - Assert.Equal(remaining > 0, contact.Classified); - Assert.Equal(remaining > 0 ? classifiedKeys : 0, RevealBookSubjects(o)); - if (remaining == 1 && info >= .8f) Assert.Same(guns[1], o.RevealsOf(e).Single().Item); - } - - // The same through a real tick: a gun destroyed while its flash still shows, seen by an observer that identifies - // the ship, is not in the book after the tick. - [Fact] - public void ADestroyedGunStillFlashingIsNotRecordedByAnIdentifyingObserver() - { - var e = Add(float3(0, 0, 60), new[] { "Gun", "Gun" }); - var o = Add(float3(0, 0, 0), new[] { "Eye" }); - var guns = Guns(e); - Fire(e, guns[0]); - Fire(e, guns[1]); - e.SetSignature(new object(), SignatureChannel.Reflection, 1000f, guns[0]); // the dying gun's glare outlasts the tick - guns[0].EquippableItem.Durability = 0f; - o.EntityInfoGathered[e] = 1f; // o's sensor keeps it at about 1 across the tick (decay .5 x dt) - - Tick(.01f); - - Assert.True(Track(o, e) >= .8f, "fixture: o still identifies e"); - - Assert.DoesNotContain(o.RevealsOf(e), r => r.Item == guns[0]); - Assert.Contains(o.RevealsOf(e), r => r.Item == guns[1]); - } - // A paint inboxed in the tick its emitter leaves the zone is published the next tick, and is no longer the act of // a subject in the zone: it warns for its tick and neither creates nor refreshes a reveal. The reveal the // locker's earlier paint made stays what it was, as a departure out of sight (below the observer's gate) leaves it. @@ -961,10 +907,7 @@ public sealed class ContactRevealTests : IDisposable var sense = SenseOf(o); for (var i = 0; i < 10; i++) sense(); Assert.Equal(19, _zone.Entities.Where(x => x != o).Count(x => o.RevealsOf(x).Any())); // fixture: every ship is revealed - // One of them is identified by o (its destroyed gun is ended in the pass), and one has left, quiet, so its - // entries await the prune and its departure sits in the zone's list. - var identified = gunItems.Select(g => _zone.Entities.Single(x => x.Equipment.Contains(g))).First(); - o.EntityInfoGathered[identified] = 1f; + // One of them has left, quiet, so its entries await the prune and its departure sits in the zone's list. var departed = _zone.Entities.Single(x => x.Equipment.Contains(gunItems[18])); foreach (var source in departed.VisibilitySources.Keys.ToArray()) departed.SetSignature(source, SignatureChannel.Reflection, 0f, gunItems[18]); _zone.Entities.Remove(departed); From 396c02b08f955d9bc870e9d740413dedc81b1a6f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 22:58:10 +0200 Subject: [PATCH 11/17] Perceived item destruction ends the reveal in the observer's Sense; moving-subject bearing pinned Restores the Identified item-end pass and its tests (AnIdentifiedObserverSeesGearDestroyed, the flashing-gun tick case, the Identified part of SenseAllocatesNothingOnceWarm) that 49ace47f held for this batch, and extends AnActIsRevealedOnItsOwnTickWhateverTheListOrder to a moving subject (two velocities, both observer orders). Builds: all commits build. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 18 ++++- .../ContactRevealTests.cs | 74 +++++++++++++++++-- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 9415ff7f3..80bce7a01 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -216,6 +216,7 @@ public abstract class Entity // item). Written only by RecordReveal (an act enters) and EndReveals (an end the observer perceives, or a departed // subject's entries aged past BearingMemory), cleared by Deactivate (ruling reveal-ends-on-perception). private readonly Dictionary> _reveals = new Dictionary>(); + private readonly List _endedItems = new List(); private readonly List _agedSubjects = new List(); public ReactiveProperty CurrentSecurityLevel = new ReactiveProperty(SecurityLevel.Open); public ReadOnlyReactiveProperty PresencePermitted; @@ -914,9 +915,10 @@ public abstract class Entity // 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 death, dock or warp of a subject its instruments could see - // leave, or, for a subject that has left the zone, the freshest entry aging past BearingMemory; Deactivate - // clears it all. Whether an item can be aimed at now is FireControl.IsRevealed's question, not this one. + // perceives (ruling reveal-ends-on-perception): the destruction of an item of a ship it identifies, the death, + // dock or warp of a subject its instruments could see leave, or, for a subject that has left the zone, the + // freshest entry aging past BearingMemory; Deactivate clears it all. Whether an item can be aimed at now is + // FireControl.IsRevealed's question, not this one. public IEnumerable RevealsOf(Entity subject) => _reveals.TryGetValue(subject, out var book) ? book.Values : Enumerable.Empty(); @@ -962,6 +964,16 @@ public abstract class Entity if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f); if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel); } + // The observer resolves this ship's gear (ContactOf's own Identified gate), so it perceives an item of it + // destroyed. Ending after recording: a destroyed item's lingering flash cannot re-enter the book. + if (EntityInfoGathered.TryGetValue(other, out var info) && info >= ItemManager.GameplaySettings.TargetGearInfoThreshold + && _reveals.TryGetValue(other, out var book)) + { + _endedItems.Clear(); + foreach (var item in book.Keys) + if (item.EquippableItem.Durability < .01f) _endedItems.Add(item); + for (var j = 0; j < _endedItems.Count; j++) EndReveals(other, _endedItems[j]); + } } // A subject that left the zone (death, dock, warp, any removal) is perceived gone under the light-act gate: as // bright as its non-Emission signature was at removal, from where it was. A re-admitted subject did not leave. diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 3a214b888..c06ebb216 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -687,7 +687,9 @@ public sealed class ContactRevealTests : IDisposable // Ruling contact-reads-book-aim-reads-live: what an observer knows of a ship comes from its own book. A gun // destroyed by a third party out of its sight changes none of its levels and stays in its reveal list, while - // the aiming read, which asks about the item now, refuses the dead gun and still serves the live one. + // the aiming read, which asks about the item now, refuses the dead gun and still serves the live one. No Sense + // runs after the destruction here (the aiming read needs info 1, at which a Sense would perceive it); the + // unseen case with a Sense is AnIdentifiedObserverSeesGearDestroyed's below-threshold case. [Fact] public void GearDestroyedOutOfSightLeavesWhatTheObserverKnowsAlone() { @@ -719,6 +721,58 @@ public sealed class ContactRevealTests : IDisposable Assert.True(o.ContactOf(e).Classified); } + // Ruling reveal-ends-on-perception: an observer that resolves a ship's gear (its track at TargetGearInfoThreshold) + // perceives an item of it destroyed, and the reveal of that item ends in the same Sense pass. One that does not + // resolve it perceives nothing. + [Theory] + [InlineData(.8f, 1, 1, 1)] + [InlineData(.8f, 2, 0, 0)] + [InlineData(.79f, 1, 2, 1)] + [InlineData(.79f, 2, 2, 1)] + public void AnIdentifiedObserverSeesGearDestroyed(float info, int destroyed, int remaining, int classifiedKeys) + { + var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + o.VisibleEntities.Add(e); // tracked, so what the track holds is not the question + o.EntityInfoGathered[e] = info; + var sense = SenseOf(o); + for (var i = 0; i < destroyed; i++) guns[i].EquippableItem.Durability = 0f; + + sense(); + + Assert.Equal(remaining, o.RevealsOf(e).Count()); + var contact = o.ContactOf(e); + Assert.True(contact.Detected); + Assert.Equal(remaining > 0, contact.Classified); + Assert.Equal(remaining > 0 ? classifiedKeys : 0, RevealBookSubjects(o)); + if (remaining == 1 && info >= .8f) Assert.Same(guns[1], o.RevealsOf(e).Single().Item); + } + + // The same through a real tick: a gun destroyed while its flash still shows, seen by an observer that identifies + // the ship, is not in the book after the tick. + [Fact] + public void ADestroyedGunStillFlashingIsNotRecordedByAnIdentifyingObserver() + { + var e = Add(float3(0, 0, 60), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + e.SetSignature(new object(), SignatureChannel.Reflection, 1000f, guns[0]); // the dying gun's glare outlasts the tick + guns[0].EquippableItem.Durability = 0f; + o.EntityInfoGathered[e] = 1f; // o's sensor keeps it at about 1 across the tick (decay .5 x dt) + + Tick(.01f); + + Assert.True(Track(o, e) >= .8f, "fixture: o still identifies e"); + + Assert.DoesNotContain(o.RevealsOf(e), r => r.Item == guns[0]); + Assert.Contains(o.RevealsOf(e), r => r.Item == guns[1]); + } + // A paint inboxed in the tick its emitter leaves the zone is published the next tick, and is no longer the act of // a subject in the zone: it warns for its tick and neither creates nor refreshes a reveal. The reveal the // locker's earlier paint made stays what it was, as a departure out of sight (below the observer's gate) leaves it. @@ -813,16 +867,23 @@ public sealed class ContactRevealTests : IDisposable } // The zone's list order is not a rule of the game: two observers equidistant from a gun, one listed before the - // gun's ship and one after, record the same reveal with the same bearing and the same clock. - [Fact] - public void AnActIsRevealedOnItsOwnTickWhateverTheListOrder() + // gun's ship and one after, record the same reveal with the same bearing and the same clock. The ship may be + // moving: the bearing is that of the ship where the tick left it, whatever the order the zone lists them in. + [Theory] + [InlineData(0f, 0f)] + [InlineData(30f, -20f)] + [InlineData(-45f, 10f)] + public void AnActIsRevealedOnItsOwnTickWhateverTheListOrder(float vx, float vz) { var before = Add(float3(-30, 0, 40), new[] { "Eye" }); var e = Add(float3(30, 0, 40), new[] { "Gun" }); var after = Add(float3(0, 0, 0), new[] { "Eye" }); + var start = e.Position; + e.Velocity = float2(vx, vz); var at = Fire(e, Guns(e)[0]); + if (vx != 0f || vz != 0f) Assert.True(length(e.Position - start) > 1f, "fixture: e moved during the firing tick"); foreach (var observer in new Entity[] { before, after }) { var reveal = Assert.Single(observer.RevealsOf(e)); @@ -907,7 +968,10 @@ public sealed class ContactRevealTests : IDisposable var sense = SenseOf(o); for (var i = 0; i < 10; i++) sense(); Assert.Equal(19, _zone.Entities.Where(x => x != o).Count(x => o.RevealsOf(x).Any())); // fixture: every ship is revealed - // One of them has left, quiet, so its entries await the prune and its departure sits in the zone's list. + // One of them is identified by o (its destroyed gun is ended in the pass), and one has left, quiet, so its + // entries await the prune and its departure sits in the zone's list. + var identified = gunItems.Select(g => _zone.Entities.Single(x => x.Equipment.Contains(g))).First(); + o.EntityInfoGathered[identified] = 1f; var departed = _zone.Entities.Single(x => x.Equipment.Contains(gunItems[18])); foreach (var source in departed.VisibilitySources.Keys.ToArray()) departed.SetSignature(source, SignatureChannel.Reflection, 0f, gunItems[18]); _zone.Entities.Remove(departed); From c212ec0232c0876a49aa691fa70b794afd6227bd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 02:20:46 +0200 Subject: [PATCH 12/17] Reveal book: no scratch holds a departed entity, a destroyed lit gun is refused at the gate, one IsDestroyed rule, departure and allocation pins Not verified yet at this commit (build and tests run next). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/EntityInstance.cs | 2 +- Assets/Scripts/ServerShared/Entity.cs | 30 +-- Assets/Scripts/ServerShared/FireControl.cs | 2 +- Assets/Scripts/ServerShared/ItemInstance.cs | 3 + .../UI/Properties Panel/PropertiesPanel.cs | 4 +- .../ContactRevealTests.cs | 179 +++++++++++++++++- 6 files changed, 198 insertions(+), 22 deletions(-) diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 318a3007e..53e5dbc71 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -290,7 +290,7 @@ public class EntityInstance : MonoBehaviour _subscriptions.Add(Entity.HullDamage.Subscribe(_ => { - if (Entity.Hull.Durability < .01f) + if (Entity.Hull.IsDestroyed) { if (!this) return; _destroyed = true; diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 80bce7a01..6618bd9bb 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -893,16 +893,23 @@ public abstract class Entity // The reveal book's one way in and its one membership gate (callers: Sense and PublishInbox, both in this file): // this observer saw `item` of `subject` act on `channel`, now, from this bearing. An item-less act (hull // radiation, a consumable) reveals nothing, and neither does a subject that is not in the zone: a departed - // subject's entries are never created or refreshed, from any caller. A later act of the same item replaces the + // subject's entries are never created or refreshed, from any caller, and neither does an item this observer + // perceives destroyed (PerceivesDestroyed). A later act of the same item replaces the // earlier one. internal void RecordReveal(Entity subject, EquippedItem item, SignatureChannel channel) { - if (!_active || item == null || !Zone.Entities.Contains(subject)) return; + if (!_active || item == null || !Zone.Entities.Contains(subject) || PerceivesDestroyed(subject, item)) return; if (!_reveals.TryGetValue(subject, out var book)) _reveals[subject] = book = new Dictionary(); book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time); } + // This observer resolves `subject`'s gear (ContactOf's Identified gate) and `item` of it is destroyed: the one rule + // for a destroyed item an observer perceives, read by the book's entry (RecordReveal) and its end (Sense). + private bool PerceivesDestroyed(Entity subject, EquippedItem item) => + item.EquippableItem.IsDestroyed + && EntityInfoGathered.TryGetValue(subject, out var info) && info >= ItemManager.GameplaySettings.TargetGearInfoThreshold; + // 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) @@ -964,15 +971,14 @@ public abstract class Entity if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f); if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel); } - // The observer resolves this ship's gear (ContactOf's own Identified gate), so it perceives an item of it - // destroyed. Ending after recording: a destroyed item's lingering flash cannot re-enter the book. - if (EntityInfoGathered.TryGetValue(other, out var info) && info >= ItemManager.GameplaySettings.TargetGearInfoThreshold - && _reveals.TryGetValue(other, out var book)) + // The observer resolves this ship's gear (PerceivesDestroyed), so it perceives an item of it destroyed and + // that reveal ends here; RecordReveal refuses the same item's lingering flash. + if (_reveals.TryGetValue(other, out var book)) { - _endedItems.Clear(); foreach (var item in book.Keys) - if (item.EquippableItem.Durability < .01f) _endedItems.Add(item); + if (PerceivesDestroyed(other, item)) _endedItems.Add(item); for (var j = 0; j < _endedItems.Count; j++) EndReveals(other, _endedItems[j]); + _endedItems.Clear(); } } // A subject that left the zone (death, dock, warp, any removal) is perceived gone under the light-act gate: as @@ -987,7 +993,6 @@ public abstract class Entity // What stays of a departed subject is memory: pruned once its freshest entry is as old as ContactOf stops // remembering it. var memory = ItemManager.GameplaySettings.BearingMemory; - _agedSubjects.Clear(); foreach (var pair in _reveals) { if (entities.Contains(pair.Key)) continue; @@ -996,6 +1001,7 @@ public abstract class Entity if (Zone.Time - freshest >= memory) _agedSubjects.Add(pair.Key); } for (var i = 0; i < _agedSubjects.Count; i++) EndReveals(_agedSubjects[i], null); + _agedSubjects.Clear(); } public Entity(ItemManager itemManager, Zone zone, EquippableItem hull, EntitySettings settings) @@ -1012,10 +1018,10 @@ public abstract class Entity for(int i=0; i(), new List()); - ItemDestroyed = ItemDamage.Where(x => x.item.EquippableItem.Durability < .01f).Select(x=>x.item); + ItemDestroyed = ItemDamage.Where(x => x.item.EquippableItem.IsDestroyed).Select(x=>x.item); WeaponDestroyed = ItemDestroyed.Select(x => x.Behaviors.FirstOrDefault(b => b is Weapon) as Weapon).Where(x => x != null); HullArmorDepleted = ArmorDamage.Where(x => Armor[x.pos.x, x.pos.y] < .01f).Select(x => x.pos); - Death = HullDamage.Where(_ => Hull.Durability < .01f).Select(_ => CauseOfDeath.HullDestroyed) + Death = HullDamage.Where(_ => Hull.IsDestroyed).Select(_ => CauseOfDeath.HullDestroyed) .Merge(HeatstrokeDeath.Select(_ => CauseOfDeath.Heatstroke)) .Merge(HypothermiaDeath.Select(_ => CauseOfDeath.Hypothermia)) .Merge(ItemDestroyed.Where(i=>i.GetBehavior()!=null).Select(_ => CauseOfDeath.CockpitDestroyed)) @@ -2193,7 +2199,7 @@ public class EquippedItem : IStatContext deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent ) * Data.Durability / Data.ThermalResilience; ThermalOnline.Value = ThermalPerformance > performanceThreshold || Entity.OverrideShutdown && EquippableItem.OverrideShutdown; - DurabilityOnline.Value = EquippableItem.Durability > .01f; + DurabilityOnline.Value = !EquippableItem.IsDestroyed; oldTemperature = temp; } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 261e1a4f3..2a2b51471 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -168,7 +168,7 @@ public static class FireControl // failing its own tier -- ranked.IndexOf(item) then returns -1 for it (the same "no longer on this // target" path below), and the survivors' tiers close up over the gap instead of leaving one. var ranked = target.Equipment - .Where(e => e.Data.HardpointType != HardpointType.Hull && e.EquippableItem.Durability >= .01f) + .Where(e => e.Data.HardpointType != HardpointType.Hull && !e.EquippableItem.IsDestroyed) .OrderByDescending(e => e.Data.HardpointType != HardpointType.Tool) .ThenByDescending(e => e.Data.Shape.Coordinates.Length) .ToList(); diff --git a/Assets/Scripts/ServerShared/ItemInstance.cs b/Assets/Scripts/ServerShared/ItemInstance.cs index d4e7d9d89..80a891666 100644 --- a/Assets/Scripts/ServerShared/ItemInstance.cs +++ b/Assets/Scripts/ServerShared/ItemInstance.cs @@ -53,6 +53,9 @@ public class EquippableItem : CraftedItemInstance [JsonProperty("durability"), Key(7)] public float Durability; [JsonProperty("override"), Key(8)] public bool OverrideShutdown; + // The one rule for a destroyed item: its wear is spent. + [IgnoreMember] public bool IsDestroyed => Durability < .01f; + // Cut 5 (docs/stats-and-power-cut.md §1.3, §0b "A power tier"): a player choice, stored on the unit rather // than the design, so two units of the same design can be prioritized differently. PowerTiers.Unassigned // until the first equip seeds a behaviour-kind default (EquippedItem's constructor) or the player overrides diff --git a/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs b/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs index a39226ccc..33d3050c5 100644 --- a/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs +++ b/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs @@ -386,7 +386,7 @@ public class PropertiesPanel : MonoBehaviour private void AddEquippableItemProperties(EquippableItem item, Func statValueFunction) { - if (item.Durability < .01f) + if (item.IsDestroyed) { return; } @@ -473,7 +473,7 @@ public class PropertiesPanel : MonoBehaviour var gearData = GameManager.ItemManager.GetData(item.EquippableItem); var statusSheet = AddStatSheet(); - if (item.EquippableItem.Durability < .01f) + if (item.EquippableItem.IsDestroyed) statusSheet.AddStat("Durability", () => "Item Destroyed!"); else statusSheet.AddStat("Durability", () => $"{(int)(item.EquippableItem.Durability / gearData.Durability * 100)}%"); statusSheet.AddStat("Temperature", () => ActionGameManager.PlayerSettings.FormatTemperature(item.Temperature)); diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index c06ebb216..7fc4d6967 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -935,6 +935,161 @@ public sealed class ContactRevealTests : IDisposable Assert.Empty(o.RevealsOf(e)); } + // A destroyed line, not a destroyed zero: Durability below .01 is destroyed and .01 or more is not, for the + // observer that identifies the ship. + [Theory] + [InlineData(.009f, true)] + [InlineData(.011f, false)] + public void AnItemIsDestroyedBelowTheDurabilityLine(float durability, bool destroyed) + { + var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + o.VisibleEntities.Add(e); + o.EntityInfoGathered[e] = 1f; + guns[0].EquippableItem.Durability = durability; + + SenseOf(o)(); + + Assert.Equal(destroyed, !o.RevealsOf(e).Any(r => r.Item == guns[0])); + Assert.Contains(o.RevealsOf(e), r => r.Item == guns[1]); + } + + // ---- Departure notes ---- + + // A kill inside FireControl.Step happens after this tick's Sense: the observers' next Sense reads it, whatever + // order the zone lists them in. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AKillInsideFireControlStepIsReadByTheNextSense(bool observerFirst) + { + Entity strong = null; + if (observerFirst) strong = Add(float3(-1, 0, 0), new[] { "Ear" }); + var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); + var killer = Add(float3(60, 0, 100), new[] { "Gun" }); + if (!observerFirst) strong = Add(float3(-1, 0, 0), new[] { "Ear" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + Tick(1f); + ShowOnly(e, 2f, guns[0]); + Assert.True(e.Visibility * strong.PassiveInfo(e.Position.xz, 1f) > _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: the ear passes the gate"); + var killed = false; + void Kill(ShotOutcome _) + { + if (killed || !_zone.Entities.Contains(e)) return; + killed = true; + e.Hull.Durability = 0f; + e.HullDamage.OnNext(1f); + } + UniRx.ObservableExtensions.Subscribe(_zone.ShotCommitted, Kill); + UniRx.ObservableExtensions.Subscribe(_zone.ShotResolved, Kill); + var killerGun = (InstantWeapon) Guns(killer)[0].Behaviors.Single(b => b is InstantWeapon); + ((InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(killerGun)).AddCharge(1000f); + killer.SetTarget(TargetRef.None); + 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.Equal(2, strong.RevealsOf(e).Count()); // this tick's Sense came before the kill + + Tick(.1f); + + Assert.Empty(strong.RevealsOf(e)); + Assert.False(strong.ContactOf(e).Detected); + } + + // The note of a removal is read by exactly one Sense: an observer that could not see the departure on that tick + // does not judge it again later from where it then is. + [Fact] + public void ADepartureIsJudgedOnTheTickAfterItsRemovalAndNeverAgain() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var gun = Guns(e)[0]; + Fire(e, gun); + ShowOnly(e, 1f, gun); + var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; + Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= threshold, "fixture: from where o is, the departure is below the gate"); + _zone.Entities.Remove(e); + Tick(.1f); + Assert.Single(o.RevealsOf(e)); + + o.Position = float3(0, 0, 90); // now o is close enough to have seen it go + Assert.True(e.Visibility * o.PassiveInfo(float2(0, 100), 1f) > threshold, "fixture: from here the same departure would pass the gate"); + Tick(.1f); + + Assert.Single(o.RevealsOf(e)); + Assert.True(o.ContactOf(e).Detected); + } + + // Every departure of a tick is judged, whatever order they were removed in. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EveryDepartureOfATickIsJudged(bool firstThenSecond) + { + var o = Add(float3(-1, 0, 0), new[] { "Ear" }); + var a = Add(float3(0, 0, 100), new[] { "Gun" }); + var b = Add(float3(0, 0, -100), new[] { "Gun" }); + Fire(a, Guns(a)[0]); + Fire(b, Guns(b)[0]); + Assert.Single(o.RevealsOf(a)); + Assert.Single(o.RevealsOf(b)); + ShowOnly(a, 2f, Guns(a)[0]); + ShowOnly(b, 2f, Guns(b)[0]); + var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; + Assert.True(a.Visibility * o.PassiveInfo(a.Position.xz, 1f) > threshold && b.Visibility * o.PassiveInfo(b.Position.xz, 1f) > threshold, "fixture: both departures pass the gate"); + + _zone.Entities.Remove(firstThenSecond ? a : b); + _zone.Entities.Remove(firstThenSecond ? b : a); + Tick(.1f); + + Assert.Empty(o.RevealsOf(a)); + Assert.Empty(o.RevealsOf(b)); + Assert.Equal(0, RevealBookSubjects(o)); + } + + // Nothing the observer keeps for a pass reaches a ship that has left: a ship with one gun ended as destroyed and + // one aged out by the prune is garbage once it is gone and its memory has lapsed. + [Fact] + public void ADepartedSubjectIsUnreachableFromTheObserverAfterThePrune() + { + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var gone = AShipIsIdentifiedHalfDestroyedAndRemoved(o); + for (var i = 0; i < 40; i++) Tick(.1f); // past BearingMemory (2 s) + Assert.Equal(0, RevealBookSubjects(o)); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False(gone[0].IsAlive, "the ship"); + Assert.False(gone[1].IsAlive, "its destroyed gun"); + Assert.False(gone[2].IsAlive, "its other gun"); + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private WeakReference[] AShipIsIdentifiedHalfDestroyedAndRemoved(Entity observer) + { + var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + Tick(1f); + observer.EntityInfoGathered[e] = 1f; + ShowOnly(e, 0f, guns[1]); // its departure is below the observer's gate: the other gun's reveal waits for the prune + guns[0].EquippableItem.Durability = 0f; + Tick(.01f); + Assert.Same(guns[1], Assert.Single(observer.RevealsOf(e)).Item); + _zone.Entities.Remove(e); + return new[] { new WeakReference(e), new WeakReference(guns[0]), new WeakReference(guns[1]) }; + } + // ---- The one rule for info per signature ---- [Fact] @@ -968,10 +1123,15 @@ public sealed class ContactRevealTests : IDisposable var sense = SenseOf(o); for (var i = 0; i < 10; i++) sense(); Assert.Equal(19, _zone.Entities.Where(x => x != o).Count(x => o.RevealsOf(x).Any())); // fixture: every ship is revealed - // One of them is identified by o (its destroyed gun is ended in the pass), and one has left, quiet, so its - // entries await the prune and its departure sits in the zone's list. + // One of them is identified by o and its gun is destroyed while it still glows, so the pass ends that reveal + // and the flash cannot re-enter; one has left, quiet, so its entries await the prune and its departure sits + // in the zone's list. var identified = gunItems.Select(g => _zone.Entities.Single(x => x.Equipment.Contains(g))).First(); o.EntityInfoGathered[identified] = 1f; + gunItems[0].EquippableItem.Durability = 0f; + sense(); + Assert.Empty(o.RevealsOf(identified)); // fixture: its gun is destroyed, ended, and still lit + Assert.Contains(identified.VisibilitySources.Values, v => v.Item == gunItems[0] && v.Strength > 0f); var departed = _zone.Entities.Single(x => x.Equipment.Contains(gunItems[18])); foreach (var source in departed.VisibilitySources.Keys.ToArray()) departed.SetSignature(source, SignatureChannel.Reflection, 0f, gunItems[18]); _zone.Entities.Remove(departed); @@ -979,10 +1139,17 @@ public sealed class ContactRevealTests : IDisposable sense(); Assert.Single(o.RevealsOf(departed)); // fixture: awaiting its prune - var before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < 600; i++) sense(); - var after = GC.GetAllocatedBytesForCurrentThread(); + // The smallest of several rounds is the steady-state allocation (FireControlCut12Tests, the Lane and + // Silhouette pin): a one-off JIT or tier-up allocation lands in one round of a busy full-suite run, a + // per-call allocation lands in every round. + var smallest = long.MaxValue; + for (var round = 0; round < 20 && smallest != 0; round++) + { + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 600; i++) sense(); + smallest = Math.Min(smallest, GC.GetAllocatedBytesForCurrentThread() - before); + } - Assert.Equal(0L, after - before); + Assert.Equal(0L, smallest); } } From a23a76b608b25ee8a3fb985e8325fe120df2a7e4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 02:22:12 +0200 Subject: [PATCH 13/17] Step-kill test lights the ship at its death; the unreachability test walks the observer's fields Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 7fc4d6967..3b6a93286 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -975,13 +975,13 @@ public sealed class ContactRevealTests : IDisposable Fire(e, guns[0]); Fire(e, guns[1]); Tick(1f); - ShowOnly(e, 2f, guns[0]); - Assert.True(e.Visibility * strong.PassiveInfo(e.Position.xz, 1f) > _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: the ear passes the gate"); var killed = false; void Kill(ShotOutcome _) { if (killed || !_zone.Entities.Contains(e)) return; killed = true; + ShowOnly(e, 2f, guns[0]); // lit at the moment it dies, as the ear's gate sees it + Assert.True(e.Visibility * strong.PassiveInfo(e.Position.xz, 1f) > _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: the ear passes the gate"); e.Hull.Durability = 0f; e.HullDamage.OnNext(1f); } @@ -1055,39 +1055,59 @@ public sealed class ContactRevealTests : IDisposable } // Nothing the observer keeps for a pass reaches a ship that has left: a ship with one gun ended as destroyed and - // one aged out by the prune is garbage once it is gone and its memory has lapsed. + // one aged out by the prune cannot be reached from the observer (its fields, collections and closures, not the + // zone it shares with everyone) once the ship is gone and its memory has lapsed. [Fact] public void ADepartedSubjectIsUnreachableFromTheObserverAfterThePrune() { var o = Add(float3(0, 0, 0), new[] { "Eye" }); - var gone = AShipIsIdentifiedHalfDestroyedAndRemoved(o); - for (var i = 0; i < 40; i++) Tick(.1f); // past BearingMemory (2 s) - Assert.Equal(0, RevealBookSubjects(o)); - - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - Assert.False(gone[0].IsAlive, "the ship"); - Assert.False(gone[1].IsAlive, "its destroyed gun"); - Assert.False(gone[2].IsAlive, "its other gun"); - } - - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - private WeakReference[] AShipIsIdentifiedHalfDestroyedAndRemoved(Entity observer) - { var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); var guns = Guns(e); Fire(e, guns[0]); Fire(e, guns[1]); Tick(1f); - observer.EntityInfoGathered[e] = 1f; + o.EntityInfoGathered[e] = 1f; ShowOnly(e, 0f, guns[1]); // its departure is below the observer's gate: the other gun's reveal waits for the prune guns[0].EquippableItem.Durability = 0f; Tick(.01f); - Assert.Same(guns[1], Assert.Single(observer.RevealsOf(e)).Item); + Assert.Same(guns[1], Assert.Single(o.RevealsOf(e)).Item); // fixture: one gun ended as destroyed, one waiting _zone.Entities.Remove(e); - return new[] { new WeakReference(e), new WeakReference(guns[0]), new WeakReference(guns[1]) }; + for (var i = 0; i < 40; i++) Tick(.1f); // past BearingMemory (2 s) + Assert.Equal(0, RevealBookSubjects(o)); // fixture: pruned + + foreach (var held in new object[] { e, guns[0], guns[1] }) + Assert.Null(PathFrom(o, held)); + } + + // 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) + { + var seen = new HashSet(ReferenceEqualityComparer.Instance) { root }; + var queue = new Queue<(object node, string path)>(); + queue.Enqueue((root, "observer")); + while (queue.Count > 0) + { + var (node, path) = queue.Dequeue(); + if (ReferenceEquals(node, target)) return path; + var type = node.GetType(); + if (node is Zone || node is ItemManager || node is Type || node is string || type.IsPrimitive || type.IsEnum + || type.Namespace == "System.Reflection") continue; + IEnumerable<(object value, string name)> children; + if (node is Array array) + children = array.Cast().Select((v, i) => (v, "[" + i + "]")); + else + children = Fields(type).Select(f => (f.GetValue(node), "." + f.Name)); + foreach (var (value, name) in children) + if (value != null && seen.Add(value)) queue.Enqueue((value, path + name)); + } + return null; + } + + private static IEnumerable Fields(Type type) + { + for (; type != null; type = type.BaseType) + foreach (var f in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + if (!f.FieldType.IsPointer) yield return f; } // ---- The one rule for info per signature ---- From f815406e9e6395ee59f0e9533a8b459fa0b40ec5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 02:22:42 +0200 Subject: [PATCH 14/17] Qualify ReferenceEqualityComparer (a3 commit does not build: ambiguous name) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ContactRevealTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 3b6a93286..3245be6ff 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1082,7 +1082,7 @@ public sealed class ContactRevealTests : IDisposable // 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) { - var seen = new HashSet(ReferenceEqualityComparer.Instance) { root }; + var seen = new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance) { root }; var queue = new Queue<(object node, string path)>(); queue.Enqueue((root, "observer")); while (queue.Count > 0) From 53ff5f7c01c0803c688aa80e2873838a96988214 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 02:23:33 +0200 Subject: [PATCH 15/17] Unreachability test: the departing ship leaves by the warp path, so its own subscriptions to the observer are not the question Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ContactRevealTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 3245be6ff..4d39e28b6 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1071,6 +1071,7 @@ public sealed class ContactRevealTests : IDisposable guns[0].EquippableItem.Durability = 0f; Tick(.01f); Assert.Same(guns[1], Assert.Single(o.RevealsOf(e)).Item); // fixture: one gun ended as destroyed, one waiting + e.Deactivate(); // the warp path: the ship's own subscriptions to the observer go with it _zone.Entities.Remove(e); for (var i = 0; i < 40; i++) Tick(.1f); // past BearingMemory (2 s) Assert.Equal(0, RevealBookSubjects(o)); // fixture: pruned From ddbd120af05d3d6de0cc2bcd270a65b774fcb2f4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:02:54 +0200 Subject: [PATCH 16/17] Pin the departure gate's strictness with a light exactly at the threshold Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 4d39e28b6..5b5075042 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -644,6 +644,35 @@ public sealed class ContactRevealTests : IDisposable Assert.Equal(ended ? 0 : 1, o.RevealsOf(e).Count()); } + // The gate is strict, as the light-act gate is: a light at removal that lands exactly on the threshold ends nothing. + // The signature is searched, a few floats either side of threshold over info, for the one whose product with the + // observer's info on the spot is exactly the threshold. + [Fact] + public void ADepartureExactlyAtTheLightActGateEndsNothing() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + Fire(e, Guns(e)[0]); + Tick(.01f); + Assert.Single(o.RevealsOf(e)); + var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; + var info = o.PassiveInfo(e.Position.xz, 1f); + var signature = threshold / info; + for (var step = 0; step < 8 && signature * info != threshold; step++) + signature = BitConverter.Int32BitsToSingle(BitConverter.SingleToInt32Bits(signature) + 1); + if (signature * info != threshold) signature = threshold / info; + for (var step = 0; step < 16 && signature * info != threshold; step++) + signature = BitConverter.Int32BitsToSingle(BitConverter.SingleToInt32Bits(signature) - 1); + Assert.True(signature * info == threshold, "fixture: a light exactly at the gate exists"); + ShowOnly(e, signature, Guns(e)[0]); + Assert.True(e.Visibility * info == threshold, "fixture: the ship's light at removal is exactly the gate"); + + _zone.Entities.Remove(e); + Tick(.1f); + + Assert.Single(o.RevealsOf(e)); + } + // A signature on the Emission channel (paint strength, a ping) is not light: it does not make a departure seen. [Fact] public void AnEmissionSignatureDoesNotMakeADepartureSeen() From 0af394bd4b5608c67663ef4f5ad8658ab2d72166 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:03:43 +0200 Subject: [PATCH 17/17] Exact-gate departure test: the observer sits where its info per unit light is a power of two (previous commit's test does not pass) Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 5b5075042..a1aabcfd6 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -645,27 +645,20 @@ public sealed class ContactRevealTests : IDisposable } // The gate is strict, as the light-act gate is: a light at removal that lands exactly on the threshold ends nothing. - // The signature is searched, a few floats either side of threshold over info, for the one whose product with the - // observer's info on the spot is exactly the threshold. + // At 32 the observer's info per unit light is 4 / 32, a power of two, so threshold x 8 is the light whose product + // with it is exactly the threshold. [Fact] public void ADepartureExactlyAtTheLightActGateEndsNothing() { - var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var e = Add(float3(0, 0, 32), new[] { "Gun" }); var o = Add(float3(0, 0, 0), new[] { "Eye" }); Fire(e, Guns(e)[0]); Tick(.01f); Assert.Single(o.RevealsOf(e)); var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; - var info = o.PassiveInfo(e.Position.xz, 1f); - var signature = threshold / info; - for (var step = 0; step < 8 && signature * info != threshold; step++) - signature = BitConverter.Int32BitsToSingle(BitConverter.SingleToInt32Bits(signature) + 1); - if (signature * info != threshold) signature = threshold / info; - for (var step = 0; step < 16 && signature * info != threshold; step++) - signature = BitConverter.Int32BitsToSingle(BitConverter.SingleToInt32Bits(signature) - 1); - Assert.True(signature * info == threshold, "fixture: a light exactly at the gate exists"); - ShowOnly(e, signature, Guns(e)[0]); - Assert.True(e.Visibility * info == threshold, "fixture: the ship's light at removal is exactly the gate"); + Assert.Equal(.125f, o.PassiveInfo(e.Position.xz, 1f)); + ShowOnly(e, threshold * 8f, Guns(e)[0]); + Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) == threshold, "fixture: the ship's light at removal is exactly the gate"); _zone.Entities.Remove(e); Tick(.1f);