Merge eureka/aetheria-release-paint-modes: paint modes, swept ping set and ping returns

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 17:39:31 +02:00
commit 12dfcef563
9 changed files with 935 additions and 70 deletions

View file

@ -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)

View file

@ -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;
}
}

View file

@ -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<Entity> _pingedEntities = new HashSet<Entity>();
// 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<Entity> _reached = new HashSet<Entity>();
// 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

View file

@ -185,12 +185,18 @@ public abstract class Entity
public Subject<Unit> HypothermiaDeath = new Subject<Unit>();
public Subject<Entity> TargetedBy = new Subject<Entity>();
public ReactiveProperty<int> TargetedByCount = new ReactiveProperty<int>(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<LockWarning> _paintInbox = new List<LockWarning>();
private readonly List<LockWarning> _incomingLocks = new List<LockWarning>();
public IReadOnlyList<LockWarning> 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<PaintWarning> _paintInbox = new List<PaintWarning>();
private readonly List<PaintWarning> _incomingPaints = new List<PaintWarning>();
public IReadOnlyList<PaintWarning> 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<PingReturn> _returnInbox = new List<PingReturn>();
private readonly List<PingReturn> _pingReturns = new List<PingReturn>();
internal IReadOnlyList<PingReturn> PingReturns => _pingReturns;
public ReactiveProperty<SecurityLevel> CurrentSecurityLevel = new ReactiveProperty<SecurityLevel>(SecurityLevel.Open);
public ReadOnlyReactiveProperty<bool> 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)

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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<Ship> _ships = new List<Ship>();
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<ItemData>(_items.ItemData.GetByName<HullData>(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<ItemData>(_items.ItemData.GetByName<GearData>(sensor));
Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef, Durability = 1, Lot = _nextLot++ }, HardpointCell));
}
if (sensor2 != null)
{
var gearRef2 = _items.ItemData.RefOf<ItemData>(_items.ItemData.GetByName<GearData>(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<Sensor>();
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<Ship, List<PaintWarning[]>> held, Dictionary<Ship, int> crossedAt) SweepAndWatch(Ship pinger, float dt)
{
var sensor = SensorOf(pinger);
sensor.Ping();
var held = _ships.ToDictionary(s => s, s => new List<PaintWarning[]>());
var crossedAt = new Dictionary<Ship, int>();
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<float>();
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<Ship, float>();
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<float>();
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<Sensor>()) 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<BehaviorData>(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<PerformanceStat>(ref reader, WireOptions).Min);
var json = JObject.Parse(JsonConvert.SerializeObject(data));
Assert.Equal(90f, json["beamWidth"].ToObject<PerformanceStat>().Min);
}
}

View file

@ -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<Radiator>(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()
{