From c47ced9cfd703027df780c2fe742fa77e840c877 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:05:16 +0200 Subject: [PATCH 01/11] Paints carry search or track; a ping sweep warns every ship it crosses in its beam; allies hear the return silently LockWarning becomes PaintWarning with a PaintMode (Search for a ping sweep, Track for a lock); Entity.IncomingLocks becomes IncomingPaints. Sensor replaces the _pingedEntities set with a stateless crossing rule over the previous and current wavefront radius, limited to a new BeamWidth stat (key 12, absent reads 360), and credits allied pings from the receiving sensor's own sensitivity and distance. The HUD lock indicator follows Track paints only. Folds in the radiator sampling-instant pin. Unverified at this commit (first build still to run). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 7 +- .../ServerShared/Behaviors/LockWeapon.cs | 18 +- .../Scripts/ServerShared/Behaviors/Sensor.cs | 55 +++- Assets/Scripts/ServerShared/Entity.cs | 26 +- Assets/Scripts/ServerShared/Sensing.cs | 25 ++ .../Aetheria.Shared.Tests/LockWarningTests.cs | 51 ++-- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 289 ++++++++++++++++++ tests/Aetheria.Shared.Tests/SignatureTests.cs | 23 ++ 8 files changed, 432 insertions(+), 62 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/PaintModeTests.cs diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 3e9004a84..3fae48b73 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1457,12 +1457,13 @@ public class ActionGameManager : MonoBehaviour } } - // The strongest lock on the player's ship, read only from IncomingLocks (never another ship's LockWeapon). + // The strongest lock on the player's ship, read only from IncomingPaints (never another ship's LockWeapon). + // Only Track paints are locks; Search paints (ping sweeps) are presented by contact-hud, not here. // Same curves as the outgoing indicator, driven by the warning's Strength. - var warnings = CurrentEntity.IncomingLocks; + var warnings = CurrentEntity.IncomingPaints; var strongest = -1; for (var w = 0; w < warnings.Count; w++) - if (strongest < 0 || warnings[w].Strength > warnings[strongest].Strength) strongest = w; + if (warnings[w].Mode == PaintMode.Track && (strongest < 0 || warnings[w].Strength > warnings[strongest].Strength)) strongest = w; var (incomingIndicator, incomingSpin) = _incomingLockIndicator; incomingIndicator.gameObject.SetActive(strongest >= 0); if (strongest >= 0) diff --git a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index c3e234f47..ac067a1ad 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, Lock); + target.ReceivePaint(Entity.Position, Item?.Data, PaintMode.Track, Lock); } else _lock = saturate(_lock - dt * Decay); } @@ -110,19 +110,3 @@ public class LockWeapon : InstantWeapon return base.Execute(dt); } } - -// What a painted ship senses of one beam on it (ruling sensor-stat-set item 4; strength per ruling -// lock-warning-strength). Strength is the emitter's own lock progress at the paint, 0..1, never summed. -public readonly struct LockWarning -{ - public readonly float2 Bearing; - public readonly EquippableItemData EmitterClass; - public readonly float Strength; - - public LockWarning(float2 bearing, EquippableItemData emitterClass, float strength) - { - Bearing = bearing; - EmitterClass = emitterClass; - Strength = strength; - } -} diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index 4225df4f7..760ebc069 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -38,6 +38,11 @@ public class SensorData : BehaviorData [Inspectable, JsonProperty("pingRadiusExponent"), Key(11)] public float PingRadiusExponent = .5f; + + // The width in degrees of the cone a ping sweeps, about the sensor's facing (ruling illuminator-beamforming: + // 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; public override Behavior CreateInstance(EquippedItem item) { @@ -61,7 +66,10 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer private float _pingLerp; private bool _pinging; private float _pingRadius; - private HashSet _pingedEntities = new HashSet(); + private float _previousRadius; + + // Allied sensors mid-sweep this tick, gathered once per Execute (scratch, rebuilt every call). + private readonly List _alliedPingers = new List(); public float Cooldown { @@ -73,6 +81,24 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer get => _pingRadius; } + // The wavefront radius before this tick's step: the ping crossed everything in [PreviousRadius, PingRadius). + public float PreviousRadius => _previousRadius; + + public float BeamWidth => _data.BeamWidth == null ? 360f : Evaluate(_data.BeamWidth); + + public float2 Facing => Direction.xz; + + // The one crossing rule: this tick's ping wavefront of `pinger` passed `subject`, inside the pinger's beam. + // It remembers nothing; the two radii are the whole state, so every observer of the same ping agrees. + private static bool Crossing(Sensor pinger, Entity subject) + { + var diff = subject.Position.xz - pinger.Entity.Position.xz; + var dist = length(diff); + if (dist < pinger._previousRadius || dist >= pinger._pingRadius) return false; + var beam = pinger.BeamWidth; + return beam >= 360f || dot(pinger.Facing, diff) >= dist * cos(beam * .5f * .0174533f); + } + public float PingBrightness => pow(1 - _pingLerp, _data.PingRadiusExponent); public event Action OnPingStart; @@ -119,7 +145,6 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer _pingCooldown = 1; _pingLerp = 0; _pingRadius = 0; - _pingedEntities.Clear(); OnPingStart?.Invoke(); } } @@ -140,6 +165,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer // instance never reaches PowerBus.Step (see Ping()) and so never accrues charge. if (Item != null) _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); + _previousRadius = _pingRadius; if (_pinging) { _pingLerp += dt / _data.PingDuration; @@ -154,6 +180,13 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer _pingCooldown -= dt / Evaluate(_data.PingCooldown); var forward = Direction.xz; + _alliedPingers.Clear(); + foreach (var ally in Entity.Zone.Entities) + { + if (ally == Entity || ally.Faction == null || ally.Faction != Entity.Faction || Entity.IsHostileTo(ally)) continue; + foreach (var sensor in ally.GetBehaviors()) + if (sensor._previousRadius < sensor._pingRadius) _alliedPingers.Add(sensor); + } foreach (var entity in Entity.Zone.Entities) { if (entity == Entity) continue; @@ -163,10 +196,22 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer var obscuration = Entity.Zone.Obscuration(Entity.Position.xz, entity.Position.xz); float previous, next; Entity.EntityInfoGathered.TryGetValue(entity, out previous); - if (!_pingedEntities.Contains(entity) && dist < _pingRadius) + + // A crossing of this entity's own ping paints the subject (it learns it is swept); a crossing of an + // allied ping is heard silently (ruling sensor-stat-set item 3): credit, no paint, no emission. + var crossings = 0; + if (Crossing(this, entity)) { - _pingedEntities.Add(entity); - next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true, obscuration)); + crossings++; + entity.ReceivePaint(Entity.Position, Item?.Data, PaintMode.Search, 0); + } + foreach (var pinger in _alliedPingers) + if (pinger.Entity != entity && Crossing(pinger, entity)) crossings++; + + if (crossings > 0) + { + var response = _data.PingBoost == null ? 0f : Evaluate(_data.PingBoost); + next = saturate(previous + crossings * Gain(entity.Visibility, Evaluate(_data.Sensitivity), response, dist, dt, true, obscuration)); } else { diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index bbca6a2ad..97caa40b7 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -185,12 +185,13 @@ public abstract class Entity public Subject HypothermiaDeath = new Subject(); public Subject TargetedBy = new Subject(); public ReactiveProperty TargetedByCount = new ReactiveProperty(0); - // TargetedBy counts selection, which emits nothing; a lock warning comes only from a paint. LockWeapon.Execute - // reports each tick it paints this ship into the inbox; Update publishes the inbox once, so a warning lives - // exactly one tick after its last paint. - private readonly List _paintInbox = new List(); - private readonly List _incomingLocks = new List(); - public IReadOnlyList IncomingLocks => _incomingLocks; + // TargetedBy counts selection, which emits nothing; a warning comes only from a paint. A search paint is a ping + // sweeping this ship (Sensor.Execute); a track paint is a lock (LockWeapon.Execute). Each reports into the + // inbox the tick it paints; PublishPaints publishes the inbox once, so a warning lives exactly one tick after + // its last paint. + private readonly List _paintInbox = new List(); + private readonly List _incomingPaints = new List(); + public IReadOnlyList IncomingPaints => _incomingPaints; public ReactiveProperty CurrentSecurityLevel = new ReactiveProperty(SecurityLevel.Open); public ReadOnlyReactiveProperty PresencePermitted; @@ -802,7 +803,7 @@ public abstract class Entity VisibleEnemies.Clear(); VisibleFriendlies.Clear(); _paintInbox.Clear(); - _incomingLocks.Clear(); + _incomingPaints.Clear(); } // Zone.Update runs this on every entity before any entity updates, so a paint made during tick N is published @@ -810,16 +811,17 @@ public abstract class Entity // one tick later for every ordering. internal void PublishPaints() { - _incomingLocks.Clear(); - _incomingLocks.AddRange(_paintInbox); + _incomingPaints.Clear(); + _incomingPaints.AddRange(_paintInbox); _paintInbox.Clear(); } - // The only caller is LockWeapon.Execute; strength is the emitter's own Lock at that paint. - internal void ReceivePaint(float3 emitterPosition, EquippableItemData emitterClass, float strength) + // 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) { if (!_active) return; - _paintInbox.Add(new LockWarning(normalize((emitterPosition - Position).xz), emitterClass, strength)); + _paintInbox.Add(new PaintWarning(normalize((emitterPosition - Position).xz), emitterClass, mode, strength)); } 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 449b25a4a..7d66e998e 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -28,3 +28,28 @@ public readonly struct Signature public Signature WithStrength(float strength) => new Signature(Channel, strength, Item); } + +// What a painted ship learns of a beam on it (ruling sensor-stat-set item 4): a Search paint is a ping sweeping +// it (Strength 0); a Track paint is a lock (Strength is the emitter's own lock progress, 0..1, ruling +// lock-warning-strength). Never summed. +public enum PaintMode +{ + Search, + Track +} + +public readonly struct PaintWarning +{ + public readonly float2 Bearing; + public readonly EquippableItemData EmitterClass; + public readonly PaintMode Mode; + public readonly float Strength; + + public PaintWarning(float2 bearing, EquippableItemData emitterClass, PaintMode mode, float strength) + { + Bearing = bearing; + EmitterClass = emitterClass; + Mode = mode; + Strength = strength; + } +} diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index bf05566ac..28b6abbc2 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -6,10 +6,10 @@ using GameCult.Caching; using Xunit; using static CultMath.math; -// A painted ship owns a typed incoming-lock warning (cut lock-warning; rulings sensor-stat-set item 4 and +// A painted ship owns a typed incoming-paint warning (cut lock-warning; rulings sensor-stat-set item 4 and // lock-warning-strength). Every fixture drives the production path: a real LockWeapon in a real ship's gear // executes inside Zone.Update, and the zone publishes every ship's paints before any ship updates. A paint made -// during tick N is therefore what IncomingLocks holds after tick N+1, whatever order the ships sit in the zone. +// during tick N is therefore what IncomingPaints holds after tick N+1, whatever order the ships sit in the zone. public sealed class LockWarningTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-lockwarn-" + Guid.NewGuid().ToString("N")); @@ -129,20 +129,21 @@ public sealed class LockWarningTests : IDisposable Aim(locker, b); Tick(.5f); - Assert.Empty(b.IncomingLocks); // published at the start of the next tick + Assert.Empty(b.IncomingPaints); // published at the start of the next tick Assert.Equal(.3f, launcher.Lock, 4); // fixture: 0.6 speed * 0.5 s on a dead-on, fully sensed target Tick(.5f); - var warning = Assert.Single(b.IncomingLocks); + var warning = Assert.Single(b.IncomingPaints); Assert.Equal(-.6f, warning.Bearing.x, 4); Assert.Equal(-.8f, warning.Bearing.y, 4); Assert.Same(GearOf(locker).Data, warning.EmitterClass); + Assert.Equal(PaintMode.Track, warning.Mode); Assert.Equal(.3f, warning.Strength, 4); // The strength follows the emitter's lock as it grows: a stale or scaled copy would not. Assert.Equal(.6f, launcher.Lock, 4); Tick(0f); - Assert.Equal(.6f, Assert.Single(b.IncomingLocks).Strength, 4); + Assert.Equal(.6f, Assert.Single(b.IncomingPaints).Strength, 4); } // The whole range of strength is pinned, not its middle: lock = 0.6 * dt on a dead-on, fully sensed target. @@ -164,7 +165,7 @@ public sealed class LockWarningTests : IDisposable Assert.Equal(expectedLock, LauncherOf(locker).Lock, 4); Tick(0f); - Assert.Equal(expectedLock, Assert.Single(b.IncomingLocks).Strength, 4); + Assert.Equal(expectedLock, Assert.Single(b.IncomingPaints).Strength, 4); } // A saturated lock is a strength of exactly 1: the launcher's own Execute outside the zone tick, so that the @@ -179,7 +180,7 @@ public sealed class LockWarningTests : IDisposable LauncherOf(locker).Execute(5f); Tick(0f); - Assert.Equal(1f, Assert.Single(b.IncomingLocks).Strength, 4); + Assert.Equal(1f, Assert.Single(b.IncomingPaints).Strength, 4); } // The lock's progress terms: direction (lerp ^ DirectionImpact) and sensor info (info ^ SensorImpact), each @@ -197,7 +198,7 @@ public sealed class LockWarningTests : IDisposable Assert.Equal(.18204f, LauncherOf(locker).Lock, 4); Tick(0f); - Assert.Equal(.18204f, Assert.Single(b.IncomingLocks).Strength, 4); + Assert.Equal(.18204f, Assert.Single(b.IncomingPaints).Strength, 4); } // Dead on, the direction term is 1 and the sensor term alone scales the lock: 0.8 * 0.5^3 = 0.1. @@ -225,7 +226,7 @@ public sealed class LockWarningTests : IDisposable Tick(.5f); Tick(0f); - Assert.Single(b.IncomingLocks); + Assert.Single(b.IncomingPaints); Assert.DoesNotContain(locker, b.VisibleEnemies); } @@ -246,8 +247,8 @@ public sealed class LockWarningTests : IDisposable Tick(.5f); Tick(0f); - Assert.Empty(b.IncomingLocks); - Assert.Empty(c.IncomingLocks); // C is aimed at but not the target: nothing paints it either + Assert.Empty(b.IncomingPaints); + Assert.Empty(c.IncomingPaints); // C is aimed at but not the target: nothing paints it either } // A broken lock stops warning on the same tick whichever of the two ships the zone updates first. @@ -271,16 +272,16 @@ public sealed class LockWarningTests : IDisposable Aim(locker, b); Tick(.5f); // tick 1: painted - Assert.Empty(b.IncomingLocks); + Assert.Empty(b.IncomingPaints); Tick(.5f); // tick 2: tick 1's paint published, painted again - Assert.Single(b.IncomingLocks); + Assert.Single(b.IncomingPaints); locker.Aim = float3(0, 0, -1); // the lock breaks: decays, paints nothing Tick(.5f); // tick 3: tick 2's paint published, nothing painted - Assert.Single(b.IncomingLocks); + Assert.Single(b.IncomingPaints); Assert.True(LauncherOf(locker).Lock > 0f, "fixture: the lock decays, it does not vanish"); Tick(.5f); // tick 4: nothing to publish - Assert.Empty(b.IncomingLocks); + Assert.Empty(b.IncomingPaints); } [Fact] @@ -294,14 +295,14 @@ public sealed class LockWarningTests : IDisposable Aim(locker, b); Tick(.5f); Tick(.5f); - Assert.Single(b.IncomingLocks); + Assert.Single(b.IncomingPaints); Aim(locker, c); // retarget: aim at C Tick(.5f); // the lock restarts on C: 0.3 Tick(0f); // tick after the retarget: B has nothing newer to publish - Assert.Empty(b.IncomingLocks); - Assert.Equal(.3f, Assert.Single(c.IncomingLocks).Strength, 4); + Assert.Empty(b.IncomingPaints); + Assert.Equal(.3f, Assert.Single(c.IncomingPaints).Strength, 4); Assert.Equal(.3f, launcher.Lock, 4); } @@ -318,11 +319,11 @@ public sealed class LockWarningTests : IDisposable Tick(.5f); Tick(0f); - Assert.Equal(2, b.IncomingLocks.Count); - var strengths = b.IncomingLocks.Select(w => w.Strength).OrderBy(s => s).ToArray(); + Assert.Equal(2, b.IncomingPaints.Count); + var strengths = b.IncomingPaints.Select(w => w.Strength).OrderBy(s => s).ToArray(); Assert.Equal(.23333f, strengths[0], 4); Assert.Equal(.3f, strengths[1], 4); // never summed - Assert.Equal(2, b.IncomingLocks.Select(w => (w.Bearing.x, w.Bearing.y)).Distinct().Count()); + Assert.Equal(2, b.IncomingPaints.Select(w => (w.Bearing.x, w.Bearing.y)).Distinct().Count()); } [Fact] @@ -333,21 +334,21 @@ public sealed class LockWarningTests : IDisposable Aim(locker, b); Tick(.5f); Tick(0f); - Assert.Single(b.IncomingLocks); + Assert.Single(b.IncomingPaints); b.Deactivate(); - Assert.Empty(b.IncomingLocks); + Assert.Empty(b.IncomingPaints); Tick(.5f); // painted while docked or dead b.Activate(); Tick(0f); - Assert.Empty(b.IncomingLocks); // the paint received while inactive was never stored + Assert.Empty(b.IncomingPaints); // the paint received while inactive was never stored // A paint still waiting in the inbox when the ship deactivates does not survive to the next activation. Tick(.5f); b.Deactivate(); b.Activate(); Tick(0f); - Assert.Empty(b.IncomingLocks); + Assert.Empty(b.IncomingPaints); } } diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs new file mode 100644 index 000000000..8eb11a0b2 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; + +// A ping sweep warns every ship its wavefront crosses inside its beam, and allies hear the return silently (cut +// paint-modes; rulings sensor-stat-set items 3 and 4, illuminator-beamforming, shared-track-bearing-only). Every +// fixture drives the production path: real Sensors in real ships tick inside Zone.Update. The numbers a rule is a +// function of are authored here and the expectations are the arithmetic of those numbers: a ping credit is +// visibility x sensitivity x PingBoost x distance, with the subject at a constant visibility of 5 and no clouds. +// The passive curve is zero, so a track moves only by ping credit (or by the decay a test turns on). +public sealed class PaintModeTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-paintmode-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + private static readonly int2 HardpointCell = new int2(0, 0); + private CultCache _openCache; + private ItemManager _items; + private Zone _zone; + private readonly List _ships = new List(); + + public PaintModeTests() + { + Directory.CreateDirectory(_root); + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + var hullShape = new Shape(3, 3); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData + { + Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 1000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } + }); + cache.Upsert(SensorGear("Omni", sensitivity: 2f, pingBoost: .0001f, beamWidth: null)); + cache.Upsert(SensorGear("Beam90", sensitivity: 2f, pingBoost: .0001f, beamWidth: 90f)); + cache.Upsert(SensorGear("Ear", sensitivity: 3f, pingBoost: .0002f, beamWidth: null)); + cache.FlushAsync().Wait(); + } + + _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); + var ledger = new ProvenanceLedger(); + for (var lot = 1; lot < 12; 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, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 2f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }, _ => { }); + _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 GearData SensorGear(string name, float sensitivity, float pingBoost, float? beamWidth) => new GearData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = + { + new SensorData + { + Sensitivity = Const(sensitivity), + SensitivityCurve = new BezierCurve { Keys = new[] { float4(0, 0, 0, 0), float4(1, 0, 0, 0) } }, // no passive gain + PingBoost = Const(pingBoost), PingEnergy = Const(0), PingVisibility = Const(5), PingRange = Const(300), + PingCooldown = Const(4), + BeamWidth = beamWidth.HasValue ? Const(beamWidth.Value) : null + } + } + }; + + private int _nextLot = 2; + + // A ship at `position`; a ship without a `sensor` is a bare hull. Every ship is lit at a constant visibility of 5. + private Ship NewShip(float3 position, string sensor = null, Faction faction = null) + { + var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Skiff")); + var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()) + { Faction = faction ?? new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; + if (sensor != null) + { + var gearRef = _items.ItemData.RefOf(_items.ItemData.GetByName(sensor)); + Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef, Durability = 1, Lot = _nextLot++ }, HardpointCell)); + } + _zone.Entities.Add(ship); + ship.Activate(); + ship.Position = position; + ship.SetSignature(new object(), SignatureChannel.Reflection, 5f, null); + _zone.Update(0f); // brings the gear online + _ships.Add(ship); + return ship; + } + + // One short tick so every sensor's cooldown is below zero and it may ping. + private void Settle() + { + _zone.Update(.01f); + foreach (var ship in _ships) Assert.Equal(5f, ship.Visibility, 3); // fixture: a constant, known light + } + + private static Sensor SensorOf(Entity e) => e.GetBehavior(); + + private static EquippedItem GearOf(Entity e) => e.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor)); + + private static float Track(Entity observer, Entity subject) => + observer.EntityInfoGathered.TryGetValue(subject, out var info) ? info : 0f; + + private void Tick(float dt) => _zone.Update(dt); + + // Pings and ticks until the wavefront has passed everything (the last step overshoots the range a little). + // Returns, per ship, the paints held after each tick and the tick on which the wavefront first passed it. + private (Dictionary> held, Dictionary crossedAt) SweepAndWatch(Ship pinger, float dt) + { + var sensor = SensorOf(pinger); + sensor.Ping(); + var held = _ships.ToDictionary(s => s, s => new List()); + var crossedAt = new Dictionary(); + var ticks = (int)(2.4f / dt); + for (var tick = 1; tick <= ticks; tick++) + { + Tick(dt); + foreach (var ship in _ships) + { + held[ship].Add(ship.IncomingPaints.ToArray()); + var planar = length(ship.Position.xz - pinger.Position.xz); + if (ship != pinger && !crossedAt.ContainsKey(ship) && sensor.PingRadius > planar) crossedAt[ship] = tick; + } + } + return (held, crossedAt); + } + + [Fact] + public void APingWarnsEveryShipItSweeps() + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var ahead = NewShip(float3(0, 0, 100)); + var behind = NewShip(float3(0, 0, -120)); + var sideAlly = NewShip(float3(80, 0, 0), faction: a.Faction); // an ally is swept like anyone + var beyondRange = NewShip(float3(0, 0, 350)); + Settle(); + + var (held, crossedAt) = SweepAndWatch(a, .1f); + + foreach (var swept in new[] { ahead, behind, sideAlly }) + { + Assert.True(crossedAt.TryGetValue(swept, out var crossed), "fixture: the wavefront reaches the ship"); + // One paint, published on the tick after the crossing (list index crossed = tick crossed + 1), gone after. + var counts = held[swept].Select(h => h.Length).ToArray(); + Assert.Equal(1, counts[crossed]); + Assert.Equal(1, counts.Sum()); + var paint = held[swept][crossed].Single(); + Assert.Equal(PaintMode.Search, paint.Mode); + Assert.Equal(0f, paint.Strength); + Assert.Same(GearOf(a).Data, paint.EmitterClass); + var toward = normalize((a.Position - swept.Position).xz); + Assert.Equal(toward.x, paint.Bearing.x, 4); + Assert.Equal(toward.y, paint.Bearing.y, 4); + } + + Assert.False(crossedAt.ContainsKey(beyondRange), "fixture: the wavefront never reaches the far ship"); + Assert.All(held[beyondRange], h => Assert.Empty(h)); + Assert.All(held[a], h => Assert.Empty(h)); // a ship is not swept by its own ping + } + + [Fact] + public void APingOutsideItsBeamPaintsNothing() + { + // Beam 90 degrees facing +x: half-width 45. + var a = NewShip(float3(0, 0, 0), "Beam90"); + a.Direction = float2(1, 0); + float3 At(float degrees) => float3(100 * cos(degrees * .0174533f), 0, 100 * sin(degrees * .0174533f)); + var onAxis = NewShip(At(0)); + var justInside = NewShip(At(44)); + var justOutside = NewShip(At(46)); + var abeam = NewShip(At(90)); + var astern = NewShip(At(180)); + Settle(); + + var (held, crossedAt) = SweepAndWatch(a, .1f); + + foreach (var ship in new[] { onAxis, justInside }) + { + Assert.Equal(1, held[ship].Sum(h => h.Length)); + Assert.True(Track(a, ship) > 0f, "a ship in the beam is credited"); + } + foreach (var ship in new[] { justOutside, abeam, astern }) + { + Assert.True(crossedAt.ContainsKey(ship), "fixture: the wavefront reaches the ship's distance"); + Assert.Equal(0, held[ship].Sum(h => h.Length)); + Assert.Equal(0f, Track(a, ship)); + } + } + + // The one credit a ping gives: 5 x 2 x .0001 x 100 = 0.1, whatever the tick length. + [Theory] + [InlineData(1f / 120f)] + [InlineData(1f / 10f)] + public void OneCreditPerSweep(float dt) + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var b = NewShip(float3(0, 0, 100)); + Settle(); + + SweepAndWatch(a, dt); + + Assert.Equal(.1f, Track(a, b), 4); + } + + [Fact] + public void AnAllyHearsTheReturnSilently() + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var t = NewShip(float3(0, 0, 100)); + var c = NewShip(float3(30, 0, 140), "Ear", a.Faction); // 50 from T; its own sensitivity and boost + var d = NewShip(float3(30, 0, 100), "Ear"); // another faction + var e = NewShip(float3(-30, 0, 130), "Ear", a.Faction); // same faction but hostile to A + e.SetIff(a, true); + Settle(); + var cEmission = c.SignatureOn(SignatureChannel.Emission); + + var (held, _) = SweepAndWatch(a, .1f); + + // C: 5 x 3 x .0002 x 50 = .15, from C's own sensor and C's own distance; A's own credit is .1. + Assert.Equal(.15f, Track(c, t), 4); + Assert.Equal(.1f, Track(a, t), 4); + // Nothing of C is emitted or painted: T's only paint is A's sweep. + Assert.Equal(cEmission, c.SignatureOn(SignatureChannel.Emission)); + var paint = Assert.Single(held[t].SelectMany(h => h)); + Assert.Same(GearOf(a).Data, paint.EmitterClass); + // A hostile or foreign listener hears nothing of A's ping. + Assert.Equal(0f, Track(d, t)); + Assert.Equal(0f, Track(e, t)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void KillingTheIlluminatorBlindsTheAlly(bool illuminatorDies) + { + _items.GameplaySettings.TargetInfoDecay = .5f; + var a = NewShip(float3(0, 0, 0), "Omni"); + var t = NewShip(float3(0, 0, 100)); + var c = NewShip(float3(30, 0, 140), "Ear", a.Faction); + Settle(); + c.EntityInfoGathered[t] = .5f; + + SensorOf(a).Ping(); + var track = new List(); + for (var tick = 1; tick <= 12; tick++) + { + Tick(.1f); + if (tick == 2 && illuminatorDies) + { + Assert.True(SensorOf(a).PingRadius < 100f, "fixture: A dies before its wavefront reaches T"); + _zone.Entities.Remove(a); + } + track.Add(Track(c, t)); + } + + var rises = Enumerable.Range(1, track.Count - 1).Count(i => track[i] > track[i - 1]); + if (illuminatorDies) + { + Assert.Equal(0, rises); + Assert.True(track.Last() > 0f && track.Last() < .5f); + } + else Assert.Equal(1, rises); // the allied sweep crossing T is the one rise + } +} diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index cc0dbd7d8..c9b7efb8a 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -282,6 +282,29 @@ public sealed class SignatureTests : IDisposable Assert.True(Math.Abs(seen[0] - seen[1]) > 1e-3, "fixture: the two temperatures differ"); } + // A tick that cools the radiator publishes the strength of the temperature it started the tick at, not the + // cooled one (follow-up radiator-sampling-instant-pin). + [Fact] + public void ARadiatorsSignatureIsSampledBeforeTheTicksCooling() + { + _radiationMultiplier = .001f; + _radiationExponent = 2f; + var r = Build(Armament.None, 0f, null, new RadiatorData + { + Emissivity = Constant(3), PumpedHeat = Constant(0), TemperatureFloor = 0, WasteHeat = Constant(0), EnergyUsage = Constant(0), ThermalMass = Constant(1000) + }); + var radiator = Single(r.Ship); + var before = radiator.RadiatorTemperature; + Assert.True(before > 1f, "fixture: the radiator is warm"); + + radiator.Update(1f); + + var cooled = radiator.RadiatorTemperature; + Assert.True(cooled < before - 1e-3, "fixture: this tick cooled the radiator"); + var expected = Math.Pow(before, 2) * .001 * 3; + Assert.InRange(Of(r.Ship, radiator).Strength, expected * .999, expected * 1.001); + } + [Fact] public void AReflectorShowsAReflectionWithItsItem() { From 9dc4ae092fa4be43d0d95bb7a06798b3600102d6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:06:24 +0200 Subject: [PATCH 02/11] Sensing.cs: using CultMath for PaintWarning's float2 (the previous commit does not build) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Sensing.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index 7d66e998e..7cb47c4fb 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +using CultMath; + // Light signatures split by channel (ruling visibility-per-capability-reveal): an observer learns what a ship // is by which channel carries the light. Append-only: values are serialized in VisibilityData. public enum SignatureChannel From 5731fcee944ab8b3051dad7c2f07b8879347b76e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:07:47 +0200 Subject: [PATCH 03/11] PaintModeTests: the hull radiates nothing, so visibility is the fixture's own constant Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 8eb11a0b2..2b2fab6b1 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -60,7 +60,8 @@ public sealed class PaintModeTests : IDisposable UnaidedTracking = 10f, UnaidedPrecision = 2f, AgentMinHitProbability = 0f, - BeamResolveInterval = .1f + BeamResolveInterval = .1f, + HeatRadiationMultiplier = 0f // the hull radiates nothing, so a ship's light is the one source the fixture adds }, _ => { }); _zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); } From 8c5825ed9497c4cfc8c79d2bc52c9949d69b240b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:09:32 +0200 Subject: [PATCH 04/11] PaintModeTests: concurrent sweeps credit once per crossing; factionless ships are not allies Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 2b2fab6b1..ec5019de7 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -94,11 +94,11 @@ public sealed class PaintModeTests : IDisposable private int _nextLot = 2; // A ship at `position`; a ship without a `sensor` is a bare hull. Every ship is lit at a constant visibility of 5. - private Ship NewShip(float3 position, string sensor = null, Faction faction = null) + private Ship NewShip(float3 position, string sensor = null, Faction faction = null, bool factionless = false) { var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Skiff")); var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()) - { Faction = faction ?? new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; + { Faction = factionless ? null : faction ?? new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; if (sensor != null) { var gearRef = _items.ItemData.RefOf(_items.ItemData.GetByName(sensor)); @@ -254,6 +254,39 @@ public sealed class PaintModeTests : IDisposable Assert.Equal(0f, Track(e, t)); } + // Two sweeps crossing one subject on one tick are two observations: C's own ping and A's ping heard as an ally. + // A and C stand 100 from T, so both wavefronts pass it on the same tick. + [Fact] + public void ConcurrentSweepsCreditOncePerCrossing() + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var t = NewShip(float3(0, 0, 100)); + var c = NewShip(float3(100, 0, 100), "Ear", a.Faction); + Settle(); + + SensorOf(a).Ping(); + SensorOf(c).Ping(); + for (var tick = 0; tick < 24; tick++) Tick(.1f); + + Assert.Equal(2 * .3f, Track(c, t), 4); // 5 x 3 x .0002 x 100, its own ping and A's + Assert.Equal(2 * .1f, Track(a, t), 4); // 5 x 2 x .0001 x 100, its own ping and C's + } + + // Allegiance needs a faction: two factionless ships are not allies, so one hears nothing of the other's ping. + [Fact] + public void FactionlessShipsAreNotAllies() + { + var a = NewShip(float3(0, 0, 0), "Omni", factionless: true); + var t = NewShip(float3(0, 0, 100)); + var drifter = NewShip(float3(30, 0, 140), "Ear", factionless: true); + Settle(); + + SweepAndWatch(a, .1f); + + Assert.Equal(.1f, Track(a, t), 4); + Assert.Equal(0f, Track(drifter, t)); + } + [Theory] [InlineData(false)] [InlineData(true)] From c1d5487b72a2dcc49ea90d5af33ef34e2ee79989 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:24:57 +0200 Subject: [PATCH 05/11] PaintModeTests: a pinging ship is not a subject of its own ping, observed through its ally Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index ec5019de7..f0115ce8c 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -270,6 +270,11 @@ public sealed class PaintModeTests : IDisposable Assert.Equal(2 * .3f, Track(c, t), 4); // 5 x 3 x .0002 x 100, its own ping and A's Assert.Equal(2 * .1f, Track(a, t), 4); // 5 x 2 x .0001 x 100, its own ping and C's + // A pinging ship is not a subject of its own ping, wherever its wavefront starts: A sees C once (A's own + // sweep), at C's light of 10 (5 reflected, 5 emitted by the ping) and 100 x sqrt 2 away; and C sees A once. + var apart = length(float2(100, 100)); + Assert.Equal(10 * 2 * .0001f * apart, Track(a, c), 4); + Assert.Equal(10 * 3 * .0002f * apart, Track(c, a), 4); } // Allegiance needs a faction: two factionless ships are not allies, so one hears nothing of the other's ping. From e1827c9db015d72ba252e84e3b55b806b55ebd2c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 15:22:08 +0200 Subject: [PATCH 06/11] PaintModeTests: the swept-set and bistatic-echo rules, written against the c1d5487b sensor Eight new tests (moving subjects, list order, stopped illuminator, bistatic oval, cloud legs, drift behind the front, late arrival, BeamWidth key 12 and its JSON name) and the ally credit of AnAllyHearsTheReturnSilently restated as bistatic (.21213). This commit builds; the moving-subject, list-order, stopped-illuminator, oval, cloud and ally-echo tests are expected to fail against the Sensor of c1d5487b, which the next commit replaces. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 269 +++++++++++++++++- 1 file changed, 267 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index f0115ce8c..3a047cdb1 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -4,8 +4,15 @@ using System.IO; using System.Linq; 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; // A ping sweep warns every ship its wavefront crosses inside its beam, and allies hear the return silently (cut // paint-modes; rulings sensor-stat-set items 3 and 4, illuminator-beamforming, shared-track-bearing-only). Every @@ -242,8 +249,9 @@ public sealed class PaintModeTests : IDisposable var (held, _) = SweepAndWatch(a, .1f); - // C: 5 x 3 x .0002 x 50 = .15, from C's own sensor and C's own distance; A's own credit is .1. - Assert.Equal(.15f, Track(c, t), 4); + // C hears the return bistatically, at the geometric mean of A's range to T (100) and its own (50), with its own + // sensor: 5 x 3 x .0002 x sqrt(100 x 50) = .21213. A's own credit is .1. + Assert.Equal(.21213f, Track(c, t), 4); Assert.Equal(.1f, Track(a, t), 4); // Nothing of C is emitted or painted: T's only paint is A's sweep. Assert.Equal(cEmission, c.SignatureOn(SignatureChannel.Emission)); @@ -325,4 +333,261 @@ public sealed class PaintModeTests : IDisposable } else Assert.Equal(1, rises); // the allied sweep crossing T is the one rise } + + private static void Near(float expected, float actual, float tolerance = 1e-3f) => + Assert.True(abs(expected - actual) <= tolerance, $"expected {expected}, got {actual}"); + + // Ships moving relative to the wavefront are crossed exactly once, on the first tick it is past them, whatever + // their speed: 48 subjects 150-225 out, their positions set before each .1 tick, an omni ping of range 300 (its + // radius is 300 x sqrt(.05 x tick) while it runs, 21 ticks). Inward ships close the gap faster than a tick's step + // can bracket; outward ones flee near the front's own speed. + [Theory] + [InlineData(-85f)] + [InlineData(-60f)] + [InlineData(60f)] + public void AMovingSubjectIsCrossedOnce(float outwardSpeed) + { + var a = NewShip(float3(0, 0, 0), "Omni"); + const int count = 48; + const float dt = .1f; + var subjects = new List<(Ship ship, float3 start, float3 outward)>(); + for (var i = 0; i < count; i++) + { + var angle = i * 2 * PI / count; + var outward = float3(cos(angle), 0, sin(angle)); + var start = outward * (150f + 100f * (i % 4) / 4f); // phases differ + subjects.Add((NewShip(start), start, outward)); + } + Settle(); + + var paints = subjects.ToDictionary(s => s.ship, _ => 0); + var crossedAtDistance = new Dictionary(); + SensorOf(a).Ping(); + for (var tick = 1; tick <= 24; tick++) + { + foreach (var (ship, start, outward) in subjects) ship.Position = start + outward * outwardSpeed * tick * dt; + Tick(dt); + foreach (var (ship, start, outward) in subjects) + { + var held = ship.IncomingPaints.Count; + paints[ship] += held; + // A paint is published on the tick after the crossing: the ship was then where the previous tick put it. + if (held > 0) crossedAtDistance[ship] = length((start + outward * outwardSpeed * (tick - 1) * dt).xz); + } + } + + Assert.All(paints.Values, painted => Assert.True(painted <= 1, "a ping crosses a ship at most once")); + if (outwardSpeed < 0) Assert.All(paints.Values, painted => Assert.Equal(1, painted)); + Assert.Contains(paints.Values, painted => painted == 1); // fixture: the sweep reaches ships + foreach (var (ship, _, _) in subjects) + Near(crossedAtDistance.TryGetValue(ship, out var distance) ? .001f * distance : 0f, Track(a, ship)); + } + + // C hears A's ping returned off T: A's wavefront crossed T at 100, C stands 50 from T, so the credit is at the + // geometric mean range, 5 x 3 x .0002 x sqrt(100 x 50) = .21213. It lands on the tick after the crossing (tick + // 3) and does not depend on the order the two ships tick in, or on A still being in the zone. + [Theory] + [InlineData(false, 0)] + [InlineData(true, 0)] + [InlineData(false, 3)] + [InlineData(true, 3)] + public void AnAllysCreditIsTheSameInEveryListOrder(bool allyFirst, int removePingerAfterTick) + { + Ship a, c; + if (allyFirst) + { + c = NewShip(float3(30, 0, 140), "Ear"); + a = NewShip(float3(0, 0, 0), "Omni", c.Faction); + } + else + { + a = NewShip(float3(0, 0, 0), "Omni"); + c = NewShip(float3(30, 0, 140), "Ear", a.Faction); + } + var t = NewShip(float3(0, 0, 100)); + Settle(); + + SensorOf(a).Ping(); + for (var tick = 1; tick <= 24; tick++) + { + Tick(.1f); + if (tick == removePingerAfterTick) _zone.Entities.Remove(a); + Near(tick <= 3 ? 0f : .21213f, Track(c, t)); + Assert.Equal(tick == 4 ? 1 : 0, t.IncomingPaints.Count); + } + } + + // A sensor that stops executing leaves no echo: killed (disabled) after the tick its wavefront crossed T, the + // one return it posted is heard once; before that tick, there is nothing to hear and T is never painted. + [Theory] + [InlineData(2)] + [InlineData(3)] + public void AStoppedIlluminatorLeavesNoEcho(int disableAfterTick) + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var c = NewShip(float3(30, 0, 140), "Ear", a.Faction); + var t = NewShip(float3(0, 0, 100)); + Settle(); + + SensorOf(a).Ping(); + var track = new List(); + var painted = 0; + for (var tick = 1; tick <= 14; tick++) + { + Tick(.1f); + if (tick == disableAfterTick) GearOf(a).Enabled.Value = false; + track.Add(Track(c, t)); + painted += t.IncomingPaints.Count; + } + + if (disableAfterTick == 3) + { + Assert.Equal(1, painted); + Assert.Equal(1, Enumerable.Range(1, track.Count - 1).Count(i => track[i] > track[i - 1])); + Assert.All(track, value => Assert.True(value <= .21213f + 1e-3f)); + Near(.21213f, track.Last()); + } + else + { + Assert.Equal(0, painted); + Assert.All(track, value => Assert.Equal(0f, value)); + } + } + + // The oval of Cassini: an ally hears the return while the illuminated range (A to T, 100) times its own (ally to + // T) is under PingRange squared (300^2 = 90000). Inside it, the credit is at the geometric mean range. + [Theory] + [InlineData(30f, 140f)] // Rr 50: 5000 + [InlineData(0f, 990f)] // Rr 890: 89000 + [InlineData(0f, 1010f)] // Rr 910: 91000, outside + public void AnAllyHearsOnlyInsideTheBistaticOval(float allyX, float allyZ) + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var t = NewShip(float3(0, 0, 100)); + var c = NewShip(float3(allyX, 0, allyZ), "Ear", a.Faction); + Settle(); + var illuminated = 100f; + var received = length(float2(allyX, allyZ - 100f)); + + var (held, _) = SweepAndWatch(a, .1f); + + Assert.Equal(1, held[t].Sum(h => h.Length)); + Near(.1f, Track(a, t)); + Near(illuminated * received < 300f * 300f ? .003f * sqrt(illuminated * received) : 0f, Track(c, t)); + } + + // A vapour cloud thins both legs of the echo (ruling vapour-cloud-obscuration); the pinger's paint on the + // subject is published whatever the cloud (map R3). A at the origin, T 100 ahead, C at (60, 0, 100). + // Leg 0: an opaque cloud on A to T. Leg 1: an opaque cloud on C to T. Leg 2: a half-opaque cloud on A to T. + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void CloudsThinBothLegsOfTheEcho(int leg) + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var t = NewShip(float3(0, 0, 100)); + var c = NewShip(float3(60, 0, 100), "Ear", a.Faction); + var at = leg == 1 ? float2(30, 100) : float2(0, 50); + _zone.Vent(new VapourCloud { Body = new KinematicBody { Position = at }, Radius = 10f, Opacity = leg == 2 ? .5f : 1f, Lifetime = 1000f }); + Settle(); + + SensorOf(a).Ping(); + var painted = 0; + float illuminatedLeg = 0f, receiveLeg = 0f; + for (var tick = 1; tick <= 24; tick++) + { + Tick(.1f); + painted += t.IncomingPaints.Count; + if (tick == 3) illuminatedLeg = _zone.Obscuration(a.Position.xz, t.Position.xz); + if (tick == 4) receiveLeg = _zone.Obscuration(c.Position.xz, t.Position.xz); + } + + Assert.Equal(1, painted); + if (leg == 0) + { + Assert.Equal(0f, Track(a, t)); + Assert.Equal(0f, Track(c, t)); + } + else if (leg == 1) + { + Near(.1f, Track(a, t)); + Assert.Equal(0f, Track(c, t)); + } + else + { + Assert.InRange(illuminatedLeg, .01f, .99f); // fixture: the cloud really thins the leg + Assert.Equal(1f, receiveLeg, 3); + Near(.003f * sqrt(100f * 60f) * sqrt(illuminatedLeg * receiveLeg), Track(c, t)); + } + } + + // The front has passed S, and S was abeam of a narrow beam: it joined the swept set uncredited. S then drifts + // into the beam behind the front; that is not a crossing. + [Fact] + public void ADriftIntoTheBeamBehindTheFrontIsNotACrossing() + { + var a = NewShip(float3(0, 0, 0), "Beam90"); + a.Direction = float2(1, 0); + var s = NewShip(float3(0, 0, 100)); + Settle(); + + SensorOf(a).Ping(); + var painted = 0; + for (var tick = 1; tick <= 24; tick++) + { + Tick(.1f); + painted += s.IncomingPaints.Count; + if (tick == 3) + { + Assert.True(SensorOf(a).PingRadius > 100f, "fixture: the front has passed S"); + s.Position = float3(100, 0, 0); + } + } + + Assert.Equal(0, painted); + Assert.Equal(0f, Track(a, s)); + } + + [Fact] + public void AShipArrivingAfterThePingIsNotSwept() + { + var a = NewShip(float3(0, 0, 0), "Omni"); + Settle(); + SensorOf(a).Ping(); + for (var tick = 1; tick <= 24; tick++) Tick(.1f); + Assert.True(SensorOf(a).PingRadius > 300f, "fixture: the ping has run its course"); + + var arrival = NewShip(float3(0, 0, 100)); + var painted = 0; + for (var tick = 1; tick <= 5; tick++) + { + Tick(.1f); + painted += arrival.IncomingPaints.Count; + } + + Assert.Equal(0, painted); + Assert.Equal(0f, Track(a, arrival)); + } + + private static MessagePackSerializerOptions WireOptions => CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly); + + // BeamWidth is slot 12 of the sensor record, the fifth union arm of BehaviorData, and the JSON property + // beamWidth. Read as bytes, so a renumbered key or a renamed property cannot pass because writer and reader move together. + [Fact] + public void TheBeamWidthIsKey12OfUnion5() + { + var data = new SensorData { BeamWidth = Const(90) }; + var bytes = MessagePackSerializer.Serialize(data, WireOptions); + + var reader = new MessagePackReader(bytes); + Assert.Equal(2, reader.ReadArrayHeader()); + Assert.Equal(5, reader.ReadInt32()); + Assert.Equal(13, reader.ReadArrayHeader()); + for (var slot = 0; slot < 12; slot++) reader.Skip(); + Assert.Equal(90f, MessagePackSerializer.Deserialize(ref reader, WireOptions).Min); + + var json = JObject.Parse(JsonConvert.SerializeObject(data)); + Assert.Equal(90f, json["beamWidth"].ToObject().Min); + } } From dd896528cc724d8b127adf4248fa98569a0e197a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 15:22:29 +0200 Subject: [PATCH 07/11] Sensor: the pinger's swept set decides each crossing once; allies credit a published ping return inside the bistatic oval Deletes the stateless interval crossing (_previousRadius, PreviousRadius, Crossing) and the allied-pinger scan over other ships' Sensors. The pinging Sensor remembers the subjects its wavefront has reached (_reached), in the beam or not, and a subject's first sweeping tick inside the radius is its one crossing. On it the pinger paints, credits itself and posts a PingReturn to each ally; Entity publishes returns with paints (PublishPaints is now PublishInbox) and the ally's own sensors credit them for one tick at the geometric mean range, inside Rt x Rr < PingRange^2, through the geometric mean of both legs' obscuration (ruling ally-echo-bistatic-oval). This commit builds and replaces the sensor the previous commit's tests were written against. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/Behaviors/Sensor.cs | 73 ++++++++++--------- Assets/Scripts/ServerShared/Entity.cs | 20 ++++- Assets/Scripts/ServerShared/Sensing.cs | 18 +++++ Assets/Scripts/ServerShared/Zone.cs | 2 +- 4 files changed, 74 insertions(+), 39 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index 760ebc069..61bf5821b 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -66,10 +66,9 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer private float _pingLerp; private bool _pinging; private float _pingRadius; - private float _previousRadius; - // Allied sensors mid-sweep this tick, gathered once per Execute (scratch, rebuilt every call). - private readonly List _alliedPingers = new List(); + // The subjects this ping's wavefront has reached, in the beam or not; each is crossed at most once per ping. + private readonly HashSet _reached = new HashSet(); public float Cooldown { @@ -81,22 +80,15 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer get => _pingRadius; } - // The wavefront radius before this tick's step: the ping crossed everything in [PreviousRadius, PingRadius). - public float PreviousRadius => _previousRadius; - public float BeamWidth => _data.BeamWidth == null ? 360f : Evaluate(_data.BeamWidth); public float2 Facing => Direction.xz; - // The one crossing rule: this tick's ping wavefront of `pinger` passed `subject`, inside the pinger's beam. - // It remembers nothing; the two radii are the whole state, so every observer of the same ping agrees. - private static bool Crossing(Sensor pinger, Entity subject) + // Whether the offset `diff` (length `dist`) from this sensor lies inside its beam. + private bool InBeam(float2 diff, float dist) { - var diff = subject.Position.xz - pinger.Entity.Position.xz; - var dist = length(diff); - if (dist < pinger._previousRadius || dist >= pinger._pingRadius) return false; - var beam = pinger.BeamWidth; - return beam >= 360f || dot(pinger.Facing, diff) >= dist * cos(beam * .5f * .0174533f); + var beam = BeamWidth; + return beam >= 360f || dot(Facing, diff) >= dist * cos(beam * .5f * .0174533f); } public float PingBrightness => pow(1 - _pingLerp, _data.PingRadiusExponent); @@ -145,6 +137,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer _pingCooldown = 1; _pingLerp = 0; _pingRadius = 0; + _reached.Clear(); OnPingStart?.Invoke(); } } @@ -165,7 +158,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer // instance never reaches PowerBus.Step (see Ping()) and so never accrues charge. if (Item != null) _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); - _previousRadius = _pingRadius; + var sweeping = _pinging; if (_pinging) { _pingLerp += dt / _data.PingDuration; @@ -180,13 +173,6 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer _pingCooldown -= dt / Evaluate(_data.PingCooldown); var forward = Direction.xz; - _alliedPingers.Clear(); - foreach (var ally in Entity.Zone.Entities) - { - if (ally == Entity || ally.Faction == null || ally.Faction != Entity.Faction || Entity.IsHostileTo(ally)) continue; - foreach (var sensor in ally.GetBehaviors()) - if (sensor._previousRadius < sensor._pingRadius) _alliedPingers.Add(sensor); - } foreach (var entity in Entity.Zone.Entities) { if (entity == Entity) continue; @@ -197,31 +183,46 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer float previous, next; Entity.EntityInfoGathered.TryGetValue(entity, out previous); - // A crossing of this entity's own ping paints the subject (it learns it is swept); a crossing of an - // allied ping is heard silently (ruling sensor-stat-set item 3): credit, no paint, no emission. - var crossings = 0; - if (Crossing(this, entity)) - { - crossings++; - entity.ReceivePaint(Entity.Position, Item?.Data, PaintMode.Search, 0); - } - foreach (var pinger in _alliedPingers) - if (pinger.Entity != entity && Crossing(pinger, entity)) crossings++; + var sensitivity = Evaluate(_data.Sensitivity); + var response = _data.PingBoost == null ? 0f : Evaluate(_data.PingBoost); + var credit = 0f; + var heard = false; - if (crossings > 0) + // The first sweeping tick this ping's wavefront is past the entity is its one crossing. Inside the beam + // it paints the subject (it learns it is swept), credits this sensor, and is returned to every ally; an + // ally hears it silently (ruling sensor-stat-set item 3): credit, no paint, no emission. + if (sweeping && dist < _pingRadius && _reached.Add(entity) && InBeam(diff, dist)) { - var response = _data.PingBoost == null ? 0f : Evaluate(_data.PingBoost); - next = saturate(previous + crossings * Gain(entity.Visibility, Evaluate(_data.Sensitivity), response, dist, dt, true, obscuration)); + heard = true; + entity.ReceivePaint(Entity.Position, Item?.Data, 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) + if (ally != Entity && ally != entity && ally.Faction != null && ally.Faction == Entity.Faction && !ally.IsHostileTo(Entity)) + ally.ReceiveReturn(pingReturn); } + + // An ally's ping returned off this subject (ruling ally-echo-bistatic-oval): heard while the + // illuminated and received ranges sit inside the oval Rt x Rr < PingRange^2, credited at their geometric mean. + foreach (var r in Entity.PingReturns) + { + if (r.Subject != entity || r.IlluminatedRange * dist >= r.MaxRange * r.MaxRange) continue; + heard = true; + credit += Gain(entity.Visibility, sensitivity, response, sqrt(r.IlluminatedRange * dist), dt, true, sqrt(r.IlluminatedObscuration * obscuration)); + } + + if (heard) + next = saturate(previous + credit); else { var angle = acos(dot(forward, normalize(diff))); - next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration)); + next = saturate(previous + Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration)); } next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt; //Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%"); Entity.EntityInfoGathered[entity] = next; } + if (!_pinging) _reached.Clear(); return true; } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 97caa40b7..b12eb6c7f 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -187,11 +187,16 @@ public abstract class Entity public ReactiveProperty TargetedByCount = new ReactiveProperty(0); // TargetedBy counts selection, which emits nothing; a warning comes only from a paint. A search paint is a ping // sweeping this ship (Sensor.Execute); a track paint is a lock (LockWeapon.Execute). Each reports into the - // inbox the tick it paints; PublishPaints publishes the inbox once, so a warning lives exactly one tick after + // 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(); 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 + // ship's own sensors for one tick. + private readonly List _returnInbox = new List(); + private readonly List _pingReturns = new List(); + internal IReadOnlyList PingReturns => _pingReturns; public ReactiveProperty CurrentSecurityLevel = new ReactiveProperty(SecurityLevel.Open); public ReadOnlyReactiveProperty PresencePermitted; @@ -804,16 +809,27 @@ public abstract class Entity VisibleFriendlies.Clear(); _paintInbox.Clear(); _incomingPaints.Clear(); + _returnInbox.Clear(); + _pingReturns.Clear(); } // Zone.Update runs this on every entity before any entity updates, so a paint made during tick N is published // at the start of tick N+1 whatever order the emitter and this ship update in; a paint that stops is gone // one tick later for every ordering. - internal void PublishPaints() + internal void PublishInbox() { _incomingPaints.Clear(); _incomingPaints.AddRange(_paintInbox); _paintInbox.Clear(); + _pingReturns.Clear(); + _pingReturns.AddRange(_returnInbox); + _returnInbox.Clear(); + } + + internal void ReceiveReturn(PingReturn pingReturn) + { + if (!_active) return; + _returnInbox.Add(pingReturn); } // The callers are Sensor.Execute (a Search paint, Strength 0) and LockWeapon.Execute (a Track paint, Strength diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index 7cb47c4fb..bed29a429 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -40,6 +40,24 @@ public enum PaintMode Track } +// An ally's ping crossed `Subject` at `IlluminatedRange` through `IlluminatedObscuration`; `MaxRange` is that +// sensor's PingRange. Posted on the crossing tick and read for one tick by the receiving ship's own sensors. +public readonly struct PingReturn +{ + public readonly Entity Subject; + public readonly float IlluminatedRange; + public readonly float IlluminatedObscuration; + public readonly float MaxRange; + + public PingReturn(Entity subject, float illuminatedRange, float illuminatedObscuration, float maxRange) + { + Subject = subject; + IlluminatedRange = illuminatedRange; + IlluminatedObscuration = illuminatedObscuration; + MaxRange = maxRange; + } +} + public readonly struct PaintWarning { public readonly float2 Bearing; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index ebeee66b8..4aa2063cc 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -227,7 +227,7 @@ public class Zone agent.Update(deltaTime); var entities = Entities.ToArray(); - foreach (var entity in entities) entity.PublishPaints(); + foreach (var entity in entities) entity.PublishInbox(); foreach (var entity in entities) entity.Update(deltaTime); StepMines(deltaTime); From 86323768e05e089eeece5351f58751e839d8c051 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 15:23:28 +0200 Subject: [PATCH 08/11] PaintModeTests: a cloud-thinned echo reads as nothing within the cloud's own fade, not exactly zero The cloud fades linearly over its lifetime, so an opaque cloud's two legs read 3e-5 a few ticks in. The zero cases compare within a tolerance. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 3a047cdb1..4229c4e63 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -506,13 +506,13 @@ public sealed class PaintModeTests : IDisposable Assert.Equal(1, painted); if (leg == 0) { - Assert.Equal(0f, Track(a, t)); - Assert.Equal(0f, Track(c, t)); + Near(0f, Track(a, t)); + Near(0f, Track(c, t)); } else if (leg == 1) { Near(.1f, Track(a, t)); - Assert.Equal(0f, Track(c, t)); + Near(0f, Track(c, t)); } else { From 34a9e72ce19bebf8cb05ef6317a9f32bc2b9c5ef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 15:25:08 +0200 Subject: [PATCH 09/11] PaintModeTests: the cloud fixture lives long enough that its fade does not move the zero cases, and the finished ping is read at its last radius Opacity falls linearly over the cloud's lifetime and the receive leg takes a square root, so a thousand-second cloud turned an opaque leg's 3e-4 into a .004 credit. The finished ping's radius is 300 within float rounding, not above it. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 4229c4e63..011603785 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -489,7 +489,7 @@ public sealed class PaintModeTests : IDisposable var t = NewShip(float3(0, 0, 100)); var c = NewShip(float3(60, 0, 100), "Ear", a.Faction); var at = leg == 1 ? float2(30, 100) : float2(0, 50); - _zone.Vent(new VapourCloud { Body = new KinematicBody { Position = at }, Radius = 10f, Opacity = leg == 2 ? .5f : 1f, Lifetime = 1000f }); + _zone.Vent(new VapourCloud { Body = new KinematicBody { Position = at }, Radius = 10f, Opacity = leg == 2 ? .5f : 1f, Lifetime = 1e7f }); Settle(); SensorOf(a).Ping(); @@ -556,7 +556,7 @@ public sealed class PaintModeTests : IDisposable Settle(); SensorOf(a).Ping(); for (var tick = 1; tick <= 24; tick++) Tick(.1f); - Assert.True(SensorOf(a).PingRadius > 300f, "fixture: the ping has run its course"); + Assert.True(SensorOf(a).PingRadius >= 299f, "fixture: the ping has run its course"); var arrival = NewShip(float3(0, 0, 100)); var painted = 0; From 0b917630ad15ceea7e78dda8c25f03067a49b77c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 15:26:45 +0200 Subject: [PATCH 10/11] PaintModeTests: a second ping crosses the same ship again Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 011603785..2bdc95b60 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -570,6 +570,27 @@ public sealed class PaintModeTests : IDisposable Assert.Equal(0f, Track(a, arrival)); } + // The swept set is per ping: a second ping crosses the same ship again, paints it again and credits again. + [Fact] + public void ASecondPingCrossesTheSameShipAgain() + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var t = NewShip(float3(0, 0, 100)); + Settle(); + var painted = 0; + for (var ping = 1; ping <= 2; ping++) + { + SensorOf(a).Ping(); + for (var tick = 1; tick <= 45; tick++) // the ping runs 21 ticks, the cooldown is 4 seconds + { + Tick(.1f); + painted += t.IncomingPaints.Count; + } + Near(.1f * ping, Track(a, t)); + } + Assert.Equal(2, painted); + } + private static MessagePackSerializerOptions WireOptions => CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly); // BeamWidth is slot 12 of the sensor record, the fifth union arm of BehaviorData, and the JSON property From d2709d550e38bdef97406def9726c64b0a0b8e60 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 16:42:16 +0200 Subject: [PATCH 11/11] Sensor: one reset of the swept set; a ping belongs to its zone; PaintModeTests pin the ending step and the return inbox The idle _reached.Clear() at the end of Execute is deleted: Ping() is the one per-ping reset (ASecondPingCrossesTheSameShipAgain). A sweeping tick drops reached subjects that left the zone, and a ping ends when its pinger is in another zone than it began in. New tests: the ending step's band, every return credited, an inactive ally posted none, Deactivate clearing the inbox, a ping ending on a zone change, and a leave-and-return subject crossed again. Not yet verified: no verify job has run on this commit. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/Behaviors/Sensor.cs | 21 ++- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 134 +++++++++++++++++- 2 files changed, 144 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index 61bf5821b..f55017fc5 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -68,8 +68,13 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer private float _pingRadius; // The subjects this ping's wavefront has reached, in the beam or not; each is crossed at most once per ping. + // Ping() is the one reset. A sweeping tick drops every subject no longer in the zone, so a subject that + // leaves and comes back inside the front is reached again, as a ship arriving inside the front is. private readonly HashSet _reached = new HashSet(); + // The zone the ping began in: a ping belongs to it, and ends when the pinger is anywhere else. + private Zone _pingZone; + public float Cooldown { get => saturate(_pingCooldown); @@ -134,6 +139,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer { Entity.SetSignature(this, SignatureChannel.Emission, Evaluate(_data.PingVisibility), Item); // interim: emissions-heard replaces this with a range-gated emission _pinging = true; + _pingZone = Entity.Zone; _pingCooldown = 1; _pingLerp = 0; _pingRadius = 0; @@ -158,16 +164,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer // instance never reaches PowerBus.Step (see Ping()) and so never accrues charge. if (Item != null) _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); + if (_pinging && Entity.Zone != _pingZone) EndPing(); var sweeping = _pinging; if (_pinging) { _pingLerp += dt / _data.PingDuration; _pingRadius = lerp(0, Evaluate(_data.PingRange), pow(_pingLerp, _data.PingRadiusExponent)); - if (_pingLerp > 1) - { - _pinging = false; - OnPingEnd?.Invoke(); - } + _reached.IntersectWith(Entity.Zone.Entities); + if (_pingLerp > 1) EndPing(); } _pingCooldown -= dt / Evaluate(_data.PingCooldown); @@ -222,10 +226,15 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer //Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%"); Entity.EntityInfoGathered[entity] = next; } - if (!_pinging) _reached.Clear(); return true; } + private void EndPing() + { + _pinging = false; + OnPingEnd?.Invoke(); + } + // Mining Cut 3 (docs/mining-cut-refresh.md): the one detection gain rule -- the info one observation of a // target adds. A ping adds `visibility × sensitivity × PingBoost × distance` once; a passive tick adds // `visibility × sensitivity × curve(angle/π) × dt / distance`. `response` is PingBoost for a ping and the diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 2bdc95b60..2928e2907 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -43,6 +43,14 @@ public sealed class PaintModeTests : IDisposable Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 1000, Mass = 1000, Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } }); + var twinShape = new Shape(3, 3); + foreach (var cell in twinShape.AllCoordinates) twinShape[cell] = true; + cache.Upsert(new HullData + { + Name = "Twin", HullType = HullType.Ship, Shape = twinShape, Durability = 1000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() }, + new HardpointData { Type = HardpointType.Sensors, Position = new int2(1, 0), Shape = new Shape() } } + }); cache.Upsert(SensorGear("Omni", sensitivity: 2f, pingBoost: .0001f, beamWidth: null)); cache.Upsert(SensorGear("Beam90", sensitivity: 2f, pingBoost: .0001f, beamWidth: 90f)); cache.Upsert(SensorGear("Ear", sensitivity: 3f, pingBoost: .0002f, beamWidth: null)); @@ -101,21 +109,27 @@ public sealed class PaintModeTests : IDisposable private int _nextLot = 2; // A ship at `position`; a ship without a `sensor` is a bare hull. Every ship is lit at a constant visibility of 5. - private Ship NewShip(float3 position, string sensor = null, Faction faction = null, bool factionless = false) + private Ship NewShip(float3 position, string sensor = null, Faction faction = null, bool factionless = false, string hull = "Skiff", string sensor2 = null, Zone zone = null) { - var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Skiff")); - var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()) + zone ??= _zone; + var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName(hull)); + var ship = new Ship(_items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()) { Faction = factionless ? null : faction ?? new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; if (sensor != null) { var gearRef = _items.ItemData.RefOf(_items.ItemData.GetByName(sensor)); Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef, Durability = 1, Lot = _nextLot++ }, HardpointCell)); } - _zone.Entities.Add(ship); + if (sensor2 != null) + { + var gearRef2 = _items.ItemData.RefOf(_items.ItemData.GetByName(sensor2)); + Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef2, Durability = 1, Lot = _nextLot++ }, new int2(1, 0))); + } + zone.Entities.Add(ship); ship.Activate(); ship.Position = position; ship.SetSignature(new object(), SignatureChannel.Reflection, 5f, null); - _zone.Update(0f); // brings the gear online + zone.Update(0f); // brings the gear online _ships.Add(ship); return ship; } @@ -591,6 +605,116 @@ public sealed class PaintModeTests : IDisposable Assert.Equal(2, painted); } + // The ping's last step reaches PingRange; a ship in that step's band (the front is at 292.4 before it and 300 + // after) is still swept: the step that ends the ping sweeps. + [Fact] + public void AShipInTheEndingStepsBandIsStillSwept() + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var s = NewShip(float3(0, 0, 295)); + Settle(); + SensorOf(a).Ping(); + var paints = 0; + for (var tick = 1; tick <= 26; tick++) { Tick(.1f); paints += s.IncomingPaints.Count; } + Assert.Equal(1, paints); + Near(5 * 2 * .0001f * 295, Track(a, s)); + } + + // An ally holding two returns off one subject (two pingers on one allied ship) credits each: 2 x 5 x 3 x .0002 x + // sqrt(100 x 50). + [Fact] + public void AnAllyCreditsEveryReturnItHolds() + { + var a = NewShip(float3(0, 0, 0), "Omni", hull: "Twin", sensor2: "Omni"); + var c = NewShip(float3(30, 0, 140), "Ear", a.Faction); + var t = NewShip(float3(0, 0, 100)); + Settle(); + foreach (var s in a.GetBehaviors()) s.Ping(); + for (var tick = 1; tick <= 12; tick++) Tick(.1f); + Near(2 * 5 * 3 * .0002f * sqrt(100f * 50f), Track(c, t)); + } + + // Ticks until the pinger has credited the subject (the crossing tick, on which the returns are posted to allies). + private void TickToTheCrossing(Ship pinger, Ship subject) + { + for (var tick = 1; tick <= 40 && Track(pinger, subject) == 0f; tick++) Tick(.1f); + Assert.True(Track(pinger, subject) > 0f); + } + + // A return posted to an inactive ally is dropped: the ally, active again, hears nothing of that ping. + [Fact] + public void AnInactiveAllyIsPostedNoReturn() + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var t = NewShip(float3(0, 0, 100)); + var c = NewShip(float3(30, 0, 140), "Ear", a.Faction); + Settle(); + c.Deactivate(); + SensorOf(a).Ping(); + TickToTheCrossing(a, t); + c.Activate(); + Tick(.1f); + Assert.Equal(0f, Track(c, t)); + } + + // Deactivating a ship clears the returns it holds: readmitted, it hears nothing of a ping it was docked through. + [Fact] + public void ADeactivatedAllyKeepsNoReturnsForItsNextAdmission() + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var t = NewShip(float3(0, 0, 100)); + var c = NewShip(float3(30, 0, 140), "Ear", a.Faction); + Settle(); + SensorOf(a).Ping(); + TickToTheCrossing(a, t); // the crossing tick posted a return into c + c.Deactivate(); + c.Activate(); + Tick(.1f); + Assert.Equal(0f, Track(c, t)); + } + + // A ping belongs to the zone it began in: a pinger that moves to another zone mid-ping ends it, and the ships of + // the new zone inside the radius it had reached are not swept. + [Fact] + public void APingEndsWhenThePingerLeavesItsZone() + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var z2 = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Zone 2", Owner = null }, null); + var near = NewShip(float3(0, 0, 50), zone: z2); + var far = NewShip(float3(0, 0, 250), zone: z2); + Settle(); + SensorOf(a).Ping(); + for (var tick = 1; tick <= 3; tick++) Tick(.1f); + Assert.True(SensorOf(a).PingRadius > 50f); // the front had passed 50 m by the move + a.Deactivate(); _zone.Entities.Remove(a); a.Zone = z2; z2.Admit(a, false); + var paints = 0; + for (var tick = 1; tick <= 24; tick++) { z2.Update(.1f); paints += near.IncomingPaints.Count + far.IncomingPaints.Count; } + Assert.Equal(0, paints); + Assert.Equal(0f, Track(a, near)); + Assert.Equal(0f, Track(a, far)); + } + + // The swept set is the zone members the front has crossed: a subject that leaves after its crossing and returns + // while the front is still past it is crossed again, as a ship arriving inside the front is. + [Fact] + public void ASubjectThatLeavesAndReturnsIsCrossedAgain() + { + var a = NewShip(float3(0, 0, 0), "Omni"); + var t = NewShip(float3(0, 0, 100)); + Settle(); + SensorOf(a).Ping(); + var paints = 0; + for (var tick = 1; tick <= 30; tick++) + { + if (tick == 5) { _zone.Entities.Remove(t); t.Deactivate(); } + if (tick == 8) { _zone.Entities.Add(t); t.Activate(); } + Tick(.1f); + paints += t.IncomingPaints.Count; + } + Assert.Equal(2, paints); + Near(.1f, Track(a, t)); + } + private static MessagePackSerializerOptions WireOptions => CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly); // BeamWidth is slot 12 of the sensor record, the fifth union arm of BehaviorData, and the JSON property