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;