Merge cut emissions-heard (Soul verdict cut-emissions-heard.s1)

Conflict: ContactRevealTests Locker fixture. Kept the branch's Locker(name, interceptFactor) helper and added master's five munition parts to it (as master's Locker carries them since missile-stats).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-13 03:20:22 +02:00
commit 3c628f54f2
8 changed files with 461 additions and 33 deletions

View file

@ -23,6 +23,11 @@ public class LockWeaponData : InstantWeaponData
[Inspectable, JsonProperty("decay"), Key(25)]
public PerformanceStat Decay = new PerformanceStat();
// The ratio of how far this lock sees to how far others hear it (ruling sensor-stat-set); a design without it
// reads as 1.
[Inspectable, JsonProperty("interceptFactor"), Key(34)]
public PerformanceStat InterceptFactor;
public override Behavior CreateInstance(EquippedItem item)
{
@ -50,6 +55,7 @@ public class LockWeapon : InstantWeapon
public float LockAngle { get; private set; }
public float DirectionImpact { get; private set; }
public float Decay { get; private set; }
public float InterceptFactor => _data.InterceptFactor == null ? 1f : Evaluate(_data.InterceptFactor);
public override float Progress => saturate(_cooldown > 0 ? _cooldown : _lock);
@ -103,6 +109,7 @@ public class LockWeapon : InstantWeapon
var lerp = 1 - unlerp(0, 90, degrees);
_lock = saturate(_lock + pow(lerp, DirectionImpact) * dt * LockSpeed * pow(Entity.EntityInfoGathered[target], SensorImpact));
target.ReceivePaint(Entity, Item, PaintMode.Track, Lock);
Entity.Emit(Item, Range / InterceptFactor);
}
else _lock = saturate(_lock - dt * Decay);
}

View file

@ -42,6 +42,11 @@ public class SensorData : BehaviorData
// how much a ping reveals is a stat). 360 or more is omnidirectional; a design without it reads as 360.
[Inspectable, JsonProperty("beamWidth"), Key(12)]
public PerformanceStat BeamWidth;
// The ratio of how far this sensor sees to how far others hear it (ruling sensor-stat-set); a design without it
// reads as 1.
[Inspectable, JsonProperty("interceptFactor"), Key(13)]
public PerformanceStat InterceptFactor;
public override Behavior CreateInstance(EquippedItem item)
{
@ -86,6 +91,8 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
public float BeamWidth => _data.BeamWidth == null ? 360f : Evaluate(_data.BeamWidth);
public float InterceptFactor => _data.InterceptFactor == null ? 1f : Evaluate(_data.InterceptFactor);
public float2 Facing => Direction.xz;
// Whether the offset `diff` (length `dist`) from this sensor lies inside its beam.
@ -169,6 +176,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
_pingLerp += dt / _data.PingDuration;
_pingRadius = lerp(0, Evaluate(_data.PingRange), pow(_pingLerp, _data.PingRadiusExponent));
_reached.IntersectWith(Entity.Zone.Entities);
Entity.Emit(Item, Evaluate(_data.PingRange) / InterceptFactor);
if (_pingLerp > 1) EndPing();
}

View file

@ -217,6 +217,9 @@ public abstract class Entity
private readonly List<PingReturn> _returnInbox = new List<PingReturn>();
private readonly List<PingReturn> _pingReturns = new List<PingReturn>();
internal IReadOnlyList<PingReturn> PingReturns => _pingReturns;
// What this ship's active sensors and lock paints emit this tick (Emit), heard by other ships' Sense (ruling
// sensor-stat-set): written in Update, read by Sense in the same tick, cleared by the next PublishInbox.
private readonly List<ActiveEmission> _emissions = new List<ActiveEmission>();
// What this observer has seen each ship's items do (ruling visibility-per-capability-reveal), keyed (subject,
// item). Written only by RecordReveal (an act enters) and EndReveals (an end the observer perceives, or a departed
// subject's entries aged past BearingMemory), cleared by Deactivate (ruling reveal-ends-on-perception).
@ -691,6 +694,10 @@ public abstract class Entity
return _activeSensors;
}
// How much of an emission's reach this observer's instruments hear it at (ruling sensor-stat-set: listeners hear at
// a multiple of an emitter's own range): 1 with an active sensor, 0 without. Listener gear extends it.
private int HearingMultiple() => ActiveSensors().Count > 0 ? 1 : 0;
// Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of
// what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so
// Cut 12.3's fix batch (proportional multi-lane item absorption, below) can call the armour phase once per
@ -860,6 +867,7 @@ public abstract class Entity
_incomingPaints.Clear();
_returnInbox.Clear();
_pingReturns.Clear();
_emissions.Clear();
_reveals.Clear();
}
@ -868,6 +876,7 @@ public abstract class Entity
// one tick later for every ordering.
internal void PublishInbox()
{
_emissions.Clear(); // written in Update, read by Sense in the same tick
_incomingPaints.Clear();
foreach (var paint in _paintInbox)
{
@ -886,6 +895,15 @@ public abstract class Entity
_returnInbox.Add(pingReturn);
}
// The callers are Sensor.Execute (a sweeping ping) and LockWeapon.Execute (a lock paint): this ship's `item` emits this
// tick and is heard by another ship's Sense within `hearRange` (the emitter's range over its intercept factor,
// fixed here), bearing only.
internal void Emit(EquippedItem item, float hearRange)
{
if (!_active) return;
_emissions.Add(new ActiveEmission(item, hearRange));
}
// The callers are Sensor.Execute (a Search paint, Strength 0) and LockWeapon.Execute (a Track paint, Strength
// the emitter's own Lock at that paint). The emitter's class is the item's data (null with no item: a
// consumable's effect, which has no item and so is never revealed).
@ -964,6 +982,7 @@ public abstract class Entity
{
if (!_active) return;
var threshold = ItemManager.GameplaySettings.TargetDetectionInfoThreshold;
var hearing = HearingMultiple();
var entities = Zone.Entities;
for (var i = 0; i < entities.Count; i++)
{
@ -976,6 +995,18 @@ public abstract class Entity
if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f);
if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel);
}
// Every emission this ship made this tick that reaches this observer is revealed, bearing only (ruling
// sensor-stat-set), through RecordReveal's one gate. It feeds no track.
if (hearing > 0 && other._emissions.Count > 0)
{
var planar = length(other.Position.xz - Position.xz);
var obscuration = Zone.Obscuration(Position.xz, other.Position.xz);
for (var j = 0; j < other._emissions.Count; j++)
{
var emission = other._emissions[j];
if (planar <= emission.HearRange * hearing * obscuration) RecordReveal(other, emission.Item, SignatureChannel.Emission);
}
}
// The observer resolves this ship's gear (PerceivesDestroyed), so it perceives an item of it destroyed and
// that reveal ends here; RecordReveal refuses the same item's lingering flash.
if (_reveals.TryGetValue(other, out var book))
@ -987,7 +1018,7 @@ public abstract class Entity
}
}
// A subject that left the zone (death, dock, warp, any removal) is perceived gone under the light-act gate: as
// bright as its non-Emission signature was at removal, from where it was. A re-admitted subject did not leave.
// bright as its whole Visibility was at removal, from where it was. A re-admitted subject did not leave.
var departures = Zone.Departures;
for (var i = 0; i < departures.Count; i++)
{

View file

@ -58,6 +58,20 @@ public readonly struct PingReturn
}
}
// An item's emission this tick, heard by another ship's Sense within `HearRange` x its HearingMultiple x obscuration;
// it decides nothing by itself.
public readonly struct ActiveEmission
{
public readonly EquippedItem Item;
public readonly float HearRange;
public ActiveEmission(EquippedItem item, float hearRange)
{
Item = item;
HearRange = hearRange;
}
}
public readonly struct PaintWarning
{
public readonly float2 Bearing;

View file

@ -24,7 +24,7 @@ using int2 = CultMath.int2;
// its info at S x 2 / (d x TargetInfoDecay), and is revealed exactly when that exceeds TargetDetectionInfoThreshold
// (.1). Signatures decay at once (VisibilityDecay 100), so an act is a single tick's light and builds no track.
// The observer senses after every ship has updated, so the zone's list order never decides when an act is revealed.
public sealed class ContactRevealTests : IDisposable
public sealed partial class ContactRevealTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-contact-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
@ -55,6 +55,10 @@ public sealed class ContactRevealTests : IDisposable
});
cache.Upsert(Eye("Eye", sensitivity: 2f));
cache.Upsert(Eye("Ear", sensitivity: 4f));
// Narrow beams (10 degrees, facing +z) so a hearer off the beam is never painted: the ping is heard at its
// PingRange (300) over the intercept factor.
cache.Upsert(Eye("Shout", sensitivity: 2f, interceptFactor: 2f, beamWidth: 10f));
cache.Upsert(Eye("Roar", sensitivity: 2f, interceptFactor: .5f, beamWidth: 10f));
cache.Upsert(Tool("Reactor", new ReactorData { Charge = Const(1000), Efficiency = Const(1), OverloadEfficiency = Const(1), ThrottlingFactor = Const(2) }));
cache.Upsert(Tool("Targeting", new TargetingSystemData { Accuracy = Const(1), Resolution = Const(1000f), Precision = Const(1000f), Tracking = Const(1000000f) }));
cache.Upsert(Tool("Thruster", new ThrusterData { Thrust = Const(100), Visibility = Const(10), Heat = Const(0), EnergyUsage = Const(10) }));
@ -82,24 +86,8 @@ public sealed class ContactRevealTests : IDisposable
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
Behaviors = { gun }
});
cache.Upsert(new GearData
{
Name = "Locker", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors =
{
new LauncherData
{
Damage = Const(0), Range = Const(1000), MinRange = Const(0), Velocity = Const(0),
Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1),
BurstTime = Const(0), Cooldown = Const(1000),
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } },
LockSpeed = Const(.6f), SensorImpact = Const(1), LockAngle = Const(30), DirectionImpact = Const(1), Decay = Const(.1f)
},
// A munition design carries one of each part (missile-stats); the reveal tests read none of them.
new MunitionHullData(), new MunitionThrusterData(), new MunitionSeekerData(), new MunitionControlData(), new MunitionFuelData()
}
});
cache.Upsert(Locker("Locker", interceptFactor: null));
cache.Upsert(Locker("Hushed", interceptFactor: 4f));
cache.Upsert(new ConsumableItemData
{
Name = "Pinger", Duration = 30f, Shape = new Shape(),
@ -145,18 +133,41 @@ public sealed class ContactRevealTests : IDisposable
private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v };
private static SensorData SensorBehavior(float sensitivity) => new SensorData
{
Sensitivity = Const(sensitivity),
SensitivityCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, // the same gain at every bearing
PingBoost = Const(.0001f), PingEnergy = Const(0), PingRange = Const(300), PingCooldown = Const(4)
};
private static GearData Eye(string name, float sensitivity) => new GearData
// A launcher with Range 1000, heard at that Range over its intercept factor while it paints.
private static GearData Locker(string name, float? interceptFactor) => new GearData
{
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { SensorBehavior(sensitivity) }
Behaviors =
{
new LauncherData
{
Damage = Const(0), Range = Const(1000), MinRange = Const(0), Velocity = Const(0),
Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1),
BurstTime = Const(0), Cooldown = Const(1000),
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } },
LockSpeed = Const(.6f), SensorImpact = Const(1), LockAngle = Const(30), DirectionImpact = Const(1), Decay = Const(.1f),
InterceptFactor = interceptFactor.HasValue ? Const(interceptFactor.Value) : null
},
// A munition design carries one of each part (missile-stats); the reveal tests read none of them.
new MunitionHullData(), new MunitionThrusterData(), new MunitionSeekerData(), new MunitionControlData(), new MunitionFuelData()
}
};
private static SensorData SensorBehavior(float sensitivity, float? interceptFactor = null, float? beamWidth = null) => new SensorData
{
Sensitivity = Const(sensitivity),
SensitivityCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, // the same gain at every bearing
PingBoost = Const(.0001f), PingEnergy = Const(0), PingRange = Const(300), PingCooldown = Const(4),
InterceptFactor = interceptFactor.HasValue ? Const(interceptFactor.Value) : null,
BeamWidth = beamWidth.HasValue ? Const(beamWidth.Value) : null
};
private static GearData Eye(string name, float sensitivity, float? interceptFactor = null, float? beamWidth = null) => new GearData
{
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { SensorBehavior(sensitivity, interceptFactor, beamWidth) }
};
private static GearData Tool(string name, BehaviorData behavior) => new GearData
@ -321,7 +332,7 @@ public sealed class ContactRevealTests : IDisposable
public void APaintRevealsTheEmitterEvenUnseen()
{
var locker = Add(float3(0, 0, 200), new[] { "Locker" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var o = Add(float3(0, 0, 0)); // deaf: no active sensor, so the reveal is the paint's alone, never the lock's emission heard
locker.SetIff(o, true);
locker.EntityInfoGathered[o] = 1f;
locker.SetTarget(o);
@ -399,8 +410,11 @@ public sealed class ContactRevealTests : IDisposable
var p = t.Add(float3(0, 0, 1000), new[] { "Eye" });
var o = t.Add(float3(0, 0, 0), new[] { "Eye" });
var sensor = p.Equipment.SelectMany(i => i.Behaviors).OfType<Sensor>().Single();
var fired = false;
sensor.OnPingStart += () => fired = true;
t.Tick(.1f); // a sensor's first tick opens its ping cooldown; Ping() is refused before it
if (ping) sensor.Ping();
Assert.Equal(ping, fired); // fixture: the ping started exactly when asked, so the run is not a control in disguise
var light = new List<float> { p.Visibility };
for (var tick = 0; tick < 10; tick++)
{
@ -686,6 +700,30 @@ public sealed class ContactRevealTests : IDisposable
Assert.Empty(o.RevealsOf(e));
}
// The hull's own radiation names no item, and the departure still counts it: the light at removal sits on an item-less
// entry alone, a hair above the observer's gate (ends the reveals) or a hair below (ends nothing).
[Theory]
[InlineData(1.01f, true)]
[InlineData(.99f, false)]
public void ADepartureCountsTheHullsOwnLight(float against, bool ended)
{
var e = Add(float3(0, 0, 100), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
Fire(e, Guns(e)[0]);
Tick(.01f);
Assert.Single(o.RevealsOf(e));
var gate = _items.GameplaySettings.TargetDetectionInfoThreshold / o.PassiveInfo(e.Position.xz, 1f);
foreach (var source in e.VisibilitySources.Keys.ToArray()) e.SetSignature(source, SignatureChannel.Reflection, 0f, Guns(e)[0]);
e.SetSignature(new object(), SignatureChannel.Thermal, gate * against, null);
Assert.Equal(gate * against, e.Visibility, 4);
Assert.All(e.VisibilitySources.Values.Where(v => v.Strength > 0f), v => Assert.Null(v.Item)); // fixture: the deciding light names no item
_zone.Entities.Remove(e);
Tick(.1f);
Assert.Equal(ended ? 0 : 1, o.RevealsOf(e).Count());
}
// The gate is strict, as the light-act gate is: a light at removal that lands exactly on the threshold ends nothing.
// At 32 the observer's info per unit light is 4 / 32, a power of two, so threshold x 8 is the light whose product
// with it is exactly the threshold.
@ -827,7 +865,7 @@ public sealed class ContactRevealTests : IDisposable
public void APaintFromAShipThatHasLeftTheZoneRevealsNothing()
{
var locker = Add(float3(0, 0, 200), new[] { "Locker" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var o = Add(float3(0, 0, 0)); // deaf: no active sensor, so the reveal is the paint's alone, never the lock's emission heard
locker.SetIff(o, true);
locker.EntityInfoGathered[o] = 1f;
locker.SetTarget(o);
@ -854,7 +892,7 @@ public sealed class ContactRevealTests : IDisposable
public void ASearchPaintRevealsThePingersSensor()
{
var p = Add(float3(0, 0, 100), new[] { "Eye" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var o = Add(float3(0, 0, 0)); // deaf: the reveal is the paint's alone, never the ping's emission heard
var item = p.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor));
var sensor = (Sensor) item.Behaviors.Single(b => b is Sensor);
Tick(.01f);
@ -880,7 +918,7 @@ public sealed class ContactRevealTests : IDisposable
public void ASearchPaintFromAPingerThatHasLeftCreatesNoReveal()
{
var p = Add(float3(0, 0, 100), new[] { "Eye" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var o = Add(float3(0, 0, 0)); // deaf: it heard nothing of the sweep while the pinger stood in the zone
var sensor = (Sensor) p.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor)).Behaviors.Single(b => b is Sensor);
Tick(.01f);
sensor.Ping();
@ -1187,10 +1225,19 @@ public sealed class ContactRevealTests : IDisposable
for (var s = 0; s < 10; s++) ship.SetSignature(new object(), SignatureChannel.Reflection, 1000f, item);
gunItems.Add(item);
}
// One ship holds a lock paint on another, so the observer's sense hears an emission on every pass (the observer's
// own Add runs the zone's last update, which is where the emission is made; no update follows).
var painted = Add(float3(40, 0, 300));
var painter = Add(float3(0, 0, 300), new[] { "Locker" });
painter.SetIff(painted, true);
painter.EntityInfoGathered[painted] = 1f;
painter.SetTarget(painted);
painter.Aim = float3(1, 0, 0);
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var sense = SenseOf(o);
for (var i = 0; i < 10; i++) sense();
Assert.Equal(19, _zone.Entities.Where(x => x != o).Count(x => o.RevealsOf(x).Any())); // fixture: every ship is revealed
Assert.Equal(19, _zone.Entities.Where(x => x != o && x != painter && x != painted).Count(x => o.RevealsOf(x).Any())); // fixture: every ship is revealed
Assert.Single(o.RevealsOf(painter)); // fixture: the lock's emission is heard
// One of them is identified by o and its gun is destroyed while it still glows, so the pass ends that reveal
// and the flash cannot re-enter; one has left, quiet, so its entries await the prune and its departure sits
// in the zone's list.

View file

@ -0,0 +1,317 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using CultMath;
using GameCult.Caching.MessagePack;
using MessagePack;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Visibility cut 4 (rulings sensor-stat-set, visibility-per-capability-reveal, vapour-cloud-obscuration,
// reveal-ends-on-perception): a ping or a lock paint is an emission, heard by any ship with an active sensor within the
// emitter's range over its intercept factor (thinned by the fog on the line), bearing only, on the tick it is made. The
// numbers are the catalog's own: a ping reaches 300 and a launcher 1000; "Shout" has intercept factor 2 (heard at 150),
// "Roar" .5 (heard at 600), and both beam 10 degrees facing +z, so a hearer off the beam is never painted and its reveal
// is the hearing alone. Every fixture drives the production path (Sensor.Execute and LockWeapon.Execute post through
// Entity.Emit; Sense hears), ticking inside Zone.Update.
public sealed partial class ContactRevealTests
{
private static Sensor SensorOf(Entity e) => e.Equipment.SelectMany(i => i.Behaviors).OfType<Sensor>().Single();
private static EquippedItem SensorItemOf(Entity e) => e.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor));
// The pinger's first tick opens its ping cooldown; Ping() is refused before it. Returns the sweeping tick's clock.
private float PingAt(Entity pinger)
{
Tick(.1f);
SensorOf(pinger).Ping();
Tick(.1f);
return _zone.Time;
}
// A launcher at the origin locking a ship 200 ahead of it. Two ticks: the first paint is published on the second.
private (Ship locker, Ship target) LockOn(string launcher)
{
var locker = Add(float3(0, 0, 0), new[] { launcher });
var target = Add(float3(0, 0, 200));
locker.SetIff(target, true);
locker.EntityInfoGathered[target] = 1f;
locker.SetTarget(target);
locker.Aim = float3(0, 0, 1);
return (locker, target);
}
// ---- APingIsHeardWithinRangeOverAlpha ----
[Fact]
public void APingIsHeardWithinRangeOverAlpha()
{
var p = Add(float3(0, 0, 0), new[] { "Shout" });
var within = Add(float3(149, 0, 0), new[] { "Eye" }); // PingRange 300 / alpha 2 = 150
var beyond = Add(float3(0, 0, -151), new[] { "Eye" });
var at = PingAt(p);
var reveal = Assert.Single(within.RevealsOf(p));
Assert.Same(SensorItemOf(p), reveal.Item);
Assert.Equal(SignatureChannel.Emission, reveal.Channel);
AssertBearing(-1f, 0f, reveal.Bearing); // normalize((P - hearer).xz)
Assert.Equal(at, reveal.Time);
Assert.Empty(within.IncomingPaints); // fixture: off the beam, never painted
Assert.Empty(beyond.RevealsOf(p));
}
// ---- ALoudSensorIsHeardBeyondItsReach ----
[Fact]
public void ALoudSensorIsHeardBeyondItsReach()
{
var p = Add(float3(0, 0, 0), new[] { "Roar" }); // PingRange 300 / alpha .5 = 600
var within = Add(float3(599, 0, 0), new[] { "Eye" });
var beyond = Add(float3(0, 0, -601), new[] { "Eye" });
PingAt(p);
Assert.Single(within.RevealsOf(p));
Assert.Empty(beyond.RevealsOf(p));
Assert.Empty(within.IncomingPaints); // fixture: beyond the ping's radius, so never painted
}
// ---- ALockPaintIsHeardByThirdParties ----
// The launcher's Range is 1000; "Hushed" has alpha 4, heard at 250.
[Theory]
[InlineData("Locker", 999f, true)]
[InlineData("Locker", 1001f, false)]
[InlineData("Hushed", 249f, true)]
[InlineData("Hushed", 251f, false)]
public void ALockPaintIsHeardByThirdParties(string launcher, float distance, bool heard)
{
var (locker, target) = LockOn(launcher);
var third = Add(float3(distance, 0, 0), new[] { "Eye" });
Tick(.1f);
Tick(.1f);
Assert.Single(target.IncomingPaints); // fixture: the lock painted B
var warned = Assert.Single(target.RevealsOf(locker)); // B records the launcher through its paint either way
Assert.Equal(SignatureChannel.Emission, warned.Channel);
if (!heard)
{
Assert.Empty(third.RevealsOf(locker));
return;
}
var reveal = Assert.Single(third.RevealsOf(locker));
Assert.Same(locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)), reveal.Item);
Assert.Equal(SignatureChannel.Emission, reveal.Channel);
AssertBearing(-1f, 0f, reveal.Bearing);
Assert.Equal(_zone.Time, reveal.Time);
Assert.Empty(third.IncomingPaints);
}
// ---- AFogBankShortensHearing ----
// Alpha 2 reaches 150; a bank of opacity .5 across the line passes half of it: 75.
[Theory]
[InlineData(74f, true)]
[InlineData(76f, false)]
public void AFogBankShortensHearing(float distance, bool heard)
{
var p = Add(float3(0, 0, 0), new[] { "Shout" });
var hearer = Add(float3(distance, 0, 0), new[] { "Eye" });
Hand(_zone, float2(30, 0), 5f, .5f);
Assert.Equal(.5f, _zone.Obscuration(p.Position.xz, hearer.Position.xz), 4); // fixture: the bank is on the line
PingAt(p);
Assert.Equal(heard ? 1 : 0, hearer.RevealsOf(p).Count());
}
// ---- ADeafShipHearsNothing ----
[Fact]
public void ADeafShipHearsNothing()
{
var p = Add(float3(0, 0, 0), new[] { "Shout" });
var deaf = Add(float3(100, 0, 0));
var hearing = Add(float3(-100, 0, 0), new[] { "Eye" });
PingAt(p);
Assert.Single(hearing.RevealsOf(p));
Assert.Empty(deaf.RevealsOf(p));
}
// ---- AnEmissionIsHeardOnItsOwnTickWhateverTheListOrder ----
[Theory]
[InlineData(0f, 0f)]
[InlineData(30f, -20f)]
public void AnEmissionIsHeardOnItsOwnTickWhateverTheListOrder(float vx, float vz)
{
var before = Add(float3(-30, 0, 40), new[] { "Eye" });
var p = Add(float3(30, 0, 40), new[] { "Shout" });
var after = Add(float3(0, 0, 0), new[] { "Eye" });
var start = p.Position;
p.Velocity = float2(vx, vz);
var at = PingAt(p);
if (vx != 0f || vz != 0f) Assert.True(length(p.Position - start) > 1f, "fixture: the pinger moved");
foreach (var observer in new Entity[] { before, after })
{
var reveal = Assert.Single(observer.RevealsOf(p));
Assert.Equal(at, reveal.Time);
var toward = normalize((p.Position - observer.Position).xz);
AssertBearing(toward.x, toward.y, reveal.Bearing);
}
}
// ---- AnEmissionEndsWithItsAct ----
[Fact]
public void AnEmissionEndsWithItsAct()
{
var p = Add(float3(0, 0, 0), new[] { "Shout" });
var heard = Add(float3(100, 0, 0), new[] { "Eye" });
var late = Add(float3(0, 0, -1000), new[] { "Eye" });
var ended = false;
SensorOf(p).OnPingEnd += () => ended = true;
PingAt(p);
for (var tick = 0; tick < 40 && !ended; tick++) Tick(.1f);
Assert.True(ended, "fixture: the ping ended");
var last = Freshest(heard, p);
Assert.Equal(_zone.Time, last); // the ending tick still emitted
late.Position = float3(-100, 0, 0); // moved into range after the act is over
for (var tick = 0; tick < 3; tick++) Tick(.1f);
Assert.Equal(last, Freshest(heard, p));
Assert.Empty(late.RevealsOf(p));
}
[Fact]
public void ALockEmissionEndsWhenTheLockLeavesItsAngle()
{
var (locker, target) = LockOn("Locker");
var third = Add(float3(500, 0, 0), new[] { "Eye" });
Tick(.1f);
Tick(.1f);
var last = Freshest(third, locker);
Assert.Equal(_zone.Time, last); // fixture: heard on the lock's tick
locker.Aim = float3(0, 0, -1); // 180 degrees off the target, past LockAngle
for (var tick = 0; tick < 3; tick++) Tick(.1f);
Assert.Equal(last, Freshest(third, locker));
}
// ---- HearingFeedsNoTrack ----
[Fact]
public void HearingFeedsNoTrack()
{
var (heardTrack, heardReveals) = RunHeard(ping: true);
var (controlTrack, controlReveals) = RunHeard(ping: false);
Assert.Equal(1, heardReveals); // fixture: the emission was heard
Assert.Equal(0, controlReveals);
Assert.Equal(controlTrack, heardTrack, 6);
}
private static (float track, int reveals) RunHeard(bool ping)
{
using var t = new ContactRevealTests();
var p = t.Add(float3(0, 0, 0), new[] { "Shout" });
var h = t.Add(float3(100, 0, 0), new[] { "Eye" });
if (ping) t.PingAt(p);
else
{
t.Tick(.1f);
t.Tick(.1f);
}
return (Track(h, p), h.RevealsOf(p).Count());
}
// ---- AnAllyCreditingAReturnEmitsNothing ----
// PaintModeTests.AnAllyHearsTheReturnSilently's geometry: A pings T, ally C hears the return. X stands near C and far
// from A, so X hears C if and only if C emitted.
[Fact]
public void AnAllyCreditingAReturnEmitsNothing()
{
var a = Add(float3(0, 0, 0), new[] { "Shout" });
var t = Add(float3(0, 0, 100));
var c = Add(float3(30, 0, 140), new[] { "Eye" });
c.Faction = a.Faction;
var x = Add(float3(100, 0, 200), new[] { "Eye" });
var returns = typeof(Entity).GetProperty("PingReturns", BindingFlags.Instance | BindingFlags.NonPublic);
Tick(.1f);
SensorOf(a).Ping();
var credited = false;
for (var tick = 0; tick < 24; tick++)
{
Tick(.1f);
credited |= ((System.Collections.ICollection) returns.GetValue(c)).Count > 0;
}
Assert.True(credited, "fixture: C was handed A's return");
Assert.NotEmpty(t.RevealsOf(a)); // fixture: T was painted
Assert.Empty(x.RevealsOf(c));
Assert.Empty(x.RevealsOf(a)); // fixture: X is beyond A's 150
}
// ---- TheInterceptFactorKeys ----
private static MessagePackSerializerOptions EmissionWire => CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly);
// InterceptFactor is slot 13 of the sensor record and slot 34 of the lock weapon (a launcher is its stored arm; its
// own slot 26 is the guidance curve), and the JSON property interceptFactor. Read as bytes, so a renumbered key or a
// renamed property cannot pass because writer and reader move together.
[Fact]
public void TheInterceptFactorKeys()
{
var sensor = MessagePackSerializer.Serialize<BehaviorData>(new SensorData { InterceptFactor = Const(2) }, EmissionWire);
var reader = new MessagePackReader(sensor);
Assert.Equal(2, reader.ReadArrayHeader());
Assert.Equal(5, reader.ReadInt32());
Assert.Equal(14, reader.ReadArrayHeader());
for (var slot = 0; slot < 13; slot++) reader.Skip();
Assert.Equal(2f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, EmissionWire).Min);
Assert.Equal(2f, JObject.Parse(JsonConvert.SerializeObject(new SensorData { InterceptFactor = Const(2) }))["interceptFactor"].ToObject<PerformanceStat>().Min);
var launcher = new LauncherData { InterceptFactor = Const(3), GuidanceCurve = new[] { float4(0, 1, 0, 0) } };
var bytes = MessagePackSerializer.Serialize<BehaviorData>(launcher, EmissionWire);
reader = new MessagePackReader(bytes);
Assert.Equal(2, reader.ReadArrayHeader());
reader.Skip(); // the arm's union index
Assert.Equal(35, reader.ReadArrayHeader());
for (var slot = 0; slot < 34; slot++) reader.Skip();
Assert.Equal(3f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, EmissionWire).Min);
Assert.Equal(3f, JObject.Parse(JsonConvert.SerializeObject(new LauncherData { InterceptFactor = Const(3) }))["interceptFactor"].ToObject<PerformanceStat>().Min);
var back = Assert.IsType<LauncherData>(MessagePackSerializer.Deserialize<BehaviorData>(bytes, EmissionWire));
Assert.Equal(3f, back.InterceptFactor.Min);
Assert.Single(back.GuidanceCurve);
}
[Fact]
public void AnAbsentInterceptFactorReadsAsOne()
{
var plain = Add(float3(0, 0, 0), new[] { "Eye" });
var shout = Add(float3(10, 0, 0), new[] { "Shout" });
var locker = Add(float3(20, 0, 0), new[] { "Locker" });
var hushed = Add(float3(30, 0, 0), new[] { "Hushed" });
Assert.Equal(1f, SensorOf(plain).InterceptFactor);
Assert.Equal(2f, SensorOf(shout).InterceptFactor);
Assert.Equal(1f, locker.Equipment.SelectMany(i => i.Behaviors).OfType<LockWeapon>().Single().InterceptFactor);
Assert.Equal(4f, hushed.Equipment.SelectMany(i => i.Behaviors).OfType<LockWeapon>().Single().InterceptFactor);
}
}

View file

@ -726,7 +726,7 @@ public sealed class PaintModeTests : IDisposable
var reader = new MessagePackReader(bytes);
Assert.Equal(2, reader.ReadArrayHeader());
Assert.Equal(5, reader.ReadInt32());
Assert.Equal(13, reader.ReadArrayHeader());
Assert.Equal(14, reader.ReadArrayHeader());
for (var slot = 0; slot < 12; slot++) reader.Skip();
Assert.Equal(90f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, WireOptions).Min);

View file

@ -326,15 +326,19 @@ public sealed class SignatureTests : IDisposable
// Emission is active emission, never light: SetSignature refuses it before writing, and a Visibility gear
// authored on it throws on the Zone.Update that first executes it.
[Fact]
public void SetSignatureRefusesEmission()
[Theory]
[InlineData(true, 5f)]
[InlineData(false, 5f)]
[InlineData(true, 0f)]
[InlineData(false, 0f)]
public void SetSignatureRefusesEmission(bool withItem, float strength)
{
var r = Build(Armament.Gun, 0f, null);
Fire(r);
var before = r.Ship.VisibilitySources.ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.NotEmpty(before);
Assert.Throws<ArgumentException>(() => r.Ship.SetSignature(new object(), SignatureChannel.Emission, 5f, r.WeaponItem));
Assert.Throws<ArgumentException>(() => r.Ship.SetSignature(new object(), SignatureChannel.Emission, strength, withItem ? r.WeaponItem : null));
Assert.Equal(before.Count, r.Ship.VisibilitySources.Count);
foreach (var kv in before) Assert.Equal(kv.Value, r.Ship.VisibilitySources[kv.Key]);