2026-10-22 20:23:53 +02:00
using System ;
using System.Collections.Generic ;
using System.Linq ;
using System.Reflection ;
using CultMath ;
2026-10-23 01:47:52 +02:00
using UniRx ;
2026-10-22 20:23:53 +02:00
using Xunit ;
using static CultMath . math ;
using float3 = CultMath . float3 ;
using int2 = CultMath . int2 ;
// Cut sim-rate (ruling lower-game-sim-rate, map docs/aetheria-arena-sim-rate-map.md): the game's tick has one owner,
// and content authored in seconds means the same at every tick, because a restarted timer keeps what the last step
// overshot and passive sensing decays exactly.
public sealed class SimRateClockTests
{
[Fact]
public void OneTickOwner ( )
{
var clock = SimClock . ForGame ( ) ;
Assert . Equal ( 1f / SimClock . TickRate , SimClock . GameStep ) ;
Assert . Equal ( SimClock . GameStep , clock . Step ) ;
Assert . Equal ( SimClock . TickRate , clock . StepsPerRealSecond ) ;
// Ten real seconds of irregular frames pay ten seconds of steps, so the game runs at real time.
var frames = new [ ] { . 004f , . 031f , . 016f , . 0083f , . 05f , . 022f , . 0125f , . 027f } ;
var real = 0f ;
for ( var i = 0 ; real < 10f ; i + + )
{
var frame = Math . Min ( frames [ i % frames . Length ] , 10f - real ) ;
real + = frame ;
clock . Advance ( frame , _ = > { } ) ;
}
Assert . InRange ( clock . Steps , 10 * SimClock . TickRate - 1 , 10 * SimClock . TickRate + 1 ) ;
}
}
2026-10-22 20:35:28 +02:00
public sealed partial class ContactRevealTests
{
private static readonly float [ ] CadenceSteps = { 1f / 60f , 1f / 30f , 1f / 20f , 1f / 15f } ;
private static readonly float [ ] Cooldowns = { . 25f , . 13f } ;
// A gun of its own cooldown, bottomless magazine, so only the cooldown paces it. Upserted by name into the open catalog.
2026-10-23 02:51:33 +02:00
// Automatic by default, as a gun that holds is; a semi-automatic one is the plain InstantWeaponData.
2026-10-23 06:12:55 +02:00
private string GunWithCooldown ( float cooldown , bool auto = true , float velocity = 0f )
2026-10-22 20:35:28 +02:00
{
2026-10-23 06:12:55 +02:00
var name = $"{(auto ? " Auto " : " Gun ")}@{cooldown:R}{(velocity > 0 ? $" v { velocity : R } " : " ")}" ;
2026-10-22 20:35:28 +02:00
if ( _openCache . GetByName < ItemData > ( name ) ! = null ) return name ;
2026-10-23 02:51:33 +02:00
InstantWeaponData data = auto
? new AutoWeaponData { Count = Const ( 1 ) , BurstTime = Const ( 0 ) , Cooldown = Const ( cooldown ) }
: new InstantWeaponData { Count = Const ( 1 ) , BurstTime = Const ( 0 ) , Cooldown = Const ( cooldown ) } ;
var gun = Armed ( data , 0 ) ;
2026-10-23 06:12:55 +02:00
gun . Velocity = Const ( velocity ) ;
2026-10-23 00:27:47 +02:00
gun . Energy = Const ( 1 ) ; // the fixture reactor pays a round a hundredth of a second apart; the rounds-per-step tests top up nothing
2026-10-22 20:35:28 +02:00
_openCache . Upsert ( new WeaponItemData
{
Name = name , Hardpoint = HardpointType . Sensors , Shape = new Shape ( ) , Durability = 1 ,
MinimumTemperature = - 1000 , MaximumTemperature = 1000 , OptimalTemperature = 0 , PlateauWidth = 2000 ,
Fuse = WeaponFuse . Proximity , BlastRadius = 30f ,
Behaviors = { gun }
} ) ;
return name ;
}
// Holds the trigger for `seconds`, topping up the capacitor so only the cooldown paces the gun. Returns the zone
// time of each round.
2026-10-22 23:47:39 +02:00
private List < float > HoldTrigger ( Ship ship , EquippedItem item , float dt , float seconds , List < float > fireTimes = null )
2026-10-22 20:35:28 +02:00
{
var gun = ( InstantWeapon ) item . Behaviors . Single ( b = > b is InstantWeapon ) ;
var capacitor = ( InputCapacitor ) typeof ( InstantWeapon ) . GetField ( "_capacitor" , BindingFlags . Instance | BindingFlags . NonPublic ) . GetValue ( gun ) ;
var rounds = new List < float > ( ) ;
2026-10-22 23:47:39 +02:00
gun . OnFire + = id = >
{
rounds . Add ( _zone . Time ) ;
if ( fireTimes ! = null & & _zone . TryGetShot ( id , out var shot ) ) fireTimes . Add ( shot . FireTime ) ;
} ;
2026-10-22 20:35:28 +02:00
ship . SetTarget ( TargetRef . None ) ;
2026-10-22 20:41:48 +02:00
for ( var step = 0 ; step < ( int ) MathF . Round ( seconds / dt ) ; step + + )
2026-10-22 20:35:28 +02:00
{
2026-10-23 00:02:49 +02:00
capacitor . AddStepCharge ( 1000f , dt ) ;
2026-10-22 20:35:28 +02:00
gun . Activate ( ) ;
Tick ( dt ) ;
}
2026-10-23 01:47:52 +02:00
gun . Deactivate ( ) ;
2026-10-22 20:35:28 +02:00
return rounds ;
}
[Fact]
public void CooldownCadenceIsPerSecond ( )
{
foreach ( var cooldown in Cooldowns )
foreach ( var dt in CadenceSteps )
{
var ship = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { GunWithCooldown ( cooldown ) } , new [ ] { "Targeting" , "Reactor" } ) ;
var rounds = HoldTrigger ( ship , Guns ( ship ) [ 0 ] , dt , 30f ) ;
var expected = 30f / cooldown ;
Assert . True ( abs ( rounds . Count - expected ) < = 1.01f , $"cooldown {cooldown} at dt {dt}: {rounds.Count} rounds, expected {expected} +-1" ) ;
}
}
[Fact]
public void CooldownNeverFastFires ( )
{
foreach ( var cooldown in Cooldowns )
foreach ( var dt in CadenceSteps )
{
var ship = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { GunWithCooldown ( cooldown ) } , new [ ] { "Targeting" , "Reactor" } ) ;
var item = Guns ( ship ) [ 0 ] ;
for ( var i = 0 ; i < 20 ; i + + ) Tick ( dt ) ; // idle: the finished timer banks at most one step
var rounds = HoldTrigger ( ship , item , dt , 10f ) ;
Assert . True ( rounds . Count > 3 ) ;
for ( var i = 1 ; i < rounds . Count ; i + + )
Assert . True ( rounds [ i ] - rounds [ i - 1 ] > = cooldown - dt - 1e-4f , $"cooldown {cooldown} at dt {dt}: rounds {rounds[i - 1]} and {rounds[i]}" ) ;
}
}
private int PingsHeld ( float pingCooldown , float dt , float seconds )
{
var name = $"Pinger@{pingCooldown:R}" ;
if ( _openCache . GetByName < ItemData > ( name ) = = null )
{
var sensor = SensorBehavior ( sensitivity : 2f ) ;
sensor . PingCooldown = Const ( pingCooldown ) ;
_openCache . Upsert ( new GearData
{
Name = name , Hardpoint = HardpointType . Sensors , Shape = new Shape ( ) , Durability = 1 ,
MinimumTemperature = - 1000 , MaximumTemperature = 1000 , OptimalTemperature = 0 , PlateauWidth = 2000 ,
Behaviors = { sensor }
} ) ;
}
var ship = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { name } ) ;
var pings = 0 ;
SensorOf ( ship ) . OnPingStart + = ( ) = > pings + + ;
2026-10-22 20:41:48 +02:00
for ( var step = 0 ; step < ( int ) MathF . Round ( seconds / dt ) ; step + + )
2026-10-22 20:35:28 +02:00
{
SensorOf ( ship ) . Ping ( ) ;
Tick ( dt ) ;
}
return pings ;
}
[Fact]
public void PingCooldownCarriesItsOvershoot ( )
{
foreach ( var cooldown in new [ ] { . 5f , . 26f } )
foreach ( var dt in CadenceSteps )
{
var pings = PingsHeld ( cooldown , dt , 30f ) ;
Assert . True ( abs ( pings - 30f / cooldown ) < = 1.01f , $"ping cooldown {cooldown} at dt {dt}: {pings} pings, expected {30f / cooldown} +-1" ) ;
}
}
[Fact]
public void CooldownBehaviourCarriesItsOvershoot ( )
{
foreach ( var cooldown in new [ ] { . 5f , . 26f } )
foreach ( var dt in CadenceSteps )
{
var name = $"Metronome@{cooldown:R}" ;
if ( _openCache . GetByName < ItemData > ( name ) = = null )
_openCache . Upsert ( Tool ( name , new CooldownData { Cooldown = Const ( cooldown ) } ) ) ;
var ship = Add ( float3 ( 0 , 0 , 0 ) , null , new [ ] { name } ) ;
var clock = ship . Equipment . SelectMany ( i = > i . Behaviors ) . OfType < Cooldown > ( ) . Single ( ) ;
var fired = 0 ;
2026-10-22 20:41:48 +02:00
for ( var step = 0 ; step < ( int ) MathF . Round ( 30f / dt ) ; step + + )
2026-10-22 20:35:28 +02:00
{
Tick ( dt ) ;
if ( clock . Execute ( dt ) ) fired + + ;
}
Assert . True ( abs ( fired - 30f / cooldown ) < = 1.01f , $"cooldown {cooldown} at dt {dt}: {fired} restarts, expected {30f / cooldown} +-1" ) ;
}
}
[Fact]
public void AmmoIntervalCarriesItsOvershoot ( )
{
_openCache . Upsert ( new SimpleCommodityData { Name = "Slug" , Shape = new Shape ( ) , Price = 1 , MaxStack = 100 } ) ;
foreach ( var interval in new [ ] { . 5f , . 26f } )
foreach ( var dt in CadenceSteps )
{
var name = $"Stream@{interval:R}" ;
if ( _openCache . GetByName < ItemData > ( name ) = = null )
{
var beam = ( ConstantWeaponData ) Armed ( new ConstantWeaponData ( ) , 100000 ) ;
beam . AmmoInterval = interval ;
beam . AmmoType = _openCache . RefOf ( _openCache . GetByName < SimpleCommodityData > ( "Slug" ) ) ;
_openCache . Upsert ( new WeaponItemData
{
Name = name , Hardpoint = HardpointType . Sensors , Shape = new Shape ( ) , Durability = 1 ,
MinimumTemperature = - 1000 , MaximumTemperature = 1000 , OptimalTemperature = 0 , PlateauWidth = 2000 ,
Fuse = WeaponFuse . Proximity , BlastRadius = 30f ,
Behaviors = { beam }
} ) ;
}
var ship = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { name } , new [ ] { "Targeting" , "Reactor" } ) ;
var weapon = ship . Equipment . SelectMany ( i = > i . Behaviors ) . OfType < ConstantWeapon > ( ) . Single ( ) ;
typeof ( ConstantWeapon ) . GetField ( "_ammo" , BindingFlags . Instance | BindingFlags . NonPublic ) . SetValue ( weapon , 100000 ) ;
ship . SetTarget ( TargetRef . None ) ;
weapon . Activate ( ) ;
2026-10-22 20:41:48 +02:00
for ( var step = 0 ; step < ( int ) MathF . Round ( 30f / dt ) ; step + + ) Tick ( dt ) ;
2026-10-22 20:35:28 +02:00
var spent = 100000 - weapon . Ammo ;
Assert . True ( abs ( spent - 30f / interval ) < = 1.01f , $"ammo interval {interval} at dt {dt}: {spent} spent, expected {30f / interval} +-1" ) ;
}
}
// A fixed target under passive sensing settles where Entity.PassiveInfo says (G / k), at any step.
[Fact]
public void PassiveInfoSettlesAtTheDocumentedLevel ( )
{
foreach ( var dt in new [ ] { 1f / 60f , 1f / 15f } )
{
var observer = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { "Eye" } ) ;
var target = Add ( float3 ( 0 , 0 , 100 ) ) ;
2026-10-22 21:05:13 +02:00
var marker = new object ( ) ;
void Glow ( ) = > target . SetSignature ( marker , SignatureChannel . Thermal , 5f , null ) ; // held as bright as a thruster
Glow ( ) ;
Tick ( 0f ) ;
2026-10-22 20:35:28 +02:00
var distance = 100f ;
while ( observer . PassiveInfo ( float2 ( 0 , distance ) , target . Visibility ) > . 5f )
{
distance * = 2 ;
target . Position = float3 ( 0 , 0 , distance ) ;
}
var documented = observer . PassiveInfo ( target . Position . xz , target . Visibility ) ;
Assert . InRange ( documented , 1e-4f , . 5f ) ;
2026-10-22 21:05:13 +02:00
for ( var step = 0 ; step < ( int ) MathF . Round ( 40f / dt ) ; step + + )
{
Glow ( ) ;
Tick ( dt ) ;
}
2026-10-22 20:35:28 +02:00
Assert . InRange ( Track ( observer , target ) , documented - 1e-3f , documented + 1e-3f ) ;
}
}
2026-10-22 21:05:13 +02:00
// An idle timer banks at most one step: after ten idle seconds the first two restarts are still a cooldown apart.
[Fact]
public void AnIdleSensorBanksAtMostOneStep ( )
{
foreach ( var dt in CadenceSteps )
{
var name = "Pinger@.5" ;
if ( _openCache . GetByName < ItemData > ( name ) = = null )
{
var sensor = SensorBehavior ( sensitivity : 2f ) ;
sensor . PingCooldown = Const ( . 5f ) ;
_openCache . Upsert ( new GearData
{
Name = name , Hardpoint = HardpointType . Sensors , Shape = new Shape ( ) , Durability = 1 ,
MinimumTemperature = - 1000 , MaximumTemperature = 1000 , OptimalTemperature = 0 , PlateauWidth = 2000 ,
Behaviors = { sensor }
} ) ;
}
var ship = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { name } ) ;
var times = new List < float > ( ) ;
SensorOf ( ship ) . OnPingStart + = ( ) = > times . Add ( _zone . Time ) ;
for ( var step = 0 ; step < ( int ) MathF . Round ( 10f / dt ) ; step + + ) Tick ( dt ) ;
for ( var step = 0 ; step < ( int ) MathF . Round ( 3f / dt ) ; step + + )
{
SensorOf ( ship ) . Ping ( ) ;
Tick ( dt ) ;
}
Assert . True ( times . Count > = 4 ) ;
for ( var i = 1 ; i < times . Count ; i + + )
Assert . True ( times [ i ] - times [ i - 1 ] > = . 5f - dt - 1e-4f , $"dt {dt}: pings at {times[i - 1]} and {times[i]}" ) ;
}
}
[Fact]
public void AnIdleCooldownBanksAtMostOneStep ( )
{
foreach ( var dt in CadenceSteps )
{
var name = "Metronome@.5" ;
if ( _openCache . GetByName < ItemData > ( name ) = = null )
_openCache . Upsert ( Tool ( name , new CooldownData { Cooldown = Const ( . 5f ) } ) ) ;
var ship = Add ( float3 ( 0 , 0 , 0 ) , null , new [ ] { name } ) ;
var clock = ship . Equipment . SelectMany ( i = > i . Behaviors ) . OfType < Cooldown > ( ) . Single ( ) ;
2026-10-23 01:47:52 +02:00
// Idle: the timer is updated but its chain never reaches Execute (an earlier behaviour gates it, or the item is
// offline). Zone ticks would not do: an Active tool reaches Execute every step and restarts the timer each time.
for ( var step = 0 ; step < ( int ) MathF . Round ( 10f / dt ) ; step + + ) clock . Update ( dt ) ;
var restarts = new List < int > ( ) ;
for ( var step = 0 ; step < ( int ) MathF . Round ( 2f / dt ) ; step + + )
2026-10-22 21:05:13 +02:00
{
2026-10-23 01:47:52 +02:00
if ( clock . Execute ( dt ) ) restarts . Add ( step ) ;
clock . Update ( dt ) ;
2026-10-22 21:05:13 +02:00
}
2026-10-23 01:47:52 +02:00
Assert . True ( restarts . Count > = 3 , $"dt {dt}: {restarts.Count} restarts in 2 s" ) ;
for ( var i = 1 ; i < restarts . Count ; i + + )
Assert . True ( ( restarts [ i ] - restarts [ i - 1 ] ) * dt > = . 5f - dt - 1e-4f , $"dt {dt}: restarts at steps {restarts[i - 1]} and {restarts[i]}" ) ;
2026-10-22 21:05:13 +02:00
}
}
// A magazine of three, cooldown .1 s and a .9 s reload is three rounds a 1.1 s cycle, at any step.
2026-10-22 23:47:39 +02:00
// Ruling rounds-per-step: cadence is authored in seconds at any tick rate. A gun of 0.01 s fires 100 rounds a second at 15, 30
// and 60 Hz (several in one step), each born at its own moment inside the step, one cooldown after the one before.
[Fact]
public void AFastGunDeliversItsAuthoredRoundsAtEveryRate ( )
{
foreach ( var dt in new [ ] { 1f / 60f , 1f / 30f , 1f / 15f } )
{
var ship = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { GunWithCooldown ( . 01f ) } , new [ ] { "Targeting" , "Reactor" } ) ;
var fireTimes = new List < float > ( ) ;
var rounds = HoldTrigger ( ship , Guns ( ship ) [ 0 ] , dt , 10f , fireTimes ) ;
2026-10-23 00:15:58 +02:00
var probe = ( InstantWeapon ) Guns ( ship ) [ 0 ] . Behaviors . Single ( b = > b is InstantWeapon ) ;
var cap = ( InputCapacitor ) typeof ( InstantWeapon ) . GetField ( "_capacitor" , BindingFlags . Instance | BindingFlags . NonPublic ) . GetValue ( probe ) ;
2026-10-23 00:27:47 +02:00
Assert . True ( abs ( rounds . Count - 1000f ) < = 1.01f , $"dt {dt}: {rounds.Count} rounds in 10 s, expected 1000 +-1; capacity {cap.Capacity} rate {cap.Rate} charge {cap.Charge} cooldown {probe.Cooldown} canfire {probe.CanFire} firing {probe.Firing}" ) ;
2026-10-22 23:47:39 +02:00
Assert . Equal ( rounds . Count , fireTimes . Count ) ;
for ( var i = 1 ; i < fireTimes . Count ; i + + )
Assert . True ( abs ( fireTimes [ i ] - fireTimes [ i - 1 ] - . 01f ) < 2e-4f , $"dt {dt}: rounds {i - 1} and {i} born {fireTimes[i - 1]:R} and {fireTimes[i]:R}" ) ;
}
}
2026-10-23 01:47:52 +02:00
private InputCapacitor CapacitorOf ( InstantWeapon gun ) = > ( InputCapacitor ) typeof ( InstantWeapon ) . GetField ( "_capacitor" , BindingFlags . Instance | BindingFlags . NonPublic ) . GetValue ( gun ) ;
2026-10-23 02:51:33 +02:00
// A press that is let go before the step runs fires one burst, whatever the cooldown, the tick and the kind of gun: the
// trigger is the weapon's state, not a pulse once per step. The press goes through the pilot's command (the player's
// action bar and the agents' Combat both do) and straight to the weapon (a turret does).
2026-10-23 01:47:52 +02:00
[Fact]
public void ATapFiresOneBurstAtEveryRate ( )
{
2026-10-23 02:51:33 +02:00
foreach ( var auto in new [ ] { false , true } )
foreach ( var cooldown in auto ? new [ ] { . 01f , . 025f , . 1f } : new [ ] { . 1f , . 25f } )
foreach ( var viaCommand in new [ ] { false , true } )
foreach ( var dt in new [ ] { 1f / 60f , 1f / 30f , 1f / 15f } )
{
var ship = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { GunWithCooldown ( cooldown , auto ) } , new [ ] { "Targeting" , "Reactor" } ) ;
var gun = ( InstantWeapon ) Guns ( ship ) [ 0 ] . Behaviors . Single ( b = > b is InstantWeapon ) ;
var capacitor = CapacitorOf ( gun ) ;
var rounds = 0 ;
gun . OnFire + = _ = > rounds + + ;
ship . SetTarget ( TargetRef . None ) ;
capacitor . AddStepCharge ( 1000f , dt ) ;
if ( viaCommand )
{
ship . Command . Pull ( gun ) ;
ship . Command . Release ( gun ) ;
}
else
{
gun . Activate ( ) ;
gun . Deactivate ( ) ;
}
for ( var step = 0 ; step < ( int ) MathF . Round ( 1f / dt ) ; step + + )
{
capacitor . AddStepCharge ( 1000f , dt ) ;
Tick ( dt ) ;
}
Assert . True ( rounds = = 1 , $"auto {auto} cooldown {cooldown} at dt {dt}, command {viaCommand}: {rounds} rounds from one tap, expected 1" ) ;
}
}
// A trigger pressed once and held: an AutoWeapon delivers the authored cadence (the rounds of ten seconds are the same at
// 15, 30 and 60 Hz) and stops when the trigger is let go; a plain gun is semi-automatic and fires its one burst however
// long the key stays down.
[Fact]
public void AHeldTriggerFiresPerTheKindOfGun ( )
{
foreach ( var auto in new [ ] { false , true } )
foreach ( var cooldown in auto ? new [ ] { . 01f , . 1f } : new [ ] { . 1f , . 25f } )
2026-10-23 01:47:52 +02:00
foreach ( var dt in new [ ] { 1f / 60f , 1f / 30f , 1f / 15f } )
{
2026-10-23 02:51:33 +02:00
var ship = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { GunWithCooldown ( cooldown , auto ) } , new [ ] { "Targeting" , "Reactor" } ) ;
2026-10-23 01:47:52 +02:00
var gun = ( InstantWeapon ) Guns ( ship ) [ 0 ] . Behaviors . Single ( b = > b is InstantWeapon ) ;
var capacitor = CapacitorOf ( gun ) ;
var rounds = 0 ;
gun . OnFire + = _ = > rounds + + ;
ship . SetTarget ( TargetRef . None ) ;
capacitor . AddStepCharge ( 1000f , dt ) ;
2026-10-23 02:51:33 +02:00
gun . Activate ( ) ;
for ( var step = 0 ; step < ( int ) MathF . Round ( 10f / dt ) ; step + + )
2026-10-23 01:47:52 +02:00
{
2026-10-23 02:51:33 +02:00
capacitor . AddStepCharge ( 1000f , dt ) ;
Tick ( dt ) ;
2026-10-23 01:47:52 +02:00
}
2026-10-23 02:51:33 +02:00
var expected = auto ? 10f / cooldown : 1f ;
Assert . True ( abs ( rounds - expected ) < = ( auto ? 1.01f : 0f ) , $"auto {auto} cooldown {cooldown} at dt {dt}: {rounds} rounds held 10 s, expected {expected}" ) ;
gun . Deactivate ( ) ;
var held = rounds ;
2026-10-23 01:47:52 +02:00
for ( var step = 0 ; step < ( int ) MathF . Round ( 1f / dt ) ; step + + )
{
capacitor . AddStepCharge ( 1000f , dt ) ;
Tick ( dt ) ;
}
2026-10-23 02:51:33 +02:00
Assert . True ( rounds - held < = 1 , $"auto {auto} cooldown {cooldown} at dt {dt}: {rounds - held} rounds after the trigger was let go" ) ;
2026-10-23 01:47:52 +02:00
}
}
2026-10-23 02:51:33 +02:00
// An agent's Combat pulls every weapon it means to fire each step. A plain gun it taps: each pull is a press, and the gun
// fires when it is ready, its authored cadence on average at any tick. An AutoWeapon it holds: the pulls land on a
// trigger already down. Both deliver the cadence; the plain one stops when the pulls stop, with no release needed.
2026-10-23 01:47:52 +02:00
[Fact]
2026-10-23 02:51:33 +02:00
public void CombatTapsAPlainGunAndHoldsAnAutoWeapon ( )
2026-10-23 01:47:52 +02:00
{
2026-10-23 02:51:33 +02:00
foreach ( var auto in new [ ] { false , true } )
2026-10-23 01:47:52 +02:00
foreach ( var dt in new [ ] { 1f / 60f , 1f / 30f , 1f / 15f } )
{
2026-10-23 02:51:33 +02:00
var ship = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { GunWithCooldown ( . 1f , auto ) } , new [ ] { "Targeting" , "Reactor" } ) ;
2026-10-23 01:47:52 +02:00
var gun = ( InstantWeapon ) Guns ( ship ) [ 0 ] . Behaviors . Single ( b = > b is InstantWeapon ) ;
var capacitor = CapacitorOf ( gun ) ;
var rounds = 0 ;
gun . OnFire + = _ = > rounds + + ;
ship . SetTarget ( TargetRef . None ) ;
for ( var step = 0 ; step < ( int ) MathF . Round ( 10f / dt ) ; step + + )
{
capacitor . AddStepCharge ( 1000f , dt ) ;
2026-10-23 02:51:33 +02:00
ship . Command . Pull ( gun ) ;
2026-10-23 01:47:52 +02:00
Tick ( dt ) ;
}
2026-10-23 02:51:33 +02:00
Assert . True ( abs ( rounds - 100f ) < = 1.01f , $"auto {auto} at dt {dt}: {rounds} rounds pulled each step for 10 s, expected 100 +-1" ) ;
var pulled = rounds ;
2026-10-23 01:47:52 +02:00
for ( var step = 0 ; step < ( int ) MathF . Round ( 1f / dt ) ; step + + )
{
capacitor . AddStepCharge ( 1000f , dt ) ;
2026-10-23 02:51:33 +02:00
if ( auto ) ship . Command . Release ( gun ) ;
2026-10-23 01:47:52 +02:00
Tick ( dt ) ;
}
2026-10-23 02:51:33 +02:00
Assert . True ( rounds - pulled < = 1 , $"auto {auto} at dt {dt}: {rounds - pulled} rounds after the pulls stopped" ) ;
2026-10-23 01:47:52 +02:00
}
}
// Within a step, rounds resolve in the order they were fired across shooters, not one shooter's block after the other's
// and not newest first: which round breaks a shield or lands the kill follows sim time. Equal moments go by shot id.
[Fact]
public void RoundsOfOneStepResolveInFireTimeOrderAcrossShooters ( )
{
var dt = 1f / 30f ;
var a = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { GunWithCooldown ( . 01f ) } , new [ ] { "Targeting" , "Reactor" } ) ;
var b = Add ( float3 ( 5 , 0 , 0 ) , new [ ] { GunWithCooldown ( . 01f ) } , new [ ] { "Targeting" , "Reactor" } ) ;
var guns = new [ ] { a , b } . Select ( s = > ( InstantWeapon ) Guns ( s ) [ 0 ] . Behaviors . Single ( x = > x is InstantWeapon ) ) . ToArray ( ) ;
var fired = new Dictionary < int , float > ( ) ;
foreach ( var gun in guns )
gun . OnFire + = id = > { if ( _zone . TryGetShot ( id , out var shot ) ) fired [ id ] = shot . FireTime ; } ;
foreach ( var s in new [ ] { a , b } ) s . SetTarget ( TargetRef . None ) ;
var order = new List < int > ( ) ;
var sources = new List < bool > ( ) ;
_zone . ShotResolved . Subscribe ( outcome = > { order . Add ( outcome . ShotId ) ; sources . Add ( ReferenceEquals ( outcome . Source , a ) ) ; } ) ;
var checkedSteps = 0 ;
for ( var step = 0 ; step < 8 ; step + + )
{
foreach ( var gun in guns ) { CapacitorOf ( gun ) . AddStepCharge ( 1000f , dt ) ; gun . Activate ( ) ; }
order . Clear ( ) ;
sources . Clear ( ) ;
Tick ( dt ) ;
if ( step < 3 ) continue ;
checkedSteps + + ;
Assert . True ( order . Count > = 4 , $"step {step}: {order.Count} rounds resolved" ) ;
for ( var i = 1 ; i < order . Count ; i + + )
{
var before = ( fired [ order [ i - 1 ] ] , order [ i - 1 ] ) ;
var after = ( fired [ order [ i ] ] , order [ i ] ) ;
Assert . True ( before . CompareTo ( after ) < 0 , $"step {step}: shot {order[i - 1]} (fired {before.Item1:R}) resolved before shot {order[i]} (fired {after.Item1:R})" ) ;
}
Assert . True ( sources . Zip ( sources . Skip ( 1 ) , ( x , y ) = > x ! = y ) . Count ( changed = > changed ) > 1 , $"step {step}: one shooter's rounds resolved as a block" ) ;
}
Assert . True ( checkedSteps > = 4 ) ;
}
2026-10-23 06:12:55 +02:00
// Within a step, rounds apply in the order they arrive, not the order they were fired: a slow gun's earlier rounds and a
// fast gun's later ones that land in the same step apply by when each lands. Equal moments go by shot id.
[Fact]
public void RoundsOfOneStepApplyInArrivalOrderWhateverTheirFlightTimes ( )
{
var dt = 1f / 30f ;
var slow = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { GunWithCooldown ( . 01f , velocity : 100f ) } , new [ ] { "Targeting" , "Reactor" } ) ;
var fast = Add ( float3 ( 5 , 0 , 0 ) , new [ ] { GunWithCooldown ( . 01f ) } , new [ ] { "Targeting" , "Reactor" } ) ;
var guns = new [ ] { slow , fast } . Select ( s = > ( InstantWeapon ) Guns ( s ) [ 0 ] . Behaviors . Single ( x = > x is InstantWeapon ) ) . ToArray ( ) ;
var fired = new Dictionary < int , ( float fire , float arrival ) > ( ) ;
foreach ( var gun in guns )
gun . OnFire + = id = > { if ( _zone . TryGetShot ( id , out var shot ) ) fired [ id ] = ( shot . FireTime , shot . ArrivalTime ) ; } ;
foreach ( var s in new [ ] { slow , fast } ) s . SetTarget ( TargetRef . None ) ;
var order = new List < int > ( ) ;
_zone . ShotResolved . Subscribe ( outcome = > order . Add ( outcome . ShotId ) ) ;
var inverted = 0 ;
for ( var step = 0 ; step < 40 ; step + + )
{
foreach ( var gun in guns ) { CapacitorOf ( gun ) . AddStepCharge ( 1000f , dt ) ; gun . Activate ( ) ; }
order . Clear ( ) ;
Tick ( dt ) ;
for ( var i = 1 ; i < order . Count ; i + + )
{
var before = ( fired [ order [ i - 1 ] ] . arrival , order [ i - 1 ] ) ;
var after = ( fired [ order [ i ] ] . arrival , order [ i ] ) ;
Assert . True ( before . CompareTo ( after ) < 0 , $"step {step}: shot {order[i - 1]} (arrives {before.Item1:R}) applied before shot {order[i]} (arrives {after.Item1:R})" ) ;
if ( fired [ order [ i - 1 ] ] . fire > fired [ order [ i ] ] . fire ) inverted + + ;
}
}
// The guns really differ: rounds were applied out of fire order, which fire-order resolution would not do.
Assert . True ( inverted > 10 , $"{inverted} rounds applied ahead of an earlier-fired one" ) ;
}
2026-10-23 00:50:05 +02:00
// Rounds born in one step leave from where the shooter was at their own moment: the origins of two rounds of a step are
// apart by the shooter's velocity times the time between them.
[Fact]
public void RoundsOfOneStepLeaveFromWhereTheShooterWasAtTheirMoment ( )
{
var dt = 1f / 15f ;
var ship = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { GunWithCooldown ( . 01f ) } , new [ ] { "Targeting" , "Reactor" } ) ;
ship . Velocity = float2 ( 30 , 0 ) ;
var gun = ( InstantWeapon ) Guns ( ship ) [ 0 ] . Behaviors . Single ( b = > b is InstantWeapon ) ;
var born = new List < ( float step , float time , float x , float velocity ) > ( ) ;
gun . OnFire + = id = >
{
if ( _zone . TryGetShot ( id , out var shot ) ) born . Add ( ( _zone . Time , shot . FireTime , shot . FireOrigin . x , ship . Velocity . x ) ) ;
} ;
ship . SetTarget ( TargetRef . None ) ;
for ( var i = 0 ; i < 20 ; i + + )
{
gun . Activate ( ) ;
Tick ( dt ) ;
}
var pairs = 0 ;
for ( var i = 1 ; i < born . Count ; i + + )
{
if ( born [ i ] . step ! = born [ i - 1 ] . step ) continue ;
pairs + + ;
var expected = born [ i ] . velocity * ( born [ i ] . time - born [ i - 1 ] . time ) ;
Assert . True ( abs ( born [ i ] . x - born [ i - 1 ] . x - expected ) < 1e-2f , $"round {i}: origins {born[i - 1].x} and {born[i].x}, expected {expected} apart" ) ;
}
Assert . True ( pairs > 100 , $"{pairs} pairs of rounds in one step" ) ;
}
2026-10-23 00:50:18 +02:00
// A cooldown of nothing is a gun bounded by the shortest cycle (and by what its power can pay), not a step that never ends.
2026-10-23 00:50:05 +02:00
[Fact]
2026-10-23 00:50:18 +02:00
public void AGunWithNoCooldownStillEndsItsStep ( )
2026-10-23 00:50:05 +02:00
{
var ship = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { GunWithCooldown ( 0f ) } , new [ ] { "Targeting" , "Reactor" } ) ;
var rounds = HoldTrigger ( ship , Guns ( ship ) [ 0 ] , 1f / 30f , 1f ) ;
2026-10-23 00:50:18 +02:00
Assert . InRange ( rounds . Count , 1 , 2001 ) ;
2026-10-23 00:50:05 +02:00
}
2026-10-22 23:47:39 +02:00
// The cluster's cost: a point-defence battery of 0.005 s guns holding its trigger, every round a queued shot with its own
// roll. The step wall time and the heap bytes per round are written beside the test for the report.
[Fact, Trait("Category", "Slow")]
public void ADenseClusterStepsAtAffordableCost ( )
{
const int guns = 16 ;
var dt = SimClock . GameStep ;
var ships = Enumerable . Range ( 0 , guns ) . Select ( i = > Add ( float3 ( i * 3 , 0 , 0 ) , new [ ] { GunWithCooldown ( . 005f ) } , new [ ] { "Targeting" , "Reactor" } ) ) . ToList ( ) ;
var weapons = ships . Select ( s = > ( InstantWeapon ) Guns ( s ) [ 0 ] . Behaviors . Single ( b = > b is InstantWeapon ) ) . ToList ( ) ;
var capacitors = weapons . Select ( w = > ( InputCapacitor ) typeof ( InstantWeapon ) . GetField ( "_capacitor" , BindingFlags . Instance | BindingFlags . NonPublic ) . GetValue ( w ) ) . ToList ( ) ;
var rounds = 0 ;
foreach ( var w in weapons ) w . OnFire + = _ = > rounds + + ;
foreach ( var s in ships ) s . SetTarget ( TargetRef . None ) ;
void Step ( )
{
2026-10-23 00:02:49 +02:00
for ( var i = 0 ; i < guns ; i + + ) { capacitors [ i ] . AddStepCharge ( 1000f , dt ) ; weapons [ i ] . Activate ( ) ; }
2026-10-22 23:47:39 +02:00
Tick ( dt ) ;
}
for ( var i = 0 ; i < 30 ; i + + ) Step ( ) ; // warm the JIT and the shot queue
2026-10-23 01:47:52 +02:00
foreach ( var w in weapons ) w . Deactivate ( ) ; // the triggers stay down until released
2026-10-23 00:02:49 +02:00
for ( var i = 0 ; i < 60 ; i + + ) Tick ( dt ) ; // let the in-flight rounds resolve
2026-10-22 23:47:39 +02:00
var steps = 300 ;
2026-10-23 00:02:49 +02:00
var idleBytes = GC . GetAllocatedBytesForCurrentThread ( ) ;
var idle = System . Diagnostics . Stopwatch . StartNew ( ) ;
for ( var i = 0 ; i < steps ; i + + ) Tick ( dt ) ;
idle . Stop ( ) ;
idleBytes = GC . GetAllocatedBytesForCurrentThread ( ) - idleBytes ;
rounds = 0 ;
2026-10-22 23:47:39 +02:00
var bytes = GC . GetAllocatedBytesForCurrentThread ( ) ;
var watch = System . Diagnostics . Stopwatch . StartNew ( ) ;
for ( var i = 0 ; i < steps ; i + + ) Step ( ) ;
watch . Stop ( ) ;
bytes = GC . GetAllocatedBytesForCurrentThread ( ) - bytes ;
2026-10-23 00:33:02 +02:00
// Where the bytes go: a round's birth (FireControl.Fire) alone, and the queued rounds' commit and resolution alone.
var item0 = Guns ( ships [ 0 ] ) [ 0 ] ;
2026-10-23 00:43:55 +02:00
for ( var i = 0 ; i < 2000 ; i + + ) FireControl . Fire ( weapons [ 0 ] , item0 , ships [ 0 ] ) ; // grows the shot queue once
Tick ( dt ) ;
2026-10-23 00:33:02 +02:00
var born = GC . GetAllocatedBytesForCurrentThread ( ) ;
for ( var i = 0 ; i < 1000 ; i + + ) FireControl . Fire ( weapons [ 0 ] , item0 , ships [ 0 ] ) ;
born = GC . GetAllocatedBytesForCurrentThread ( ) - born ;
var settle = GC . GetAllocatedBytesForCurrentThread ( ) ;
Tick ( dt ) ;
settle = GC . GetAllocatedBytesForCurrentThread ( ) - settle ;
var line = $"born {born / 1000.0:F0} B per round, 1050 rounds resolved in one step {settle / 1050.0:F0} B per round; {guns} guns at .005 s, {SimClock.TickRate} Hz: {rounds / (double) steps:F1} rounds per step, {watch.Elapsed.TotalMilliseconds / steps:F3} ms per step, {(bytes - idleBytes) / (double) rounds:F0} heap bytes per round beyond the idle step (idle step {idle.Elapsed.TotalMilliseconds / steps:F3} ms, {idleBytes / (double) steps:F0} bytes)" ;
2026-10-22 23:47:39 +02:00
System . IO . File . WriteAllText ( System . IO . Path . Combine ( System . IO . Path . GetTempPath ( ) , "simrate-stepcost.txt" ) , line ) ;
2026-10-22 23:50:25 +02:00
Assert . True ( Math . Abs ( rounds / ( double ) steps - guns * dt / . 005 ) < 1.0 * guns , line ) ;
2026-10-22 23:47:39 +02:00
}
2026-10-22 21:05:13 +02:00
[Fact]
public void MagazineCadenceIsPerSecond ( )
{
foreach ( var dt in CadenceSteps )
{
var name = "Magazine3" ;
if ( _openCache . GetByName < ItemData > ( name ) = = null )
{
var gun = Armed ( new InstantWeaponData { Count = Const ( 1 ) , BurstTime = Const ( 0 ) , Cooldown = Const ( . 1f ) , ReloadTime = . 9f } , 3 ) ;
_openCache . Upsert ( new WeaponItemData
{
Name = name , Hardpoint = HardpointType . Sensors , Shape = new Shape ( ) , Durability = 1 ,
MinimumTemperature = - 1000 , MaximumTemperature = 1000 , OptimalTemperature = 0 , PlateauWidth = 2000 ,
Fuse = WeaponFuse . Proximity , BlastRadius = 30f ,
Behaviors = { gun }
} ) ;
}
var ship = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { name } , new [ ] { "Targeting" , "Reactor" } ) ;
var rounds = HoldTrigger ( ship , Guns ( ship ) [ 0 ] , dt , 33f ) ;
Assert . True ( abs ( rounds . Count - 90f ) < = 3.01f , $"dt {dt}: {rounds.Count} rounds in 33 s, expected 90 +-3" ) ;
}
}
}
public sealed partial class RunStartTests
{
private static readonly string EquivalenceTable = System . IO . Path . Combine ( System . IO . Path . GetTempPath ( ) , "simrate-equivalence.txt" ) ;
// The two-AI fight over 40 match seeds at the old 60 Hz and at the game's step: the same fight to within noise. The
// win rate (east's hull above west's) differs by less than a 95% two-proportion interval, and the mean survivor hull
2026-10-23 00:49:36 +02:00
// and the mean time to first kill each differ by less than their 95% interval. The table is written beside the test for the report.
2026-10-22 21:05:13 +02:00
[Fact, Trait("Category", "Slow")]
public void SimRateEquivalence ( )
{
const int seeds = 40 ;
var table = new System . Text . StringBuilder ( ) ;
2026-10-23 00:49:36 +02:00
( double wins , double survivor , double firstKill , double seconds , double survivorVar , double firstKillVar ) Run ( float dt )
2026-10-22 21:05:13 +02:00
{
var wins = 0 ;
double survivor = 0 , firstKill = 0 , seconds = 0 ;
2026-10-23 00:49:36 +02:00
var survivors = new List < double > ( ) ;
var kills = new List < double > ( ) ;
2026-10-22 21:05:13 +02:00
for ( uint seed = 1 ; seed < = seeds ; seed + + )
{
var fight = TwoAiFight ( seed , dt ) ;
if ( fight . eastHull > fight . westHull ) wins + + ;
survivor + = Math . Max ( fight . eastHull , fight . westHull ) ;
2026-10-23 00:49:36 +02:00
survivors . Add ( Math . Max ( fight . eastHull , fight . westHull ) ) ;
kills . Add ( fight . firstKill ) ;
2026-10-22 21:05:13 +02:00
firstKill + = fight . firstKill ;
seconds + = fight . stepSeconds ;
table . AppendLine ( $"dt {dt:R} seed {seed}: east {fight.eastHull:R} west {fight.westHull:R} first kill {fight.firstKill:R} s, {fight.stepSeconds:F2} s wall" ) ;
}
2026-10-23 00:49:36 +02:00
double Variance ( List < double > xs ) { var m = xs . Average ( ) ; return xs . Sum ( x = > ( x - m ) * ( x - m ) ) / ( xs . Count - 1 ) ; }
return ( wins / ( double ) seeds , survivor / seeds , firstKill / seeds , seconds , Variance ( survivors ) , Variance ( kills ) ) ;
2026-10-22 21:05:13 +02:00
}
var old = Run ( 1f / 60f ) ;
var now = Run ( SimClock . GameStep ) ;
var se = Math . Sqrt ( old . wins * ( 1 - old . wins ) / seeds + now . wins * ( 1 - now . wins ) / seeds ) ;
table . AppendLine ( $"60 Hz: east wins {old.wins:P0}, mean survivor hull {old.survivor:F1}, mean first kill {old.firstKill:F2} s, {old.seconds:F1} s wall" ) ;
table . AppendLine ( $"{SimClock.TickRate} Hz: east wins {now.wins:P0}, mean survivor hull {now.survivor:F1}, mean first kill {now.firstKill:F2} s, {now.seconds:F1} s wall" ) ;
table . AppendLine ( $"win-rate difference {Math.Abs(old.wins - now.wins):F3} (95% interval +-{1.96 * se:F3}); wall-time speedup {old.seconds / now.seconds:F2}x" ) ;
System . IO . File . WriteAllText ( EquivalenceTable , table . ToString ( ) ) ;
Assert . True ( Math . Abs ( old . wins - now . wins ) < = 1.96 * se , table . ToString ( ) ) ;
2026-10-23 00:49:36 +02:00
// The survivor hull is heavy-tailed (a dead hull reads near zero, a survivor up to a few hundred), so its tolerance is the
// 95% interval of the difference of the two means, as the win rate's is, not a fixed fraction of one mean.
var survivorSe = Math . Sqrt ( old . survivorVar / seeds + now . survivorVar / seeds ) ;
var killSe = Math . Sqrt ( old . firstKillVar / seeds + now . firstKillVar / seeds ) ;
table . AppendLine ( $"mean survivor hull difference {Math.Abs(now.survivor - old.survivor):F2} (95% interval +-{1.96 * survivorSe:F2}); mean first kill difference {Math.Abs(now.firstKill - old.firstKill):F2} s (95% interval +-{1.96 * killSe:F2})" ) ;
System . IO . File . WriteAllText ( EquivalenceTable , table . ToString ( ) ) ;
Assert . True ( Math . Abs ( now . survivor - old . survivor ) < = 1.96 * survivorSe , table . ToString ( ) ) ;
Assert . True ( Math . Abs ( now . firstKill - old . firstKill ) < = 1.96 * killSe , table . ToString ( ) ) ;
2026-10-22 21:05:13 +02:00
}
2026-10-22 20:35:28 +02:00
}