diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index 8eeadbb7d..d1af7645e 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -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 diff --git a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs index d829de565..af80b0143 100644 --- a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs +++ b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs @@ -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); + } }