/* 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; // Key 7 (pingVisibility) is retired: never reuse it. [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; // 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) { 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 _reached = new HashSet(); // 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 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. 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; internal 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))) { _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); Entity.Emit(Item, Evaluate(_data.PingRange) / InterceptFactor); if (_pingLerp > 1) EndPing(); } // A finished timer keeps what the last step overshot, at most one step, so the restart in Ping is exact. var pingStep = dt / Evaluate(_data.PingCooldown); _pingCooldown = max(_pingCooldown - pingStep, -pingStep); var infoDecay = ItemManager.GameplaySettings.TargetInfoDecay; var decay = exp(-infoDecay * dt); var settle = infoDecay > 0 ? (1 - decay) / infoDecay : dt; 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)); } // The exact decay at this step: x <- x e^(-k dt) + (G / k)(1 - e^(-k dt)) for the passive gain G per second, // which settles at G / k (Entity.PassiveInfo) at every dt. A ping's credit is an impulse added first. if (heard) next = saturate(previous + credit) * decay; else { var angle = acos(dot(forward, normalize(diff))); var passive = Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, 1f, false, obscuration); next = saturate(previous * decay + passive * settle); } //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); } }