Sensor: the pinger's swept set decides each crossing once; allies credit a published ping return inside the bistatic oval

Deletes the stateless interval crossing (_previousRadius, PreviousRadius, Crossing) and the allied-pinger scan over
other ships' Sensors. The pinging Sensor remembers the subjects its wavefront has reached (_reached), in the beam
or not, and a subject's first sweeping tick inside the radius is its one crossing. On it the pinger paints, credits
itself and posts a PingReturn to each ally; Entity publishes returns with paints (PublishPaints is now
PublishInbox) and the ally's own sensors credit them for one tick at the geometric mean range, inside
Rt x Rr < PingRange^2, through the geometric mean of both legs' obscuration (ruling ally-echo-bistatic-oval).
This commit builds and replaces the sensor the previous commit's tests were written against.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 15:22:29 +02:00
commit dd896528cc
4 changed files with 71 additions and 36 deletions

View file

@ -66,10 +66,9 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
private float _pingLerp;
private bool _pinging;
private float _pingRadius;
private float _previousRadius;
// Allied sensors mid-sweep this tick, gathered once per Execute (scratch, rebuilt every call).
private readonly List<Sensor> _alliedPingers = new List<Sensor>();
// The subjects this ping's wavefront has reached, in the beam or not; each is crossed at most once per ping.
private readonly HashSet<Entity> _reached = new HashSet<Entity>();
public float Cooldown
{
@ -81,22 +80,15 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
get => _pingRadius;
}
// The wavefront radius before this tick's step: the ping crossed everything in [PreviousRadius, PingRadius).
public float PreviousRadius => _previousRadius;
public float BeamWidth => _data.BeamWidth == null ? 360f : Evaluate(_data.BeamWidth);
public float2 Facing => Direction.xz;
// The one crossing rule: this tick's ping wavefront of `pinger` passed `subject`, inside the pinger's beam.
// It remembers nothing; the two radii are the whole state, so every observer of the same ping agrees.
private static bool Crossing(Sensor pinger, Entity subject)
// Whether the offset `diff` (length `dist`) from this sensor lies inside its beam.
private bool InBeam(float2 diff, float dist)
{
var diff = subject.Position.xz - pinger.Entity.Position.xz;
var dist = length(diff);
if (dist < pinger._previousRadius || dist >= pinger._pingRadius) return false;
var beam = pinger.BeamWidth;
return beam >= 360f || dot(pinger.Facing, diff) >= dist * cos(beam * .5f * .0174533f);
var beam = BeamWidth;
return beam >= 360f || dot(Facing, diff) >= dist * cos(beam * .5f * .0174533f);
}
public float PingBrightness => pow(1 - _pingLerp, _data.PingRadiusExponent);
@ -145,6 +137,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
_pingCooldown = 1;
_pingLerp = 0;
_pingRadius = 0;
_reached.Clear();
OnPingStart?.Invoke();
}
}
@ -165,7 +158,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
// instance never reaches PowerBus.Step (see Ping()) and so never accrues charge.
if (Item != null)
_capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply);
_previousRadius = _pingRadius;
var sweeping = _pinging;
if (_pinging)
{
_pingLerp += dt / _data.PingDuration;
@ -180,13 +173,6 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
_pingCooldown -= dt / Evaluate(_data.PingCooldown);
var forward = Direction.xz;
_alliedPingers.Clear();
foreach (var ally in Entity.Zone.Entities)
{
if (ally == Entity || ally.Faction == null || ally.Faction != Entity.Faction || Entity.IsHostileTo(ally)) continue;
foreach (var sensor in ally.GetBehaviors<Sensor>())
if (sensor._previousRadius < sensor._pingRadius) _alliedPingers.Add(sensor);
}
foreach (var entity in Entity.Zone.Entities)
{
if (entity == Entity) continue;
@ -197,31 +183,46 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
float previous, next;
Entity.EntityInfoGathered.TryGetValue(entity, out previous);
// A crossing of this entity's own ping paints the subject (it learns it is swept); a crossing of an
// allied ping is heard silently (ruling sensor-stat-set item 3): credit, no paint, no emission.
var crossings = 0;
if (Crossing(this, entity))
{
crossings++;
entity.ReceivePaint(Entity.Position, Item?.Data, PaintMode.Search, 0);
}
foreach (var pinger in _alliedPingers)
if (pinger.Entity != entity && Crossing(pinger, entity)) crossings++;
var sensitivity = Evaluate(_data.Sensitivity);
var response = _data.PingBoost == null ? 0f : Evaluate(_data.PingBoost);
var credit = 0f;
var heard = false;
if (crossings > 0)
// 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))
{
var response = _data.PingBoost == null ? 0f : Evaluate(_data.PingBoost);
next = saturate(previous + crossings * Gain(entity.Visibility, Evaluate(_data.Sensitivity), response, 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)}%");
Entity.EntityInfoGathered[entity] = next;
}
if (!_pinging) _reached.Clear();
return true;
}

View file

@ -187,11 +187,16 @@ public abstract class Entity
public ReactiveProperty<int> TargetedByCount = new ReactiveProperty<int>(0);
// TargetedBy counts selection, which emits nothing; a warning comes only from a paint. A search paint is a ping
// sweeping this ship (Sensor.Execute); a track paint is a lock (LockWeapon.Execute). Each reports into the
// inbox the tick it paints; PublishPaints publishes the inbox once, so a warning lives exactly one tick after
// 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;
@ -804,16 +809,27 @@ public abstract class Entity
VisibleFriendlies.Clear();
_paintInbox.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()
{
_incomingPaints.Clear();
_incomingPaints.AddRange(_paintInbox);
_paintInbox.Clear();
_pingReturns.Clear();
_pingReturns.AddRange(_returnInbox);
_returnInbox.Clear();
}
internal void ReceiveReturn(PingReturn pingReturn)
{
if (!_active) return;
_returnInbox.Add(pingReturn);
}
// The callers are Sensor.Execute (a Search paint, Strength 0) and LockWeapon.Execute (a Track paint, Strength

View file

@ -40,6 +40,24 @@ public enum PaintMode
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;

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