2020-12-22 03:13:43 +00:00
/ * 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/. */
2020-03-22 19:05:26 +00:00
2021-03-21 01:55:25 +00:00
using System ;
using System.Collections.Generic ;
2026-08-10 14:57:50 +01:00
using MessagePack ;
using Newtonsoft.Json ;
2026-09-14 17:39:12 +02:00
using CultMath ;
using static CultMath . math ;
2026-08-10 14:57:50 +01:00
[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 ) ;
}
}
2020-03-22 19:05:26 +00:00
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
public class Sensor : Behavior , IEventBehavior , IPowerConsumer
2020-03-22 19:05:26 +00:00
{
2026-08-10 14:57:50 +01:00
private SensorData _data ;
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
// 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 ( ) ;
2021-03-21 01:55:25 +00:00
private float _pingCooldown ;
private float _pingLerp ;
private bool _pinging ;
private float _pingRadius ;
private HashSet < Entity > _pingedEntities = new HashSet < Entity > ( ) ;
2020-03-22 19:05:26 +00:00
2021-03-21 01:55:25 +00:00
public float Cooldown
{
get = > saturate ( _pingCooldown ) ;
}
2026-08-10 14:57:50 +01:00
2021-03-21 01:55:25 +00:00
public float PingRadius
{
get = > _pingRadius ;
}
2026-08-10 14:57:50 +01:00
public float PingBrightness = > pow ( 1 - _pingLerp , _data . PingRadiusExponent ) ;
2021-03-21 01:55:25 +00:00
public event Action OnPingStart ;
public event Action OnPingEnd ;
public void ResetEvents ( )
{
OnPingStart = null ;
OnPingEnd = null ;
}
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
// Cut 4: capacity/rate, resolved fresh (Capacitor.ResolveCapacity's precedent -- PowerBus.Step calls
// PowerRequest below before this behaviour's own Execute runs this tick).
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.
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
private void RefreshInputCapacitor ( )
{
2026-09-19 06:58:57 +02:00
var energy = EvaluateNominalPower ( _data . PingEnergy ) ;
var cooldown = EvaluateNominalPower ( _data . PingCooldown ) ;
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
_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 ;
2021-03-21 01:55:25 +00:00
public void Ping ( )
{
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
// 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 ) ) )
2021-03-21 01:55:25 +00:00
{
2026-10-11 09:13:24 +02:00
Entity . SetSignature ( this , SignatureChannel . Emission , Evaluate ( _data . PingVisibility ) , Item ) ; // interim: emissions-heard replaces this with a range-gated emission
2021-03-21 01:55:25 +00:00
_pinging = true ;
_pingCooldown = 1 ;
_pingLerp = 0 ;
_pingRadius = 0 ;
_pingedEntities . Clear ( ) ;
OnPingStart ? . Invoke ( ) ;
}
}
2020-03-22 19:05:26 +00:00
2026-08-10 14:57:50 +01:00
public Sensor ( SensorData data , EquippedItem item ) : base ( data , item )
2021-04-18 05:13:20 +01:00
{
2026-08-10 14:57:50 +01:00
_data = data ;
2021-04-18 05:13:20 +01:00
}
2026-08-10 14:57:50 +01:00
public Sensor ( SensorData data , ConsumableItemEffect item ) : base ( data , item )
2020-03-22 19:05:26 +00:00
{
2026-08-10 14:57:50 +01:00
_data = data ;
2020-03-22 19:05:26 +00:00
}
2021-04-18 05:13:20 +01:00
public override bool Execute ( float dt )
2020-03-22 19:05:26 +00:00
{
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
// 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 ) ;
2021-03-21 01:55:25 +00:00
if ( _pinging )
{
2026-08-10 14:57:50 +01:00
_pingLerp + = dt / _data . PingDuration ;
_pingRadius = lerp ( 0 , Evaluate ( _data . PingRange ) , pow ( _pingLerp , _data . PingRadiusExponent ) ) ;
2021-03-21 01:55:25 +00:00
if ( _pingLerp > 1 )
{
_pinging = false ;
OnPingEnd ? . Invoke ( ) ;
}
}
2026-08-10 14:57:50 +01:00
_pingCooldown - = dt / Evaluate ( _data . PingCooldown ) ;
2021-04-18 05:13:20 +01:00
var forward = Direction . xz ;
foreach ( var entity in Entity . Zone . Entities )
2021-02-26 19:01:47 +00:00
{
2021-04-18 05:13:20 +01:00
if ( entity = = Entity ) continue ;
2026-09-30 12:56:13 +02:00
2026-08-10 14:57:50 +01:00
var diff = entity . Position . xz - Entity . Position . xz ;
2021-04-12 04:20:52 +01:00
var dist = length ( diff ) ;
2026-10-09 20:22:16 +02:00
var obscuration = Entity . Zone . Obscuration ( Entity . Position . xz , entity . Position . xz ) ;
2021-04-12 04:20:52 +01:00
float previous , next ;
2021-04-18 05:13:20 +01:00
Entity . EntityInfoGathered . TryGetValue ( entity , out previous ) ;
2021-04-12 04:20:52 +01:00
if ( ! _pingedEntities . Contains ( entity ) & & dist < _pingRadius )
2021-02-26 19:01:47 +00:00
{
2021-04-12 04:20:52 +01:00
_pingedEntities . Add ( entity ) ;
2026-10-09 20:22:16 +02:00
next = saturate ( previous + Gain ( entity . Visibility , Evaluate ( _data . Sensitivity ) , Evaluate ( _data . PingBoost ) , dist , dt , true , obscuration ) ) ;
2021-02-26 19:01:47 +00:00
}
2021-04-12 04:20:52 +01:00
else
{
2026-09-30 12:56:13 +02:00
var angle = acos ( dot ( forward , normalize ( diff ) ) ) ;
2026-10-09 20:22:16 +02:00
next = saturate ( previous + Gain ( entity . Visibility , Evaluate ( _data . Sensitivity ) , _data . SensitivityCurve . Evaluate ( angle / PI ) , dist , dt , false , obscuration ) ) ;
2021-04-12 04:20:52 +01:00
}
2021-04-18 05:13:20 +01:00
next * = 1 - ItemManager . GameplaySettings . TargetInfoDecay * dt ;
2021-04-12 04:20:52 +01:00
//Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%");
2021-04-18 05:13:20 +01:00
Entity . EntityInfoGathered [ entity ] = next ;
2021-02-11 03:14:20 +00:00
}
2020-05-11 01:17:10 +01:00
return true ;
}
2026-09-30 12:56:13 +02:00
// 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
2026-10-09 20:22:16 +02:00
// 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
2026-09-30 12:56:13 +02:00
? visibility * sensitivity * response * distance
2026-10-09 20:22:16 +02:00
: visibility * sensitivity * response * dt / distance ) * obscuration ;
2026-09-30 12:56:13 +02:00
// 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 ) ) ) ;
2026-10-09 20:22:16 +02:00
return Gain ( visibility , Evaluate ( _data . Sensitivity ) , _data . SensitivityCurve . Evaluate ( angle / PI ) , length ( diff ) , 1f , false ,
Entity . Zone . Obscuration ( Entity . Position . xz , position ) ) ;
2026-09-30 12:56:13 +02:00
}
2026-10-03 15:02:51 +02:00
// 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 ) ;
}
2026-08-10 14:57:50 +01:00
}