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..f55017fc5 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,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer private float _pingLerp; private bool _pinging; private float _pingRadius; - private HashSet _pingedEntities = new HashSet(); + + // 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 { @@ -73,6 +85,17 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer get => _pingRadius; } + public float BeamWidth => _data.BeamWidth == null ? 360f : Evaluate(_data.BeamWidth); + + public float2 Facing => Direction.xz; + + // Whether the offset `diff` (length `dist`) from this sensor lies inside its beam. + private bool InBeam(float2 diff, float dist) + { + var beam = BeamWidth; + return beam >= 360f || dot(Facing, diff) >= dist * cos(beam * .5f * .0174533f); + } + public float PingBrightness => pow(1 - _pingLerp, _data.PingRadiusExponent); public event Action OnPingStart; @@ -116,10 +139,11 @@ 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; - _pingedEntities.Clear(); + _reached.Clear(); OnPingStart?.Invoke(); } } @@ -140,15 +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); @@ -163,15 +186,41 @@ 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) + + var sensitivity = Evaluate(_data.Sensitivity); + var response = _data.PingBoost == null ? 0f : Evaluate(_data.PingBoost); + var credit = 0f; + var heard = false; + + // 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)) { - _pingedEntities.Add(entity); - next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), 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)}%"); @@ -180,6 +229,12 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer 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/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index bbca6a2ad..b12eb6c7f 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -185,12 +185,18 @@ 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; 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; @@ -802,24 +808,36 @@ public abstract class Entity VisibleEnemies.Clear(); VisibleFriendlies.Clear(); _paintInbox.Clear(); - _incomingLocks.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() { - _incomingLocks.Clear(); - _incomingLocks.AddRange(_paintInbox); + _incomingPaints.Clear(); + _incomingPaints.AddRange(_paintInbox); _paintInbox.Clear(); + _pingReturns.Clear(); + _pingReturns.AddRange(_returnInbox); + _returnInbox.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) + internal void ReceiveReturn(PingReturn pingReturn) { if (!_active) return; - _paintInbox.Add(new LockWarning(normalize((emitterPosition - Position).xz), emitterClass, strength)); + _returnInbox.Add(pingReturn); + } + + // 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 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..bed29a429 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 @@ -28,3 +30,46 @@ 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 +} + +// 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; + 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/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); 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..2928e2907 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -0,0 +1,738 @@ +using System; +using System.Collections.Generic; +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 +// 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() } } + }); + 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)); + 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, + 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); + } + + 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, bool factionless = false, string hull = "Skiff", string sensor2 = null, Zone zone = null) + { + 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)); + } + 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 + _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 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)); + 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)); + } + + // 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 + // 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. + [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)] + 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 + } + + 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 = 1e7f }); + 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) + { + Near(0f, Track(a, t)); + Near(0f, Track(c, t)); + } + else if (leg == 1) + { + Near(.1f, Track(a, t)); + Near(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 >= 299f, "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)); + } + + // 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); + } + + // 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 + // 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); + } +} 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() {