Sensor.Gain takes the obscuration of its sight line

Gain stays the one detection rule: both branches are scaled by the fraction
of the signal that survives the clouds between observer and target
(Zone.Obscuration, exactly 1f with none, so the pre-cloud trace is
bit-identical). The entity loop passes it for pings and passive ticks and
PassiveRate for the chunk position. Entity.cs is untouched: a lock falls by
the existing detection-threshold rule when info does. Adds the sensor-level
facts to VapourCloudTests. Builds; not yet run (verify job follows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-09 20:22:16 +02:00
commit 0fb5ac1dfc
2 changed files with 91 additions and 8 deletions

View file

@ -160,17 +160,18 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
var diff = entity.Position.xz - Entity.Position.xz;
var dist = length(diff);
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)
{
_pingedEntities.Add(entity);
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true, obscuration));
}
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));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.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)}%");
@ -182,12 +183,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
// 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
// sensitivity curve at the bearing for a passive tick. The entity loop above and the chunk query
// (Entity.ChunkInfo, through PassiveRate) both call this; nothing else multiplies these terms.
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping) =>
ping
// sensitivity curve at the bearing for a passive tick. `obscuration` is the fraction of the signal that
// survives the vapour clouds on the sight line (Zone.Obscuration, 1f with none), scaling both. The entity
// loop above and the chunk query (Entity.ChunkInfo, through PassiveRate) both call this; nothing else
// multiplies these terms.
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping, float obscuration) =>
(ping
? visibility * sensitivity * response * distance
: visibility * sensitivity * response * dt / distance;
: visibility * sensitivity * response * dt / distance) * obscuration;
// The passive gain per second this sensor draws from a target of `visibility` at the planar `position`:
// Gain over one second, with the bearing measured from this sensor's own facing exactly as Execute does.
@ -195,7 +198,8 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
{
var diff = position - Entity.Position.xz;
var angle = acos(dot(Direction.xz, normalize(diff)));
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false);
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false,
Entity.Zone.Obscuration(Entity.Position.xz, position));
}
// An upper bound on sensitivity x response over every bearing, so PassiveRate(v, p) <= v x this / distance

View file

@ -189,6 +189,56 @@ public sealed class VapourCloudTests : IDisposable
return cloud;
}
[Fact]
public void ACloudBreaksALockAtRange()
{
var lab = Build(400f);
Establish(lab);
Vent(lab);
var ticks = 0;
while (ticks++ < 600 && !lab.Observer.Target.Value.IsNone) Tick(lab);
Assert.True(lab.Observer.Target.Value.IsNone, "the cloud never broke the target");
Assert.True(Info(lab.Observer, lab.Target) < Threshold, $"info {Info(lab.Observer, lab.Target)} is above the threshold");
Run(lab, 3);
Assert.Equal(0f, lab.Lock.Lock);
Assert.DoesNotContain(lab.Target, lab.Observer.VisibleEnemies);
}
[Fact]
public void AStrongCloseSensorBurnsThrough()
{
var lab = Build(40f);
Establish(lab);
var before = Info(lab.Observer, lab.Target);
Vent(lab);
Run(lab, 600);
var after = Info(lab.Observer, lab.Target);
Assert.True(after < before - .2f, $"the cloud thinned nothing: {before} -> {after}");
Assert.True(after > Threshold, $"info {after} fell below the threshold");
Assert.Equal(lab.Target, lab.Observer.Target.Value.Entity);
Assert.True(lab.Lock.IsLocked);
}
[Fact]
public void TheCloudBlindsItsVenter()
{
var lab = Build(400f);
Run(lab, 100);
Assert.True(Info(lab.Target, lab.Observer) > Threshold, "the venter never gathered the observer");
Assert.Contains(lab.Observer, lab.Target.VisibleEnemies);
Vent(lab);
Run(lab, 300);
Assert.True(Info(lab.Target, lab.Observer) < Threshold);
Assert.DoesNotContain(lab.Observer, lab.Target.VisibleEnemies);
Assert.Equal(Info(lab.Observer, lab.Target), Info(lab.Target, lab.Observer), 5);
}
[Fact]
public void ACloudFadesAndIsRemoved()
{
@ -270,4 +320,33 @@ public sealed class VapourCloudTests : IDisposable
Assert.Equal(.2f, Through(float2(-50, 0), float2(50, 0)), 5);
Assert.Equal(.4f, Through(float2(-50, 0), float2(15, 0)), 5);
}
// Where the rule is decided: one tick from nothing, the info gathered is exactly what survives the cloud
// times what the open sight line gathers, in both directions.
[Fact]
public void TheGainIsScaledByWhatSurvivesTheCloud()
{
var clear = Build(100f);
Tick(clear);
var cloudy = Build(100f);
Hand(cloudy.Zone, float2(0, 100), 20f, .6f);
Tick(cloudy);
Assert.True(Info(clear.Observer, clear.Target) > 0f);
Assert.Equal(.4f, Info(cloudy.Observer, cloudy.Target) / Info(clear.Observer, clear.Target), 3);
Assert.Equal(.4f, Info(cloudy.Target, cloudy.Observer) / Info(clear.Target, clear.Observer), 3);
}
// The chunk path reads the same rule: the passive rate toward a point is scaled by the same survivor fraction.
[Fact]
public void TheChunkRateIsScaledByTheSameFactor()
{
var lab = Build(100f);
var at = float2(0, 100);
var open = lab.Observer.Sensor.PassiveRate(100f, at);
Assert.True(open > 0f);
Hand(lab.Zone, at, 20f, .6f);
Assert.Equal(.4f, lab.Observer.Sensor.PassiveRate(100f, at) / open, 5);
}
}