From c47ced9cfd703027df780c2fe742fa77e840c877 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:05:16 +0200 Subject: [PATCH] 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() {