From 9de7756006f1e9aaa37708f2165fdc626f6760f5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 01:56:50 +0200 Subject: [PATCH 1/4] Visibility 4b: a ping or a lock paint is an emission heard within its range over its intercept factor Entity.Emit posts an ActiveEmission (item, range / InterceptFactor) from Sensor's sweep and LockWeapon's Track paint; Sense hears every other ship's emissions within HearRange x HearingMultiple x obscuration through RecordReveal, bearing only, feeding no track. SensorData key 13 and LockWeaponData key 34 carry InterceptFactor (absent reads 1). Unverified at this commit: the headless suite has not run yet. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/LockWeapon.cs | 7 + .../Scripts/ServerShared/Behaviors/Sensor.cs | 8 + Assets/Scripts/ServerShared/Entity.cs | 31 ++ Assets/Scripts/ServerShared/Sensing.cs | 14 + .../ContactRevealTests.cs | 74 ++-- tests/Aetheria.Shared.Tests/EmissionTests.cs | 317 ++++++++++++++++++ 6 files changed, 424 insertions(+), 27 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/EmissionTests.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index ee01e6172..271aa64c3 100644 --- a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs @@ -23,6 +23,11 @@ public class LockWeaponData : InstantWeaponData [Inspectable, JsonProperty("decay"), Key(25)] public PerformanceStat Decay = new PerformanceStat(); + + // The ratio of how far this lock sees to how far others hear it (ruling sensor-stat-set); a design without it + // reads as 1. + [Inspectable, JsonProperty("interceptFactor"), Key(34)] + public PerformanceStat InterceptFactor; public override Behavior CreateInstance(EquippedItem item) { @@ -50,6 +55,7 @@ public class LockWeapon : InstantWeapon public float LockAngle { get; private set; } public float DirectionImpact { get; private set; } public float Decay { get; private set; } + public float InterceptFactor => _data.InterceptFactor == null ? 1f : Evaluate(_data.InterceptFactor); public override float Progress => saturate(_cooldown > 0 ? _cooldown : _lock); @@ -103,6 +109,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, Item, PaintMode.Track, Lock); + Entity.Emit(Item, Range / InterceptFactor); } else _lock = saturate(_lock - dt * Decay); } diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index dc384f70f..cf9d161f0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -42,6 +42,11 @@ public class SensorData : BehaviorData // how much a ping reveals is a stat). 360 or more is omnidirectional; a design without it reads as 360. [Inspectable, JsonProperty("beamWidth"), Key(12)] public PerformanceStat BeamWidth; + + // The ratio of how far this sensor sees to how far others hear it (ruling sensor-stat-set); a design without it + // reads as 1. + [Inspectable, JsonProperty("interceptFactor"), Key(13)] + public PerformanceStat InterceptFactor; public override Behavior CreateInstance(EquippedItem item) { @@ -86,6 +91,8 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer public float BeamWidth => _data.BeamWidth == null ? 360f : Evaluate(_data.BeamWidth); + public float InterceptFactor => _data.InterceptFactor == null ? 1f : Evaluate(_data.InterceptFactor); + public float2 Facing => Direction.xz; // Whether the offset `diff` (length `dist`) from this sensor lies inside its beam. @@ -169,6 +176,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer _pingLerp += dt / _data.PingDuration; _pingRadius = lerp(0, Evaluate(_data.PingRange), pow(_pingLerp, _data.PingRadiusExponent)); _reached.IntersectWith(Entity.Zone.Entities); + Entity.Emit(Item, Evaluate(_data.PingRange) / InterceptFactor); if (_pingLerp > 1) EndPing(); } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index c63ad9874..d4e78344a 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -215,6 +215,9 @@ public abstract class Entity private readonly List _returnInbox = new List(); private readonly List _pingReturns = new List(); internal IReadOnlyList PingReturns => _pingReturns; + // What this ship's active sensors and lock paints emit this tick (Emit), heard by other ships' Sense (ruling + // sensor-stat-set): written in Update, read by Sense in the same tick, cleared by the next PublishInbox. + private readonly List _emissions = new List(); // What this observer has seen each ship's items do (ruling visibility-per-capability-reveal), keyed (subject, // 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). @@ -689,6 +692,10 @@ public abstract class Entity return _activeSensors; } + // How much of an emission's reach this observer's instruments hear it at (ruling sensor-stat-set: listeners hear at + // a multiple of an emitter's own range): 1 with an active sensor, 0 without. Listener gear extends it. + private int HearingMultiple() => ActiveSensors().Count > 0 ? 1 : 0; + // 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 // Cut 12.3's fix batch (proportional multi-lane item absorption, below) can call the armour phase once per @@ -858,6 +865,7 @@ public abstract class Entity _incomingPaints.Clear(); _returnInbox.Clear(); _pingReturns.Clear(); + _emissions.Clear(); _reveals.Clear(); } @@ -866,6 +874,7 @@ public abstract class Entity // one tick later for every ordering. internal void PublishInbox() { + _emissions.Clear(); // written in Update, read by Sense in the same tick _incomingPaints.Clear(); foreach (var paint in _paintInbox) { @@ -884,6 +893,15 @@ public abstract class Entity _returnInbox.Add(pingReturn); } + // The callers are Sensor.Execute (a sweeping ping) and LockWeapon.Execute (a lock paint): this ship's `item` emits this + // tick and is heard by another ship's Sense within `hearRange` (the emitter's range over its intercept factor, + // fixed here), bearing only. + internal void Emit(EquippedItem item, float hearRange) + { + if (!_active) return; + _emissions.Add(new ActiveEmission(item, hearRange)); + } + // 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). 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). @@ -962,6 +980,7 @@ public abstract class Entity { if (!_active) return; var threshold = ItemManager.GameplaySettings.TargetDetectionInfoThreshold; + var hearing = HearingMultiple(); var entities = Zone.Entities; for (var i = 0; i < entities.Count; i++) { @@ -974,6 +993,18 @@ public abstract class Entity if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f); if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel); } + // Every emission this ship made this tick that reaches this observer is revealed, bearing only (ruling + // sensor-stat-set), through RecordReveal's one gate. It feeds no track. + if (hearing > 0 && other._emissions.Count > 0) + { + var planar = length(other.Position.xz - Position.xz); + var obscuration = Zone.Obscuration(Position.xz, other.Position.xz); + for (var j = 0; j < other._emissions.Count; j++) + { + var emission = other._emissions[j]; + if (planar <= emission.HearRange * hearing * obscuration) RecordReveal(other, emission.Item, SignatureChannel.Emission); + } + } // 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)) diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index c1f0932d9..a8a11f39f 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -58,6 +58,20 @@ public readonly struct PingReturn } } +// An item's emission this tick, heard by another ship's Sense within `HearRange` x its HearingMultiple x obscuration; +// it decides nothing by itself. +public readonly struct ActiveEmission +{ + public readonly EquippedItem Item; + public readonly float HearRange; + + public ActiveEmission(EquippedItem item, float hearRange) + { + Item = item; + HearRange = hearRange; + } +} + public readonly struct PaintWarning { public readonly float2 Bearing; diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 10ed63fc6..72e918cc9 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -24,7 +24,7 @@ using int2 = CultMath.int2; // 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. // 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 +public sealed partial 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"); @@ -55,6 +55,10 @@ public sealed class ContactRevealTests : IDisposable }); cache.Upsert(Eye("Eye", sensitivity: 2f)); cache.Upsert(Eye("Ear", sensitivity: 4f)); + // Narrow beams (10 degrees, facing +z) so a hearer off the beam is never painted: the ping is heard at its + // PingRange (300) over the intercept factor. + cache.Upsert(Eye("Shout", sensitivity: 2f, interceptFactor: 2f, beamWidth: 10f)); + cache.Upsert(Eye("Roar", sensitivity: 2f, interceptFactor: .5f, beamWidth: 10f)); 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) })); @@ -82,22 +86,8 @@ public sealed class ContactRevealTests : IDisposable 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(Locker("Locker", interceptFactor: null)); + cache.Upsert(Locker("Hushed", interceptFactor: 4f)); cache.Upsert(new ConsumableItemData { Name = "Pinger", Duration = 30f, Shape = new Shape(), @@ -143,18 +133,39 @@ public sealed class ContactRevealTests : IDisposable 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), PingRange = Const(300), PingCooldown = Const(4) - }; - - private static GearData Eye(string name, float sensitivity) => new GearData + // A launcher with Range 1000, heard at that Range over its intercept factor while it paints. + private static GearData Locker(string name, float? interceptFactor) => new GearData { Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, - Behaviors = { SensorBehavior(sensitivity) } + 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), + InterceptFactor = interceptFactor.HasValue ? Const(interceptFactor.Value) : null + } + } + }; + + private static SensorData SensorBehavior(float sensitivity, float? interceptFactor = null, float? beamWidth = null) => 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), PingRange = Const(300), PingCooldown = Const(4), + InterceptFactor = interceptFactor.HasValue ? Const(interceptFactor.Value) : null, + BeamWidth = beamWidth.HasValue ? Const(beamWidth.Value) : null + }; + + private static GearData Eye(string name, float sensitivity, float? interceptFactor = null, float? beamWidth = null) => new GearData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { SensorBehavior(sensitivity, interceptFactor, beamWidth) } }; private static GearData Tool(string name, BehaviorData behavior) => new GearData @@ -1185,10 +1196,19 @@ public sealed class ContactRevealTests : IDisposable for (var s = 0; s < 10; s++) ship.SetSignature(new object(), SignatureChannel.Reflection, 1000f, item); gunItems.Add(item); } + // One ship holds a lock paint on another, so the observer's sense hears an emission on every pass (the observer's + // own Add runs the zone's last update, which is where the emission is made; no update follows). + var painted = Add(float3(40, 0, 300)); + var painter = Add(float3(0, 0, 300), new[] { "Locker" }); + painter.SetIff(painted, true); + painter.EntityInfoGathered[painted] = 1f; + painter.SetTarget(painted); + painter.Aim = float3(1, 0, 0); var o = Add(float3(0, 0, 0), new[] { "Eye" }); 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 + Assert.Equal(19, _zone.Entities.Where(x => x != o && x != painter && x != painted).Count(x => o.RevealsOf(x).Any())); // fixture: every ship is revealed + Assert.Single(o.RevealsOf(painter)); // fixture: the lock's emission is heard // 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. diff --git a/tests/Aetheria.Shared.Tests/EmissionTests.cs b/tests/Aetheria.Shared.Tests/EmissionTests.cs new file mode 100644 index 000000000..ee353b7a5 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/EmissionTests.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using CultMath; +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; + +// Visibility cut 4 (rulings sensor-stat-set, visibility-per-capability-reveal, vapour-cloud-obscuration, +// reveal-ends-on-perception): a ping or a lock paint is an emission, heard by any ship with an active sensor within the +// emitter's range over its intercept factor (thinned by the fog on the line), bearing only, on the tick it is made. The +// numbers are the catalog's own: a ping reaches 300 and a launcher 1000; "Shout" has intercept factor 2 (heard at 150), +// "Roar" .5 (heard at 600), and both beam 10 degrees facing +z, so a hearer off the beam is never painted and its reveal +// is the hearing alone. Every fixture drives the production path (Sensor.Execute and LockWeapon.Execute post through +// Entity.Emit; Sense hears), ticking inside Zone.Update. +public sealed partial class ContactRevealTests +{ + private static Sensor SensorOf(Entity e) => e.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); + + private static EquippedItem SensorItemOf(Entity e) => e.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor)); + + // The pinger's first tick opens its ping cooldown; Ping() is refused before it. Returns the sweeping tick's clock. + private float PingAt(Entity pinger) + { + Tick(.1f); + SensorOf(pinger).Ping(); + Tick(.1f); + return _zone.Time; + } + + // A launcher at the origin locking a ship 200 ahead of it. Two ticks: the first paint is published on the second. + private (Ship locker, Ship target) LockOn(string launcher) + { + var locker = Add(float3(0, 0, 0), new[] { launcher }); + var target = Add(float3(0, 0, 200)); + locker.SetIff(target, true); + locker.EntityInfoGathered[target] = 1f; + locker.SetTarget(target); + locker.Aim = float3(0, 0, 1); + return (locker, target); + } + + // ---- APingIsHeardWithinRangeOverAlpha ---- + + [Fact] + public void APingIsHeardWithinRangeOverAlpha() + { + var p = Add(float3(0, 0, 0), new[] { "Shout" }); + var within = Add(float3(149, 0, 0), new[] { "Eye" }); // PingRange 300 / alpha 2 = 150 + var beyond = Add(float3(0, 0, -151), new[] { "Eye" }); + + var at = PingAt(p); + + var reveal = Assert.Single(within.RevealsOf(p)); + Assert.Same(SensorItemOf(p), reveal.Item); + Assert.Equal(SignatureChannel.Emission, reveal.Channel); + AssertBearing(-1f, 0f, reveal.Bearing); // normalize((P - hearer).xz) + Assert.Equal(at, reveal.Time); + Assert.Empty(within.IncomingPaints); // fixture: off the beam, never painted + Assert.Empty(beyond.RevealsOf(p)); + } + + // ---- ALoudSensorIsHeardBeyondItsReach ---- + + [Fact] + public void ALoudSensorIsHeardBeyondItsReach() + { + var p = Add(float3(0, 0, 0), new[] { "Roar" }); // PingRange 300 / alpha .5 = 600 + var within = Add(float3(599, 0, 0), new[] { "Eye" }); + var beyond = Add(float3(0, 0, -601), new[] { "Eye" }); + + PingAt(p); + + Assert.Single(within.RevealsOf(p)); + Assert.Empty(beyond.RevealsOf(p)); + Assert.Empty(within.IncomingPaints); // fixture: beyond the ping's radius, so never painted + } + + // ---- ALockPaintIsHeardByThirdParties ---- + + // The launcher's Range is 1000; "Hushed" has alpha 4, heard at 250. + [Theory] + [InlineData("Locker", 999f, true)] + [InlineData("Locker", 1001f, false)] + [InlineData("Hushed", 249f, true)] + [InlineData("Hushed", 251f, false)] + public void ALockPaintIsHeardByThirdParties(string launcher, float distance, bool heard) + { + var (locker, target) = LockOn(launcher); + var third = Add(float3(distance, 0, 0), new[] { "Eye" }); + + Tick(.1f); + Tick(.1f); + + Assert.Single(target.IncomingPaints); // fixture: the lock painted B + var warned = Assert.Single(target.RevealsOf(locker)); // B records the launcher through its paint either way + Assert.Equal(SignatureChannel.Emission, warned.Channel); + if (!heard) + { + Assert.Empty(third.RevealsOf(locker)); + return; + } + var reveal = Assert.Single(third.RevealsOf(locker)); + Assert.Same(locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)), reveal.Item); + Assert.Equal(SignatureChannel.Emission, reveal.Channel); + AssertBearing(-1f, 0f, reveal.Bearing); + Assert.Equal(_zone.Time, reveal.Time); + Assert.Empty(third.IncomingPaints); + } + + // ---- AFogBankShortensHearing ---- + + // Alpha 2 reaches 150; a bank of opacity .5 across the line passes half of it: 75. + [Theory] + [InlineData(74f, true)] + [InlineData(76f, false)] + public void AFogBankShortensHearing(float distance, bool heard) + { + var p = Add(float3(0, 0, 0), new[] { "Shout" }); + var hearer = Add(float3(distance, 0, 0), new[] { "Eye" }); + Hand(_zone, float2(30, 0), 5f, .5f); + Assert.Equal(.5f, _zone.Obscuration(p.Position.xz, hearer.Position.xz), 4); // fixture: the bank is on the line + + PingAt(p); + + Assert.Equal(heard ? 1 : 0, hearer.RevealsOf(p).Count()); + } + + // ---- ADeafShipHearsNothing ---- + + [Fact] + public void ADeafShipHearsNothing() + { + var p = Add(float3(0, 0, 0), new[] { "Shout" }); + var deaf = Add(float3(100, 0, 0)); + var hearing = Add(float3(-100, 0, 0), new[] { "Eye" }); + + PingAt(p); + + Assert.Single(hearing.RevealsOf(p)); + Assert.Empty(deaf.RevealsOf(p)); + } + + // ---- AnEmissionIsHeardOnItsOwnTickWhateverTheListOrder ---- + + [Theory] + [InlineData(0f, 0f)] + [InlineData(30f, -20f)] + public void AnEmissionIsHeardOnItsOwnTickWhateverTheListOrder(float vx, float vz) + { + var before = Add(float3(-30, 0, 40), new[] { "Eye" }); + var p = Add(float3(30, 0, 40), new[] { "Shout" }); + var after = Add(float3(0, 0, 0), new[] { "Eye" }); + var start = p.Position; + p.Velocity = float2(vx, vz); + + var at = PingAt(p); + + if (vx != 0f || vz != 0f) Assert.True(length(p.Position - start) > 1f, "fixture: the pinger moved"); + foreach (var observer in new Entity[] { before, after }) + { + var reveal = Assert.Single(observer.RevealsOf(p)); + Assert.Equal(at, reveal.Time); + var toward = normalize((p.Position - observer.Position).xz); + AssertBearing(toward.x, toward.y, reveal.Bearing); + } + } + + // ---- AnEmissionEndsWithItsAct ---- + + [Fact] + public void AnEmissionEndsWithItsAct() + { + var p = Add(float3(0, 0, 0), new[] { "Shout" }); + var heard = Add(float3(100, 0, 0), new[] { "Eye" }); + var late = Add(float3(0, 0, -1000), new[] { "Eye" }); + var ended = false; + SensorOf(p).OnPingEnd += () => ended = true; + PingAt(p); + for (var tick = 0; tick < 40 && !ended; tick++) Tick(.1f); + Assert.True(ended, "fixture: the ping ended"); + var last = Freshest(heard, p); + Assert.Equal(_zone.Time, last); // the ending tick still emitted + + late.Position = float3(-100, 0, 0); // moved into range after the act is over + for (var tick = 0; tick < 3; tick++) Tick(.1f); + + Assert.Equal(last, Freshest(heard, p)); + Assert.Empty(late.RevealsOf(p)); + } + + [Fact] + public void ALockEmissionEndsWhenTheLockLeavesItsAngle() + { + var (locker, target) = LockOn("Locker"); + var third = Add(float3(500, 0, 0), new[] { "Eye" }); + Tick(.1f); + Tick(.1f); + var last = Freshest(third, locker); + Assert.Equal(_zone.Time, last); // fixture: heard on the lock's tick + + locker.Aim = float3(0, 0, -1); // 180 degrees off the target, past LockAngle + for (var tick = 0; tick < 3; tick++) Tick(.1f); + + Assert.Equal(last, Freshest(third, locker)); + } + + // ---- HearingFeedsNoTrack ---- + + [Fact] + public void HearingFeedsNoTrack() + { + var (heardTrack, heardReveals) = RunHeard(ping: true); + var (controlTrack, controlReveals) = RunHeard(ping: false); + + Assert.Equal(1, heardReveals); // fixture: the emission was heard + Assert.Equal(0, controlReveals); + Assert.Equal(controlTrack, heardTrack, 6); + } + + private static (float track, int reveals) RunHeard(bool ping) + { + using var t = new ContactRevealTests(); + var p = t.Add(float3(0, 0, 0), new[] { "Shout" }); + var h = t.Add(float3(100, 0, 0), new[] { "Eye" }); + if (ping) t.PingAt(p); + else + { + t.Tick(.1f); + t.Tick(.1f); + } + return (Track(h, p), h.RevealsOf(p).Count()); + } + + // ---- AnAllyCreditingAReturnEmitsNothing ---- + + // PaintModeTests.AnAllyHearsTheReturnSilently's geometry: A pings T, ally C hears the return. X stands near C and far + // from A, so X hears C if and only if C emitted. + [Fact] + public void AnAllyCreditingAReturnEmitsNothing() + { + var a = Add(float3(0, 0, 0), new[] { "Shout" }); + var t = Add(float3(0, 0, 100)); + var c = Add(float3(30, 0, 140), new[] { "Eye" }); + c.Faction = a.Faction; + var x = Add(float3(100, 0, 200), new[] { "Eye" }); + var returns = typeof(Entity).GetProperty("PingReturns", BindingFlags.Instance | BindingFlags.NonPublic); + + Tick(.1f); + SensorOf(a).Ping(); + var credited = false; + for (var tick = 0; tick < 24; tick++) + { + Tick(.1f); + credited |= ((System.Collections.ICollection) returns.GetValue(c)).Count > 0; + } + + Assert.True(credited, "fixture: C was handed A's return"); + Assert.NotEmpty(t.RevealsOf(a)); // fixture: T was painted + Assert.Empty(x.RevealsOf(c)); + Assert.Empty(x.RevealsOf(a)); // fixture: X is beyond A's 150 + } + + // ---- TheInterceptFactorKeys ---- + + private static MessagePackSerializerOptions EmissionWire => CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly); + + // InterceptFactor is slot 13 of the sensor record and slot 34 of the lock weapon (a launcher is its stored arm; its + // own slot 26 is the guidance curve), and the JSON property interceptFactor. Read as bytes, so a renumbered key or a + // renamed property cannot pass because writer and reader move together. + [Fact] + public void TheInterceptFactorKeys() + { + var sensor = MessagePackSerializer.Serialize(new SensorData { InterceptFactor = Const(2) }, EmissionWire); + var reader = new MessagePackReader(sensor); + Assert.Equal(2, reader.ReadArrayHeader()); + Assert.Equal(5, reader.ReadInt32()); + Assert.Equal(14, reader.ReadArrayHeader()); + for (var slot = 0; slot < 13; slot++) reader.Skip(); + Assert.Equal(2f, MessagePackSerializer.Deserialize(ref reader, EmissionWire).Min); + Assert.Equal(2f, JObject.Parse(JsonConvert.SerializeObject(new SensorData { InterceptFactor = Const(2) }))["interceptFactor"].ToObject().Min); + + var launcher = new LauncherData { InterceptFactor = Const(3), GuidanceCurve = new[] { float4(0, 1, 0, 0) } }; + var bytes = MessagePackSerializer.Serialize(launcher, EmissionWire); + reader = new MessagePackReader(bytes); + Assert.Equal(2, reader.ReadArrayHeader()); + reader.Skip(); // the arm's union index + Assert.Equal(35, reader.ReadArrayHeader()); + for (var slot = 0; slot < 34; slot++) reader.Skip(); + Assert.Equal(3f, MessagePackSerializer.Deserialize(ref reader, EmissionWire).Min); + Assert.Equal(3f, JObject.Parse(JsonConvert.SerializeObject(launcher))["interceptFactor"].ToObject().Min); + + var back = Assert.IsType(MessagePackSerializer.Deserialize(bytes, EmissionWire)); + Assert.Equal(3f, back.InterceptFactor.Min); + Assert.Single(back.GuidanceCurve); + } + + [Fact] + public void AnAbsentInterceptFactorReadsAsOne() + { + var plain = Add(float3(0, 0, 0), new[] { "Eye" }); + var shout = Add(float3(10, 0, 0), new[] { "Shout" }); + var locker = Add(float3(20, 0, 0), new[] { "Locker" }); + var hushed = Add(float3(30, 0, 0), new[] { "Hushed" }); + + Assert.Equal(1f, SensorOf(plain).InterceptFactor); + Assert.Equal(2f, SensorOf(shout).InterceptFactor); + Assert.Equal(1f, locker.Equipment.SelectMany(i => i.Behaviors).OfType().Single().InterceptFactor); + Assert.Equal(4f, hushed.Equipment.SelectMany(i => i.Behaviors).OfType().Single().InterceptFactor); + } +} From 80a27648ad1487d9370f800e4378dabf5044a83b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 01:57:26 +0200 Subject: [PATCH 2/4] Visibility 4b: the departure comment names the whole Visibility, not a non-Emission signature Follow-up ping-signature-test-gaps (stale-non-emission-comment). Comment only; builds. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index d4e78344a..1775ad4b1 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1016,7 +1016,7 @@ public abstract class Entity } } // 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. + // bright as its whole Visibility 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++) { From 8be15c6ac101e1d06d629f134c60de69752fcc85 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 01:57:28 +0200 Subject: [PATCH 3/4] Visibility 4b: pin the departure's item-less hull light, the Emission guard's item-less and zero-strength forms, and that the ping test's ping started Follow-up ping-signature-test-gaps (departure-hull-light-unpinned, guard-null-item-unpinned, ping-test-never-asserts-it-fired). Tests only; unverified until the suite runs. Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 27 +++++++++++++++++++ tests/Aetheria.Shared.Tests/SignatureTests.cs | 10 ++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 72e918cc9..d295849e3 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -408,8 +408,11 @@ public sealed partial class ContactRevealTests : IDisposable var p = t.Add(float3(0, 0, 1000), new[] { "Eye" }); var o = t.Add(float3(0, 0, 0), new[] { "Eye" }); var sensor = p.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); + var fired = false; + sensor.OnPingStart += () => fired = true; t.Tick(.1f); // a sensor's first tick opens its ping cooldown; Ping() is refused before it if (ping) sensor.Ping(); + Assert.Equal(ping, fired); // fixture: the ping started exactly when asked, so the run is not a control in disguise var light = new List { p.Visibility }; for (var tick = 0; tick < 10; tick++) { @@ -695,6 +698,30 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Empty(o.RevealsOf(e)); } + // The hull's own radiation names no item, and the departure still counts it: the light at removal sits on an item-less + // entry alone, a hair above the observer's gate (ends the reveals) or a hair below (ends nothing). + [Theory] + [InlineData(1.01f, true)] + [InlineData(.99f, false)] + public void ADepartureCountsTheHullsOwnLight(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]); + Tick(.01f); + Assert.Single(o.RevealsOf(e)); + var gate = _items.GameplaySettings.TargetDetectionInfoThreshold / o.PassiveInfo(e.Position.xz, 1f); + foreach (var source in e.VisibilitySources.Keys.ToArray()) e.SetSignature(source, SignatureChannel.Reflection, 0f, Guns(e)[0]); + e.SetSignature(new object(), SignatureChannel.Thermal, gate * against, null); + Assert.Equal(gate * against, e.Visibility, 4); + Assert.All(e.VisibilitySources.Values.Where(v => v.Strength > 0f), v => Assert.Null(v.Item)); // fixture: the deciding light names no item + + _zone.Entities.Remove(e); + Tick(.1f); + + 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. // 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. diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 1fb259231..f68fa163c 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -326,15 +326,19 @@ public sealed class SignatureTests : IDisposable // Emission is active emission, never light: SetSignature refuses it before writing, and a Visibility gear // authored on it throws on the Zone.Update that first executes it. - [Fact] - public void SetSignatureRefusesEmission() + [Theory] + [InlineData(true, 5f)] + [InlineData(false, 5f)] + [InlineData(true, 0f)] + [InlineData(false, 0f)] + public void SetSignatureRefusesEmission(bool withItem, float strength) { var r = Build(Armament.Gun, 0f, null); Fire(r); var before = r.Ship.VisibilitySources.ToDictionary(kv => kv.Key, kv => kv.Value); Assert.NotEmpty(before); - Assert.Throws(() => r.Ship.SetSignature(new object(), SignatureChannel.Emission, 5f, r.WeaponItem)); + Assert.Throws(() => r.Ship.SetSignature(new object(), SignatureChannel.Emission, strength, withItem ? r.WeaponItem : null)); Assert.Equal(before.Count, r.Ship.VisibilitySources.Count); foreach (var kv in before) Assert.Equal(kv.Value, r.Ship.VisibilitySources[kv.Key]); From 61e9fe792c0e1b0a1848907899504306aff990f9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 02:15:46 +0200 Subject: [PATCH 4/4] Visibility 4b: paint tests use deaf observers; the sensor record is one slot longer; the key test serializes JSON from a bare launcher Three tests failed at 8be15c6a: TheBeamWidthIsKey12OfUnion5 read a 13-slot sensor record (14 now, key 13 added); TheInterceptFactorKeys serialized a float4 guidance curve to JSON; ASearchPaintFromAPingerThatHasLeftCreatesNoReveal heard the ping while its pinger stood in the zone. The paint tests (revealed-unseen, search-paint reveal, departed pinger and departed locker) now observe with no active sensor so the reveal they pin is the paint's alone. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ContactRevealTests.cs | 8 ++++---- tests/Aetheria.Shared.Tests/EmissionTests.cs | 2 +- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index d295849e3..78037bda5 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -330,7 +330,7 @@ public sealed partial class ContactRevealTests : IDisposable public void APaintRevealsTheEmitterEvenUnseen() { var locker = Add(float3(0, 0, 200), new[] { "Locker" }); - var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var o = Add(float3(0, 0, 0)); // deaf: no active sensor, so the reveal is the paint's alone, never the lock's emission heard locker.SetIff(o, true); locker.EntityInfoGathered[o] = 1f; locker.SetTarget(o); @@ -863,7 +863,7 @@ public sealed partial class ContactRevealTests : IDisposable public void APaintFromAShipThatHasLeftTheZoneRevealsNothing() { var locker = Add(float3(0, 0, 200), new[] { "Locker" }); - var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var o = Add(float3(0, 0, 0)); // deaf: no active sensor, so the reveal is the paint's alone, never the lock's emission heard locker.SetIff(o, true); locker.EntityInfoGathered[o] = 1f; locker.SetTarget(o); @@ -890,7 +890,7 @@ public sealed partial class ContactRevealTests : IDisposable public void ASearchPaintRevealsThePingersSensor() { var p = Add(float3(0, 0, 100), new[] { "Eye" }); - var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var o = Add(float3(0, 0, 0)); // deaf: the reveal is the paint's alone, never the ping's emission heard 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); @@ -916,7 +916,7 @@ public sealed partial class ContactRevealTests : IDisposable public void ASearchPaintFromAPingerThatHasLeftCreatesNoReveal() { var p = Add(float3(0, 0, 100), new[] { "Eye" }); - var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var o = Add(float3(0, 0, 0)); // deaf: it heard nothing of the sweep while the pinger stood in the zone var sensor = (Sensor) p.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor)).Behaviors.Single(b => b is Sensor); Tick(.01f); sensor.Ping(); diff --git a/tests/Aetheria.Shared.Tests/EmissionTests.cs b/tests/Aetheria.Shared.Tests/EmissionTests.cs index ee353b7a5..1a721172a 100644 --- a/tests/Aetheria.Shared.Tests/EmissionTests.cs +++ b/tests/Aetheria.Shared.Tests/EmissionTests.cs @@ -294,7 +294,7 @@ public sealed partial class ContactRevealTests Assert.Equal(35, reader.ReadArrayHeader()); for (var slot = 0; slot < 34; slot++) reader.Skip(); Assert.Equal(3f, MessagePackSerializer.Deserialize(ref reader, EmissionWire).Min); - Assert.Equal(3f, JObject.Parse(JsonConvert.SerializeObject(launcher))["interceptFactor"].ToObject().Min); + Assert.Equal(3f, JObject.Parse(JsonConvert.SerializeObject(new LauncherData { InterceptFactor = Const(3) }))["interceptFactor"].ToObject().Min); var back = Assert.IsType(MessagePackSerializer.Deserialize(bytes, EmissionWire)); Assert.Equal(3f, back.InterceptFactor.Min); diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 43dce6634..09a421db6 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -726,7 +726,7 @@ public sealed class PaintModeTests : IDisposable var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(5, reader.ReadInt32()); - Assert.Equal(13, reader.ReadArrayHeader()); + Assert.Equal(14, reader.ReadArrayHeader()); for (var slot = 0; slot < 12; slot++) reader.Skip(); Assert.Equal(90f, MessagePackSerializer.Deserialize(ref reader, WireOptions).Min);