The observer's Entity owns a reveal book keyed (subject, item). Sense records a light act when its strength times PassiveInfoPerSignature clears the detection threshold; PublishInbox records an emitter whose paint arrived. ReceivePaint takes the emitter and its item. ContactOf derives Detected, Tracked, Classified and Identified from track, reveals, the zone clock and GameplaySettings.BearingMemory. ChunkInfo's arithmetic moves into PassiveInfoPerSignature; ActiveSensors fills one buffer instead of allocating per call. Not yet built or tested: verify follows with the tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
268 lines
No EOL
12 KiB
C#
268 lines
No EOL
12 KiB
C#
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using MessagePack;
|
||
using Newtonsoft.Json;
|
||
using CultMath;
|
||
using static CultMath.math;
|
||
|
||
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
|
||
public class SensorData : BehaviorData
|
||
{
|
||
[Inspectable, JsonProperty("sensitivity"), Key(3), RuntimeInspectable]
|
||
public PerformanceStat Sensitivity = new PerformanceStat();
|
||
|
||
[InspectableAnimationCurve, JsonProperty("sensCurve"), Key(4), RuntimeInspectable]
|
||
public BezierCurve SensitivityCurve;
|
||
|
||
[Inspectable, JsonProperty("pingBoost"), Key(5), RuntimeInspectable]
|
||
public PerformanceStat PingBoost;
|
||
|
||
[Inspectable, JsonProperty("pingEnergy"), Key(6), RuntimeInspectable]
|
||
public PerformanceStat PingEnergy;
|
||
|
||
[Inspectable, JsonProperty("pingVisibility"), Key(7), RuntimeInspectable]
|
||
public PerformanceStat PingVisibility;
|
||
|
||
[Inspectable, JsonProperty("pingRange"), Key(8), RuntimeInspectable]
|
||
public PerformanceStat PingRange;
|
||
|
||
[Inspectable, JsonProperty("pingCooldown"), Key(9), RuntimeInspectable]
|
||
public PerformanceStat PingCooldown;
|
||
|
||
[Inspectable, JsonProperty("pingDuration"), Key(10)]
|
||
public float PingDuration = 2;
|
||
|
||
[Inspectable, JsonProperty("pingRadiusExponent"), Key(11)]
|
||
public float PingRadiusExponent = .5f;
|
||
|
||
// The width in degrees of the cone a ping sweeps, about the sensor's facing (ruling illuminator-beamforming:
|
||
// 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;
|
||
|
||
public override Behavior CreateInstance(EquippedItem item)
|
||
{
|
||
return new Sensor(this, item);
|
||
}
|
||
|
||
public override Behavior CreateInstance(ConsumableItemEffect item)
|
||
{
|
||
return new Sensor(this, item);
|
||
}
|
||
}
|
||
|
||
public class Sensor : Behavior, IEventBehavior, IPowerConsumer
|
||
{
|
||
private SensorData _data;
|
||
|
||
// Cut 4 (docs/stats-and-power-cut.md, Cut 4): replaces the direct Entity.TrySpendCapacitorCharge spend in
|
||
// Ping() below -- the named, temporary exception Cut 3 left standing.
|
||
private readonly InputCapacitor _capacitor = new InputCapacitor();
|
||
private float _pingCooldown;
|
||
private float _pingLerp;
|
||
private bool _pinging;
|
||
private float _pingRadius;
|
||
|
||
// The subjects this ping's wavefront has reached, in the beam or not; each is crossed at most once per ping.
|
||
// Ping() is the one reset. A sweeping tick drops every subject no longer in the zone, so a subject that
|
||
// leaves and comes back inside the front is reached again, as a ship arriving inside the front is.
|
||
private readonly HashSet<Entity> _reached = new HashSet<Entity>();
|
||
|
||
// The zone the ping began in: a ping belongs to it, and ends when the pinger is anywhere else.
|
||
private Zone _pingZone;
|
||
|
||
public float Cooldown
|
||
{
|
||
get => saturate(_pingCooldown);
|
||
}
|
||
|
||
public float PingRadius
|
||
{
|
||
get => _pingRadius;
|
||
}
|
||
|
||
public float BeamWidth => _data.BeamWidth == null ? 360f : Evaluate(_data.BeamWidth);
|
||
|
||
public float2 Facing => Direction.xz;
|
||
|
||
// Whether the offset `diff` (length `dist`) from this sensor lies inside its beam.
|
||
private bool InBeam(float2 diff, float dist)
|
||
{
|
||
var beam = BeamWidth;
|
||
return beam >= 360f || dot(Facing, diff) >= dist * cos(beam * .5f * .0174533f);
|
||
}
|
||
|
||
public float PingBrightness => pow(1 - _pingLerp, _data.PingRadiusExponent);
|
||
|
||
public event Action OnPingStart;
|
||
public event Action OnPingEnd;
|
||
|
||
public void ResetEvents()
|
||
{
|
||
OnPingStart = null;
|
||
OnPingEnd = null;
|
||
}
|
||
|
||
// Cut 4: capacity/rate, resolved fresh (Capacitor.ResolveCapacity's precedent -- PowerBus.Step calls
|
||
// PowerRequest below before this behaviour's own Execute runs this tick).
|
||
//
|
||
// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PingEnergy and
|
||
// PingCooldown are registered request fields (StatValidation.PowerRequestFields) -- read nominally, same
|
||
// reasoning as InstantWeapon's own RefreshInputCapacitor.
|
||
private void RefreshInputCapacitor()
|
||
{
|
||
var energy = EvaluateNominalPower(_data.PingEnergy);
|
||
var cooldown = EvaluateNominalPower(_data.PingCooldown);
|
||
_capacitor.UpdateStats(energy, cooldown);
|
||
}
|
||
|
||
// Cut 4: the request PowerBus needs before Execute runs.
|
||
public float PowerRequest(float dt)
|
||
{
|
||
RefreshInputCapacitor();
|
||
return _capacitor.RequestedFill(dt);
|
||
}
|
||
|
||
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Utility -- everything else.
|
||
public int DefaultPowerTier => PowerTiers.Utility;
|
||
|
||
public void Ping()
|
||
{
|
||
// Whole-or-nothing against the ping's own input capacitor -- fires only at full charge (operator
|
||
// ruling: no fraction of a shot). A consumable-hosted instance (Item == null) has no PowerBus entry, so
|
||
// 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;
|
||
_pingLerp = 0;
|
||
_pingRadius = 0;
|
||
_reached.Clear();
|
||
OnPingStart?.Invoke();
|
||
}
|
||
}
|
||
|
||
public Sensor(SensorData data, EquippedItem item) : base(data, item)
|
||
{
|
||
_data = data;
|
||
}
|
||
|
||
public Sensor(SensorData data, ConsumableItemEffect item) : base(data, item)
|
||
{
|
||
_data = data;
|
||
}
|
||
|
||
public override bool Execute(float dt)
|
||
{
|
||
// Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item
|
||
// instance never reaches PowerBus.Step (see Ping()) and so never accrues charge.
|
||
if (Item != null)
|
||
_capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply);
|
||
if (_pinging && Entity.Zone != _pingZone) EndPing();
|
||
var sweeping = _pinging;
|
||
if (_pinging)
|
||
{
|
||
_pingLerp += dt / _data.PingDuration;
|
||
_pingRadius = lerp(0, Evaluate(_data.PingRange), pow(_pingLerp, _data.PingRadiusExponent));
|
||
_reached.IntersectWith(Entity.Zone.Entities);
|
||
if (_pingLerp > 1) EndPing();
|
||
}
|
||
|
||
_pingCooldown -= dt / Evaluate(_data.PingCooldown);
|
||
|
||
var forward = Direction.xz;
|
||
foreach (var entity in Entity.Zone.Entities)
|
||
{
|
||
if (entity == Entity) continue;
|
||
|
||
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);
|
||
|
||
var sensitivity = Evaluate(_data.Sensitivity);
|
||
var response = _data.PingBoost == null ? 0f : Evaluate(_data.PingBoost);
|
||
var credit = 0f;
|
||
var heard = false;
|
||
|
||
// 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))
|
||
{
|
||
heard = true;
|
||
entity.ReceivePaint(Entity, Item, 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, 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;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private void EndPing()
|
||
{
|
||
_pinging = false;
|
||
OnPingEnd?.Invoke();
|
||
}
|
||
|
||
// 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. `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) * 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.
|
||
public float PassiveRate(float visibility, float2 position)
|
||
{
|
||
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,
|
||
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
|
||
// (Entity.DetectionReachPerVisibility). The curve lies within its control points' hull.
|
||
public float PassiveGainBound()
|
||
{
|
||
var sensitivity = Evaluate(_data.Sensitivity);
|
||
var response = _data.SensitivityCurve.HullRange();
|
||
return max(sensitivity * response.x, sensitivity * response.y);
|
||
}
|
||
} |