Visibility 4a: retire the ping's light signature; Emission is never light

SetSignature refuses Emission; Sensor.PingVisibility (key 7) is retired and
Ping() writes no signature; Sense's Emission skip, the departure subtraction
and SignatureOn are deleted. Tests: SetSignatureRefusesEmission,
APingNoLongerBrightensThePinger; fixtures drop PingVisibility.

Not yet built or tested at this commit (verify job follows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 20:51:46 +02:00
commit d8b7f33d90
11 changed files with 74 additions and 83 deletions

View file

@ -24,8 +24,7 @@ public class SensorData : BehaviorData
[Inspectable, JsonProperty("pingEnergy"), Key(6), RuntimeInspectable]
public PerformanceStat PingEnergy;
[Inspectable, JsonProperty("pingVisibility"), Key(7), RuntimeInspectable]
public PerformanceStat PingVisibility;
// Key 7 (pingVisibility) is retired: never reuse it.
[Inspectable, JsonProperty("pingRange"), Key(8), RuntimeInspectable]
public PerformanceStat PingRange;
@ -137,7 +136,6 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
// nothing would ever fill this capacitor; bypass it rather than starving such an instance forever.
if(_pingCooldown < 0 && (Item == null || _capacitor.TrySpend(_capacitor.Capacity)))
{
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;

View file

@ -93,9 +93,14 @@ public abstract class Entity
public IReadOnlyDictionary<object, Signature> VisibilitySources => _visibilityView ??= new ReadOnlyDictionary<object, Signature>(_visibilitySources);
// The one writer of VisibilitySources (the decay loop in Update is the one decayer). Every light signature
// names its channel and the item that produced it (null for the hull).
public void SetSignature(object source, SignatureChannel channel, float strength, EquippedItem item) =>
// names its channel and the item that produced it (null for the hull). Emission is active emission (paints now,
// Entity.Emit after emissions-heard), never light: ruling visibility-per-capability-reveal, map R1.
public void SetSignature(object source, SignatureChannel channel, float strength, EquippedItem item)
{
if (channel == SignatureChannel.Emission)
throw new ArgumentException("Emission is active emission, never a light signature.", nameof(channel));
_visibilitySources[source] = new Signature(channel, strength, item);
}
public readonly ReactiveDictionary<Entity, float> EntityInfoGathered = new ReactiveDictionary<Entity, float>();
public (List<Weapon> weapons, List<EquippedItem> items)[] WeaponGroups;
@ -169,8 +174,6 @@ public abstract class Entity
public int AssignedPopulation => PopulationAssignments.Sum(pa => pa.AssignedPopulation);
public float Mass { get; private set; }
public float Visibility => VisibilitySources.Values.Sum(s => s.Strength);
public float SignatureOn(SignatureChannel channel) =>
VisibilitySources.Values.Where(s => s.Channel == channel).Sum(s => s.Strength);
public Subject<Entity> IncomingHit = new Subject<Entity>();
public Subject<(int2 pos, float damage)> ArmorDamage = new Subject<(int2, float)>();
@ -952,7 +955,7 @@ public abstract class Entity
// What this observer's instruments receive of other ships' acts this tick (Zone.Update calls it once, after every
// entity has updated): every light signature of an item that would alone settle this observer's passive info above
// TargetDetectionInfoThreshold is revealed (ruling visibility-per-capability-reveal). Hull radiation names no
// item and only feeds the track; an Emission entry is never light. One PassiveInfo per ship. Zone.Update calls it
// item and only feeds the track. One PassiveInfo per ship. Zone.Update calls it
// for every entity after all of them have updated, so what a ship shows is what every observer receives on that
// tick whatever order the zone lists them in.
internal void Sense()
@ -967,7 +970,7 @@ public abstract class Entity
var perSignature = -1f;
foreach (var signature in other._visibilitySources.Values)
{
if (signature.Item == null || signature.Channel == SignatureChannel.Emission) continue;
if (signature.Item == null) continue;
if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f);
if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel);
}

View file

@ -94,7 +94,7 @@ public readonly struct Reveal
}
// A subject's removal from a zone as the zone recorded it (death, dock, warp, any removal): where it was and how
// bright its light was (its signature outside the Emission channel). Judged by each observer's Sense under the
// bright its light was (its Visibility at removal). Judged by each observer's Sense under the
// light-act gate; notification only, one tick long, it decides nothing.
public readonly struct Departure
{

View file

@ -114,7 +114,7 @@ public class Zone
Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); }));
// The one record of a removal for observers (ruling reveal-ends-on-perception): read by the next Sense pass.
Entities.ObserveRemove().Subscribe(remove => _departures.Add(new Departure(remove.Value, remove.Value.Position.xz,
remove.Value.Visibility - remove.Value.SignatureOn(SignatureChannel.Emission))));
remove.Value.Visibility)));
Targets.Add(new EntityTargets());

View file

@ -147,7 +147,7 @@ public sealed class ContactRevealTests : IDisposable
{
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), PingVisibility = Const(5), PingRange = Const(300), PingCooldown = Const(4)
PingBoost = Const(.0001f), PingEnergy = Const(0), PingRange = Const(300), PingCooldown = Const(4)
};
private static GearData Eye(string name, float sensitivity) => new GearData
@ -378,18 +378,34 @@ public sealed class ContactRevealTests : IDisposable
Assert.Empty(o.RevealsOf(e));
}
// An Emission entry is never light (R1): a bright, near one reveals nothing through Sense. Emission reaches the
// book only through a published paint.
// A ping is the pinger's act on its beam, not light (R1): it adds nothing to the pinger's Visibility, so it raises
// no observer's track of the pinger. The control run is the same scene without the Ping() call.
[Fact]
public void AnEmissionEntryIsNotLight()
public void APingNoLongerBrightensThePinger()
{
var e = Add(float3(0, 0, 20), new[] { "Eye" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
e.SetSignature(new object(), SignatureChannel.Emission, 1000f, e.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor)));
var (pingedLight, pingedTrack) = RunPinger(ping: true);
var (controlLight, controlTrack) = RunPinger(ping: false);
Tick(.01f);
Assert.Equal(controlLight, pingedLight);
Assert.True(controlLight.Max() > 0f, "fixture: the pinger's hull is lit");
Assert.Equal(controlTrack, pingedTrack, 6);
}
Assert.Empty(o.RevealsOf(e));
// P is past the ping's radius from O; its Visibility is read on the call's own tick and on each tick after.
private static (float[] visibility, float track) RunPinger(bool ping)
{
using var t = new ContactRevealTests();
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();
if (ping) sensor.Ping();
var light = new List<float> { p.Visibility };
for (var tick = 0; tick < 10; tick++)
{
t.Tick(.1f);
light.Add(p.Visibility);
}
return (light.ToArray(), Track(o, p));
}
// Two items revealed at different times: the contact's bearing, while untracked, is the freshest reveal's.
@ -666,23 +682,6 @@ public sealed class ContactRevealTests : IDisposable
Assert.Single(o.RevealsOf(e));
}
// A signature on the Emission channel (paint strength, a ping) is not light: it does not make a departure seen.
[Fact]
public void AnEmissionSignatureDoesNotMakeADepartureSeen()
{
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);
ShowOnly(e, 0f, Guns(e)[0]);
e.SetSignature(new object(), SignatureChannel.Emission, 1000f, Guns(e)[0]);
_zone.Entities.Remove(e);
Tick(.1f);
Assert.Single(o.RevealsOf(e));
}
// A ship removed and put back before anyone senses has not left: its reveals stand, and its next act refreshes them.
[Fact]
public void AReAdmittedSubjectHasNotDeparted()

View file

@ -119,7 +119,6 @@ public sealed partial class MiningCut3Tests : IDisposable
SensitivityCurve = Falloff(),
PingBoost = Constant(.02f),
PingEnergy = Constant(0f),
PingVisibility = Constant(5f),
PingRange = Constant(300f),
PingCooldown = Constant(4f)
});
@ -132,7 +131,6 @@ public sealed partial class MiningCut3Tests : IDisposable
SensitivityCurve = Falloff(),
PingBoost = Constant(.02f),
PingEnergy = Constant(0f),
PingVisibility = Constant(5f),
PingRange = Constant(300f),
PingCooldown = Constant(4f)
});
@ -1032,7 +1030,7 @@ public sealed partial class MiningCut3Tests : IDisposable
var eye = Gear("Eye", new SensorData
{
Sensitivity = Constant(sensitivity), SensitivityCurve = Falloff(), PingBoost = Constant(boost),
PingEnergy = Constant(0f), PingVisibility = Constant(5f), PingRange = Constant(300f), PingCooldown = Constant(4f)
PingEnergy = Constant(0f), PingRange = Constant(300f), PingCooldown = Constant(4f)
});
cache.Upsert(eye);
var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { });

View file

@ -44,7 +44,6 @@ public sealed partial class MiningCut3Tests
SensitivityCurve = curve,
PingBoost = Constant(.02f),
PingEnergy = Constant(0f),
PingVisibility = Constant(5f),
PingRange = Constant(300f),
PingCooldown = Constant(4f)
});

View file

@ -99,7 +99,7 @@ public sealed class PaintModeTests : IDisposable
{
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),
PingBoost = Const(pingBoost), PingEnergy = Const(0), PingRange = Const(300),
PingCooldown = Const(4),
BeamWidth = beamWidth.HasValue ? Const(beamWidth.Value) : null
}
@ -259,7 +259,6 @@ public sealed class PaintModeTests : IDisposable
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);
@ -268,7 +267,6 @@ public sealed class PaintModeTests : IDisposable
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.

View file

@ -324,29 +324,28 @@ public sealed class SignatureTests : IDisposable
Assert.Null(s.Item);
}
[Theory]
[InlineData(5f)]
[InlineData(9f)]
public void APingShowsAnEmissionWithItsItem(float pingVisibility)
// 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()
{
var r = Build(Armament.None, 0f, null, new SensorData
{
Sensitivity = Constant(3f), PingBoost = Constant(.02f), PingEnergy = Constant(0f), PingVisibility = Constant(pingVisibility),
PingRange = Constant(300f), PingCooldown = Constant(4f)
});
var sensor = Single<Sensor>(r.Ship);
sensor.Ping();
var s = Of(r.Ship, sensor);
Assert.Equal(SignatureChannel.Emission, s.Channel);
Assert.Same(sensor.Item, s.Item);
Assert.Equal(pingVisibility, s.Strength, 3);
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.Equal(before.Count, r.Ship.VisibilitySources.Count);
foreach (var kv in before) Assert.Equal(kv.Value, r.Ship.VisibilitySources[kv.Key]);
Assert.Throws<ArgumentException>(() => Build(Armament.None, 0f, null,
new VisibilityData { Visibility = Constant(2), VisibilityDecay = Constant(0), Channel = SignatureChannel.Emission }));
}
// A catalog-authored Visibility behaviour travels on the channel its data names, thermal unless it says more.
[Theory]
[InlineData(SignatureChannel.Thermal)]
[InlineData(SignatureChannel.DrivePlume)]
[InlineData(SignatureChannel.Emission)]
public void AVisibilityBehaviourTravelsOnItsAuthoredChannel(SignatureChannel channel)
{
var r = Build(Armament.None, 0f, null, new VisibilityData { Visibility = Constant(7), VisibilityDecay = Constant(0), Channel = channel });
@ -366,9 +365,9 @@ public sealed class SignatureTests : IDisposable
// ---- VisibilityIsStillTheSum ----
// Every channel lit at once: a fired gun (100 on LaunchFlash), Visibility gear on Thermal (7), Reflection (4),
// DrivePlume (3) and Emission (2), a reflector under no sun (0) and the non-radiating hull (0). Visibility is the
// sum of the strengths, 116, the value the single-number build gave on this fixture; SignatureOn splits the
// same total by channel.
// DrivePlume (3) and a second Reflection (2), a reflector under no sun (0) and the non-radiating hull (0).
// Visibility is the sum of the strengths, 116, the value the single-number build gave on this fixture; the same
// total splits by channel over VisibilitySources, and none of it is Emission.
[Fact]
public void VisibilityIsStillTheSum()
{
@ -376,18 +375,19 @@ public sealed class SignatureTests : IDisposable
new VisibilityData { Visibility = Constant(7), VisibilityDecay = Constant(0) },
new VisibilityData { Visibility = Constant(4), VisibilityDecay = Constant(0), Channel = SignatureChannel.Reflection },
new VisibilityData { Visibility = Constant(3), VisibilityDecay = Constant(0), Channel = SignatureChannel.DrivePlume },
new VisibilityData { Visibility = Constant(2), VisibilityDecay = Constant(0), Channel = SignatureChannel.Emission },
new VisibilityData { Visibility = Constant(2), VisibilityDecay = Constant(0), Channel = SignatureChannel.Reflection },
new ReflectorData { CrossSection = Constant(3) });
Fire(r);
var ship = r.Ship;
Assert.Equal(116f, ship.Visibility, 2);
Assert.Equal(ship.VisibilitySources.Values.Sum(s => s.Strength), ship.Visibility, 3);
Assert.Equal(100f, ship.SignatureOn(SignatureChannel.LaunchFlash), 2);
Assert.Equal(7f, ship.SignatureOn(SignatureChannel.Thermal), 2);
Assert.Equal(4f, ship.SignatureOn(SignatureChannel.Reflection), 2);
Assert.Equal(2f, ship.SignatureOn(SignatureChannel.Emission), 2);
Assert.Equal(3f, ship.SignatureOn(SignatureChannel.DrivePlume), 2);
float On(SignatureChannel channel) => ship.VisibilitySources.Values.Where(s => s.Channel == channel).Sum(s => s.Strength);
Assert.Equal(100f, On(SignatureChannel.LaunchFlash), 2);
Assert.Equal(7f, On(SignatureChannel.Thermal), 2);
Assert.Equal(6f, On(SignatureChannel.Reflection), 2);
Assert.Equal(0f, On(SignatureChannel.Emission), 2);
Assert.Equal(3f, On(SignatureChannel.DrivePlume), 2);
}
// ---- DecayKeepsChannelAndItem ----
@ -412,28 +412,24 @@ public sealed class SignatureTests : IDisposable
Assert.False(r.Ship.VisibilitySources.ContainsKey(r.Weapon), "a signature below 0.1 is gone");
}
// Decay rebuilds every entry from its own. A gun, a drive that has stopped burning and a sensor that has pinged
// each wrote once and are never rewritten, so after a long tick the only thing touching them is decay; each
// still names the item of the source that wrote it, and the three items differ.
// Decay rebuilds every entry from its own. A gun and a drive that has stopped burning wrote once and are never
// rewritten, so after a long tick the only thing touching them is decay; with a Visibility gear (which rewrites
// its light each tick) each still names the item of the source that wrote it, and the three items differ.
[Fact]
public void DecayKeepsEachSourcesOwnItem()
{
var r = Build(Armament.Gun, .5f, Drive(10), new SensorData
{
Sensitivity = Constant(3f), PingBoost = Constant(.02f), PingEnergy = Constant(0f), PingVisibility = Constant(5f),
PingRange = Constant(300f), PingCooldown = Constant(4f)
});
var r = Build(Armament.Gun, .5f, Drive(10),
new VisibilityData { Visibility = Constant(5f), VisibilityDecay = Constant(0), Channel = SignatureChannel.Reflection });
var thruster = Single<Thruster>(r.Ship);
var sensor = Single<Sensor>(r.Ship);
var sensor = Single<Visibility>(r.Ship);
thruster.Item.UpdatePerformance();
Burn(r, 1f);
Burn(r, 0f); // the drive stops: its entry is no longer rewritten
sensor.Ping();
Fire(r);
var sources = new Behavior[] { thruster, sensor, r.Weapon };
// What each writer authors, named here and not read back from the fixture's own ticks.
var writes = new[] { SignatureChannel.DrivePlume, SignatureChannel.Emission, SignatureChannel.LaunchFlash };
var writes = new[] { SignatureChannel.DrivePlume, SignatureChannel.Reflection, SignatureChannel.LaunchFlash };
var before = sources.Select(b => Of(r.Ship, b)).ToArray();
Assert.Equal(3, before.Select(e => e.Item).Distinct().Count());
Assert.All(before, e => Assert.True(e.Strength > 1f, "fixture: each source has a live entry"));
@ -442,7 +438,7 @@ public sealed class SignatureTests : IDisposable
for (var i = 0; i < sources.Length; i++)
{
var after = Of(r.Ship, sources[i]);
Assert.True(after.Strength < before[i].Strength, "fixture: decay touched the entry");
if (sources[i] != sensor) Assert.True(after.Strength < before[i].Strength, "fixture: decay touched the entry");
Assert.Same(sources[i].Item, after.Item);
Assert.Equal(writes[i], after.Channel);
}

View file

@ -85,7 +85,7 @@ public sealed class VapourCloudTests : IDisposable
{
Sensitivity = Constant(1f),
SensitivityCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } },
PingBoost = Constant(.000004f), PingEnergy = Constant(0f), PingVisibility = Constant(5f),
PingBoost = Constant(.000004f), PingEnergy = Constant(0f),
PingRange = Constant(300f), PingCooldown = Constant(4f)
}));
cache.Upsert(Gear("Vent", HardpointType.Tool, new VapourDumpData

View file

@ -197,7 +197,7 @@ public static class Program
{
["Sensitivity"] = "receiver",
["PingBoost"] = "emitter", ["PingEnergy"] = "emitter", ["PingRange"] = "emitter",
["PingVisibility"] = "emitter", ["PingCooldown"] = "emitter",
["PingCooldown"] = "emitter",
};
private static readonly Dictionary<string, string> ThrusterRoles = new Dictionary<string, string>