Aetheria/Assets/Scripts/ServerShared/Behaviors/Sensor.cs

213 lines
8.6 KiB
C#
Raw Normal View History

/* 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<Entity> _pingedEntities = new HashSet<Entity>();
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).
Resolve request/brownout conflict: evaluate power requests nominally A power request may not depend on power supply (Cut 6/F6), and continuous consumers brown out through a power term on their own performance stats (Cut 7). For Radiator (PumpedHeat) and AetherDrive (Torque) those were the same stat, making six shipped catalog records illegal and leaving six BrownoutTests cases skipped. Resolved per the operator's ruling: a power request is evaluated nominally -- what the item wants at full supply, not what it is currently managing. EquippedItem.EvaluateNominalPower (mirroring ConditionRatio's own NominalContext) pins only PowerSupplyFactor to 1; Heat/Durability stay live, since they are not the term a request would be circular through. Every PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now reads a StatValidation.PowerRequestFields stat through it, so the same stat can carry a brownout curve and be a request field without the request depending on its own answer. StatValidation.ValidateNoPowerSupplyOnRequest (F6's static half of the rule, forbidding the term outright) is deleted -- nominal evaluation removes the circularity at the root instead. Only the dynamic modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) still refuses anything, since EvaluateNominalPower forwards ScaleModifier/ ConstantModifier unchanged (real, not nominal). Un-skips BrownoutTests' six Radiator/AetherDrive cases and adds two new regression tests (a direct EvaluateNominalPower probe, and a two-tick Radiator demand check) backed by a new mutation harness. Updates the four PowerCurveTests/AetheriaStoresTests cases that pinned the now-superseded static refusal to instead assert acceptance. GameData/Aetheria.cc is unchanged and still validates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:58:57 +02:00
//
// 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()
{
Resolve request/brownout conflict: evaluate power requests nominally A power request may not depend on power supply (Cut 6/F6), and continuous consumers brown out through a power term on their own performance stats (Cut 7). For Radiator (PumpedHeat) and AetherDrive (Torque) those were the same stat, making six shipped catalog records illegal and leaving six BrownoutTests cases skipped. Resolved per the operator's ruling: a power request is evaluated nominally -- what the item wants at full supply, not what it is currently managing. EquippedItem.EvaluateNominalPower (mirroring ConditionRatio's own NominalContext) pins only PowerSupplyFactor to 1; Heat/Durability stay live, since they are not the term a request would be circular through. Every PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now reads a StatValidation.PowerRequestFields stat through it, so the same stat can carry a brownout curve and be a request field without the request depending on its own answer. StatValidation.ValidateNoPowerSupplyOnRequest (F6's static half of the rule, forbidding the term outright) is deleted -- nominal evaluation removes the circularity at the root instead. Only the dynamic modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) still refuses anything, since EvaluateNominalPower forwards ScaleModifier/ ConstantModifier unchanged (real, not nominal). Un-skips BrownoutTests' six Radiator/AetherDrive cases and adds two new regression tests (a direct EvaluateNominalPower probe, and a two-tick Radiator demand check) backed by a new mutation harness. Updates the four PowerCurveTests/AetheriaStoresTests cases that pinned the now-superseded static refusal to instead assert acceptance. GameData/Aetheria.cc is unchanged and still validates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:58:57 +02:00
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: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
// 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.VisibilitySources[this] = Evaluate(_data.PingVisibility);
_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);
}
}