/* 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; 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; private HashSet _pingedEntities = new HashSet(); public float Cooldown { get => saturate(_pingCooldown); } public float PingRadius { get => _pingRadius; } 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; _pingCooldown = 1; _pingLerp = 0; _pingRadius = 0; _pingedEntities.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) { _pingLerp += dt / _data.PingDuration; _pingRadius = lerp(0, Evaluate(_data.PingRange), pow(_pingLerp, _data.PingRadiusExponent)); if (_pingLerp > 1) { _pinging = false; OnPingEnd?.Invoke(); } } _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); if (!_pingedEntities.Contains(entity) && dist < _pingRadius) { _pingedEntities.Add(entity); 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, obscuration)); } next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt; //Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%"); Entity.EntityInfoGathered[entity] = next; } return true; } // 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); } }