2026-10-09 20:17:47 +02:00
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
using CultMath ;
using GameCult.Caching ;
using Xunit ;
using static CultMath . math ;
2026-10-10 19:26:51 +02:00
// A ThrottleLock on an active consumable is a bound on the allocator's solve (ruling overdrive-forward-floor): the stick
// is ignored, forward is the only translation asked, and every thruster whose live column pushes forward is held at
// full; Turn is served by what remains. Every observation is a thruster's Axis after a real Ship.Update, with the effect
// started through Entity.ActivateConsumable. In the default fixture the one forward drive sits on the centre line, so
// the floor changes nothing there and a locked ship answers as an unlocked twin fed full forward and the same Turn
// through the same ticks; the drive-pair fixture is where the floor shows.
2026-10-09 20:17:47 +02:00
public sealed class ThrottleLockTests : IDisposable
{
private readonly string _root = Path . Combine ( Path . GetTempPath ( ) , "aetheria-throttlelock-" + Guid . NewGuid ( ) . ToString ( "N" ) ) ;
private string Catalog = > Path . Combine ( _root , "Aetheria.cc" ) ;
public ThrottleLockTests ( ) = > Directory . CreateDirectory ( _root ) ;
public void Dispose ( ) = > Directory . Delete ( _root , true ) ;
private static GameplaySettings Settings ( ) = > new GameplaySettings
{
DefaultEntitySettings = new EntitySettings ( ) ,
2026-10-17 18:55:16 +02:00
Tiers = new [ ] { new RarityTier { Name = "Common" , Quality = . 5f , Color = new float3 ( 1 , 1 , 1 ) } } ,
2026-10-10 13:44:22 +02:00
QualityPriceModifier = new ExponentialLerp ( ) ,
TorqueMultiplier = 1f
2026-10-09 20:17:47 +02:00
} ;
private static PerformanceStat Constant ( float v ) = > new PerformanceStat { Min = v , Max = v } ;
private static EquippableItem Mint ( CultCache cache , ItemManager items , ItemData design , ItemRotation rotation = ItemRotation . None ) = > new EquippableItem
{
Data = cache . RefOf < ItemData > ( design ) ,
Durability = design is EquippableItemData equippable ? equippable . Durability : 0 ,
Rotation = rotation ,
Lot = items . Lots . Add ( new Lot { Design = cache . RefOf < ItemData > ( design ) , Origin = new Attributed ( ) , Quality = . 5f , Roles = new List < RoleFill > ( ) } )
} ;
private sealed class Fixture
{
public CultCache Cache ;
public ItemManager Items ;
public Ship Ship ;
public ConsumableItemData Lock ;
2026-10-09 20:51:30 +02:00
public Thruster Forward , Reverse , Clockwise ;
2026-10-10 19:26:51 +02:00
public Thruster [ ] Drives ; // every forward-pushing thruster
2026-10-09 20:54:20 +02:00
public Thruster [ ] Right , Left ; // pairs with opposing torque: a lone off-axis strafe thruster cancels its own torque
2026-10-09 20:17:47 +02:00
public void ActivateLock ( ) = > Ship . ActivateConsumable ( new ConsumableItem
{
Data = Cache . RefOf < ItemData > ( Lock ) ,
Lot = Items . Lots . Add ( new Lot { Design = Cache . RefOf < ItemData > ( Lock ) , Origin = new Attributed ( ) , Quality = . 5f , Roles = new List < RoleFill > ( ) } )
} ) ;
}
// One thruster per intent direction, plus two reverse thrusters either side of the centre line, one of
2026-10-10 20:32:18 +02:00
// which has clockwise torque (the one Turn reaches). Thrusters are found by their item's rotation, as the
// allocator's columns carry them.
2026-10-10 19:26:51 +02:00
// With `drivePair`, the forward drive on the centre line is replaced by a strong drive and a weak one either side of
// it (their torques differ), so a hard turn has a drive to idle; the front strafe thrusters give way to them.
private Fixture Build ( float lockDuration , bool drivePair = false )
2026-10-09 20:17:47 +02:00
{
var cache = AetheriaStores . Open ( Catalog , catalogWritable : true ) ;
2026-10-10 07:18:00 +02:00
cache . Upsert ( new VerseGrammar { Revision = 1 } ) ;
2026-10-09 20:17:47 +02:00
var hullShape = new Shape ( 5 , 5 ) ;
foreach ( var cell in hullShape . AllCoordinates ) hullShape [ cell ] = true ;
cache . Upsert ( new HullData { Name = "Skiff" , HullType = HullType . Ship , Shape = hullShape , Durability = 10 , Mass = 1000 } ) ;
cache . Upsert ( new GearData
2026-10-10 19:26:51 +02:00
{
Name = "Weak Thruster" , Hardpoint = HardpointType . Tool , Shape = new Shape ( ) , Durability = 10 ,
MinimumTemperature = 0 , MaximumTemperature = 1000 , OptimalTemperature = 280 , PlateauWidth = 400 ,
Behaviors = { new ThrusterData { Thrust = Constant ( 20 ) , Visibility = Constant ( 0 ) , Heat = Constant ( 0 ) , EnergyUsage = Constant ( 0 ) } }
} ) ;
cache . Upsert ( new GearData
2026-10-09 20:17:47 +02:00
{
Name = "Thruster" , Hardpoint = HardpointType . Tool , Shape = new Shape ( ) , Durability = 10 ,
MinimumTemperature = 0 , MaximumTemperature = 1000 , OptimalTemperature = 280 , PlateauWidth = 400 ,
Behaviors = { new ThrusterData { Thrust = Constant ( 100 ) , Visibility = Constant ( 0 ) , Heat = Constant ( 0 ) , EnergyUsage = Constant ( 0 ) } }
} ) ;
var lockData = new ConsumableItemData { Name = "Overdrive" , Duration = lockDuration , Behaviors = { new ThrottleLockData ( ) } } ;
cache . Upsert ( lockData ) ;
cache . FlushAsync ( ) . Wait ( ) ;
2026-10-21 19:01:41 +02:00
var items = new ItemManager ( 1 u , cache , new ProvenanceLedger ( ) , Settings ( ) , _ = > { } ) ;
2026-10-09 20:17:47 +02:00
var zone = new Zone ( items , new PlanetSettings ( ) , new ZonePack ( ) , new GalaxyZone { Name = "Test Zone" , Owner = null } , null ) ;
var ship = new Ship ( items , zone , Mint ( cache , items , cache . GetByName < HullData > ( "Skiff" ) ) , new EntitySettings ( ) ) ;
var gear = cache . GetByName < GearData > ( "Thruster" ) ;
2026-10-10 19:26:51 +02:00
var weak = cache . GetByName < GearData > ( "Weak Thruster" ) ;
if ( drivePair )
{
Assert . True ( ship . TryEquip ( Mint ( cache , items , gear , ItemRotation . Reversed ) , new int2 ( 1 , 1 ) ) ) ;
Assert . True ( ship . TryEquip ( Mint ( cache , items , weak , ItemRotation . Reversed ) , new int2 ( 3 , 1 ) ) ) ;
}
else Assert . True ( ship . TryEquip ( Mint ( cache , items , gear , ItemRotation . Reversed ) , new int2 ( 2 , 1 ) ) ) ;
2026-10-09 20:32:00 +02:00
Assert . True ( ship . TryEquip ( Mint ( cache , items , gear , ItemRotation . None ) , new int2 ( 1 , 2 ) ) ) ;
Assert . True ( ship . TryEquip ( Mint ( cache , items , gear , ItemRotation . None ) , new int2 ( 3 , 2 ) ) ) ;
2026-10-09 20:54:20 +02:00
// Strafe pairs sit either side of the centre line across the ship's length, so their torques oppose.
2026-10-10 19:26:51 +02:00
if ( ! drivePair )
{
Assert . True ( ship . TryEquip ( Mint ( cache , items , gear , ItemRotation . CounterClockwise ) , new int2 ( 1 , 1 ) ) ) ;
Assert . True ( ship . TryEquip ( Mint ( cache , items , gear , ItemRotation . Clockwise ) , new int2 ( 3 , 1 ) ) ) ;
}
2026-10-09 20:51:30 +02:00
Assert . True ( ship . TryEquip ( Mint ( cache , items , gear , ItemRotation . CounterClockwise ) , new int2 ( 1 , 3 ) ) ) ;
2026-10-09 20:54:20 +02:00
Assert . True ( ship . TryEquip ( Mint ( cache , items , gear , ItemRotation . Clockwise ) , new int2 ( 3 , 3 ) ) ) ;
2026-10-13 02:13:43 +02:00
zone . Admit ( ship , piloted : false ) ;
2026-10-09 20:17:47 +02:00
ship . Aim = float3 ( 0 , 0 , 1 ) ;
2026-10-10 14:24:05 +02:00
// A thruster is live from its first performance update; the allocator reads that every tick.
foreach ( var item in ship . Equipment ) item . UpdatePerformance ( ) ;
2026-10-09 20:17:47 +02:00
var thrusters = ship . GetBehaviors < Thruster > ( ) . ToArray ( ) ;
return new Fixture
{
Cache = cache , Items = items , Ship = ship , Lock = cache . GetByName < ConsumableItemData > ( "Overdrive" ) ,
2026-10-10 19:26:51 +02:00
Drives = thrusters . Where ( t = > t . Item . EquippableItem . Rotation = = ItemRotation . Reversed ) . ToArray ( ) ,
Forward = thrusters . First ( t = > t . Item . EquippableItem . Rotation = = ItemRotation . Reversed ) ,
2026-10-09 20:51:30 +02:00
Right = thrusters . Where ( t = > t . Item . EquippableItem . Rotation = = ItemRotation . CounterClockwise ) . ToArray ( ) ,
Left = thrusters . Where ( t = > t . Item . EquippableItem . Rotation = = ItemRotation . Clockwise ) . ToArray ( ) ,
2026-10-09 20:17:47 +02:00
Reverse = thrusters . First ( t = > t . Item . EquippableItem . Rotation = = ItemRotation . None & & t . Torque < = 0 ) ,
Clockwise = thrusters . First ( t = > t . Item . EquippableItem . Rotation = = ItemRotation . None & & t . Torque > 0 )
} ;
}
2026-10-10 13:44:22 +02:00
private static float [ ] Axes ( Fixture f ) = > f . Ship . GetBehaviors < Thruster > ( ) . Select ( t = > t . Axis ) . ToArray ( ) ;
private static float Apart ( float [ ] a , float [ ] b ) = > a . Zip ( b , ( x , y ) = > Math . Abs ( x - y ) ) . Max ( ) ;
// Drives a (possibly locked) ship at `intent` and its unlocked twin at full forward, both at the same Turn and dt, and
2026-10-10 19:42:37 +02:00
// requires every thruster to answer alike to 1e-4: the lock holds its drive at exactly 1, and the unfloored twin's solve
// lands within the solver's few millionths of it (measured 4e-6).
2026-10-10 13:44:22 +02:00
private static void Tick ( Fixture ship , Fixture twin , float2 intent , float turn , float dt )
{
ship . Ship . MovementDirection = intent ;
twin . Ship . MovementDirection = float2 ( 0 , 1 ) ;
ship . Ship . Turn = twin . Ship . Turn = turn ;
ship . Ship . Update ( dt ) ;
twin . Ship . Update ( dt ) ;
2026-10-10 19:39:42 +02:00
var apart = Apart ( Axes ( ship ) , Axes ( twin ) ) ;
2026-10-10 19:42:37 +02:00
Assert . True ( apart < 1e-4f , $"the ship's throttles differ from its full-forward twin's by {apart}: [{string.Join(" ", Axes(ship))}] against [{string.Join(" ", Axes(twin))}]" ) ;
2026-10-10 15:43:52 +02:00
Assert . True ( twin . Forward . Axis > . 98f , "fixture: the twin's forward drive is at full" ) ;
2026-10-10 13:44:22 +02:00
}
// Spec ALockedThrottleIgnoresIntent. Mutation: read MovementDirection in place of move in Ship.Update and a locked
// ship stops answering as its full-forward twin does.
2026-10-09 20:17:47 +02:00
[Fact]
public void ALockedThrottleIgnoresIntent ( )
{
var f = Build ( 100f ) ;
2026-10-10 13:44:22 +02:00
var twin = Build ( 100f ) ;
2026-10-09 20:17:47 +02:00
using var _ = f . Cache ;
2026-10-10 13:44:22 +02:00
using var __ = twin . Cache ;
2026-10-09 20:17:47 +02:00
f . ActivateLock ( ) ;
Assert . True ( f . Ship . ThrottleLocked ) ;
2026-10-10 13:44:22 +02:00
Assert . False ( twin . Ship . ThrottleLocked ) ;
2026-10-09 20:17:47 +02:00
foreach ( var intent in new [ ] { float2 ( 0 , - 1 ) , float2 ( 1 , 0 ) , float2 ( - 1 , 0 ) } )
2026-10-10 13:44:22 +02:00
Tick ( f , twin , intent , 0 , 0.01f ) ;
2026-10-09 20:17:47 +02:00
2026-10-10 13:44:22 +02:00
// Turning stays with the pilot: a Turn moves the throttles while the lock holds, and the lock follows it.
Tick ( f , twin , float2 ( 0 , - 1 ) , 1 , 0.01f ) ;
var turning = Axes ( twin ) ;
Tick ( f , twin , float2 ( 0 , - 1 ) , 0 , 0.01f ) ;
Assert . True ( Apart ( turning , Axes ( twin ) ) > . 01f , "a Turn changes the throttles" ) ;
2026-10-09 20:17:47 +02:00
}
2026-10-10 13:44:22 +02:00
// Spec TheLockEndsWithItsEffect. Mutation: make ThrottleLocked a latch (set on activation, never cleared) and the
// ship keeps answering as its full-forward twin after expiry.
2026-10-09 20:17:47 +02:00
[Fact]
public void TheLockEndsWithItsEffect ( )
{
var f = Build ( 1f ) ;
2026-10-10 13:44:22 +02:00
var twin = Build ( 1f ) ;
2026-10-09 20:17:47 +02:00
using var _ = f . Cache ;
2026-10-10 13:44:22 +02:00
using var __ = twin . Cache ;
2026-10-09 20:17:47 +02:00
f . ActivateLock ( ) ;
2026-10-10 13:44:22 +02:00
Tick ( f , twin , float2 ( 0 , - 1 ) , 0 , 0.5f ) ;
Assert . True ( f . Reverse . Axis < 1e-6f ) ;
2026-10-09 20:17:47 +02:00
2026-10-10 13:44:22 +02:00
Tick ( f , twin , float2 ( 0 , - 1 ) , 0 , 2f ) ; // runs past the Duration; the effect leaves at the end of this tick
2026-10-09 20:17:47 +02:00
Assert . False ( f . Ship . ThrottleLocked ) ;
2026-10-10 13:44:22 +02:00
f . Ship . MovementDirection = float2 ( 0 , - 1 ) ;
twin . Ship . MovementDirection = float2 ( 0 , 1 ) ;
2026-10-09 20:17:47 +02:00
f . Ship . Update ( 0.01f ) ;
2026-10-10 13:44:22 +02:00
twin . Ship . Update ( 0.01f ) ;
Assert . True ( f . Reverse . Axis > . 5f ) ;
Assert . True ( f . Forward . Axis < 1e-6f ) ;
Assert . True ( Apart ( Axes ( f ) , Axes ( twin ) ) > . 5f , "the unlocked ship follows its stick, not the twin's full forward" ) ;
2026-10-09 20:17:47 +02:00
}
2026-10-10 13:44:22 +02:00
// Spec AnUnlockedShipFollowsIntent. Mutation: replace the conditional by an unconditional float2(0, 1) and the four
// intents give the same throttles.
2026-10-09 20:17:47 +02:00
[Fact]
public void AnUnlockedShipFollowsIntent ( )
{
var f = Build ( 1f ) ;
using var _ = f . Cache ;
Assert . False ( f . Ship . ThrottleLocked ) ;
2026-10-10 13:44:22 +02:00
float [ ] Fly ( float2 intent )
{
f . Ship . MovementDirection = intent ;
f . Ship . Update ( 0.01f ) ;
return Axes ( f ) ;
}
2026-10-09 20:17:47 +02:00
2026-10-10 13:44:22 +02:00
var back = Fly ( float2 ( 0 , - 1 ) ) ;
Assert . True ( f . Reverse . Axis > . 5f ) ;
Assert . True ( f . Forward . Axis < 1e-6f ) ;
2026-10-09 20:17:47 +02:00
2026-10-10 13:44:22 +02:00
var right = Fly ( float2 ( 1 , 0 ) ) ;
Assert . True ( f . Right . Max ( t = > t . Axis ) > . 5f ) ;
Assert . All ( f . Left , t = > Assert . True ( t . Axis < 1e-6f ) ) ;
2026-10-09 20:51:30 +02:00
2026-10-10 13:44:22 +02:00
var left = Fly ( float2 ( - 1 , 0 ) ) ;
Assert . True ( f . Left . Max ( t = > t . Axis ) > . 5f ) ;
Assert . All ( f . Right , t = > Assert . True ( t . Axis < 1e-6f ) ) ;
var forward = Fly ( float2 ( 0 , 1 ) ) ;
2026-10-10 15:43:52 +02:00
Assert . True ( f . Forward . Axis > . 98f , "a full stick asks for the hull's whole forward thrust" ) ;
2026-10-10 13:44:22 +02:00
Assert . True ( f . Reverse . Axis < 1e-6f ) ;
2026-10-10 14:29:18 +02:00
// The stick's length is the fraction of the hull's forward thrust asked for, and no stick asks for nothing.
Fly ( float2 ( 0 , . 5f ) ) ;
Assert . InRange ( f . Forward . Axis , . 4f , . 6f ) ;
Assert . All ( Fly ( float2 ( 0 , 0 ) ) , a = > Assert . True ( a < 1e-6f ) ) ;
2026-10-10 13:44:22 +02:00
var all = new [ ] { back , right , left , forward } ;
for ( var a = 0 ; a < all . Length ; a + + )
for ( var b = a + 1 ; b < all . Length ; b + + )
Assert . True ( Apart ( all [ a ] , all [ b ] ) > . 1f , "two different intents gave the same throttles" ) ;
2026-10-09 20:17:47 +02:00
}
2026-10-09 23:46:33 +02:00
// Finding zero-stick-unpinned. Mutation: float2(0, length(MovementDirection)) as the locked intent. The lock
// supplies full forward whatever the stick's magnitude, including none and a short reverse push.
[Fact]
public void ALockedShipGetsFullForwardWhateverTheStickLength ( )
{
var f = Build ( 100f ) ;
2026-10-10 13:44:22 +02:00
var twin = Build ( 100f ) ;
2026-10-09 23:46:33 +02:00
using var _ = f . Cache ;
2026-10-10 13:44:22 +02:00
using var __ = twin . Cache ;
2026-10-09 23:46:33 +02:00
f . ActivateLock ( ) ;
foreach ( var intent in new [ ] { float2 ( 0 , 0 ) , float2 ( . 3f , . 2f ) , float2 ( - . 5f , - . 5f ) } )
2026-10-10 13:44:22 +02:00
Tick ( f , twin , intent , 0 , 0.01f ) ;
2026-10-09 23:46:33 +02:00
}
2026-10-10 19:26:51 +02:00
private static float Yaw ( Fixture f ) = > f . Ship . GetBehaviors < Thruster > ( ) . Sum ( t = > t . Axis * t . Column ( t . NominalThrust ) . z ) ;
// Ruling overdrive-forward-floor. Mutations: no floor (the lock is only the intent (0,1)); the floor on a function of
// the column (y above half the strongest forward push, which spares the weak drive); the floor by type. Under the
// lock every thruster whose live column pushes forward holds full through a hard turn either way, the turn is
// slower than the unlocked ship's, the stick is ignored, and a drive that is not live is not floored.
[Theory]
[InlineData(1f)]
[InlineData(-1f)]
public void ALockedHardTurnKeepsEveryForwardDriveAtFull ( float turn )
{
var locked = Build ( 100f , drivePair : true ) ;
var open = Build ( 100f , drivePair : true ) ;
var blind = Build ( 100f , drivePair : true ) ;
using var _ = locked . Cache ;
using var __ = open . Cache ;
using var ___ = blind . Cache ;
locked . ActivateLock ( ) ;
blind . ActivateLock ( ) ;
Assert . Equal ( 2 , locked . Drives . Length ) ;
for ( var tick = 0 ; tick < 3 ; tick + + )
2026-10-09 23:46:33 +02:00
{
2026-10-10 19:26:51 +02:00
locked . Ship . MovementDirection = float2 ( 1 , - 1 ) ;
blind . Ship . MovementDirection = float2 ( 0 , 0 ) ;
open . Ship . MovementDirection = float2 ( 0 , 1 ) ;
foreach ( var f in new [ ] { locked , open , blind } ) { f . Ship . Turn = turn ; f . Ship . Update ( 0.01f ) ; }
}
Assert . All ( locked . Drives , d = > Assert . True ( d . Axis > . 98f , "a forward-pushing thruster holds full under the lock" ) ) ;
Assert . True ( open . Drives . Min ( d = > d . Axis ) < . 9f , "fixture: the unlocked ship idles a drive to turn" ) ;
Assert . True ( Yaw ( locked ) * turn < Yaw ( open ) * turn - 1e-4f , "the locked turn is what remains after the drives" ) ;
Assert . True ( Apart ( Axes ( locked ) , Axes ( blind ) ) < 1e-6f , "the stick is ignored under the lock" ) ;
}
[Fact]
public void ALockHoldsOnlyTheDrivesThatPushForward ( )
{
var f = Build ( 100f , drivePair : true ) ;
using var _ = f . Cache ;
f . ActivateLock ( ) ;
var down = f . Drives [ 0 ] ;
down . Item . Enabled . Value = false ;
for ( var tick = 0 ; tick < 3 ; tick + + )
{
f . Ship . MovementDirection = float2 ( 0 , 1 ) ;
f . Ship . Turn = 1 ;
2026-10-09 23:46:33 +02:00
f . Ship . Update ( 0.01f ) ;
}
2026-10-10 19:26:51 +02:00
Assert . False ( down . Item . Active . Value ) ;
Assert . Equal ( 0f , down . Axis ) ;
Assert . True ( f . Drives [ 1 ] . Axis > . 98f ) ;
2026-10-09 23:46:33 +02:00
}
2026-10-10 13:44:22 +02:00
// Finding ccw-turn-unpinned. Mutation: drop the counter-clockwise Turn. Turn in either sign gives the locked ship
// its full-forward twin's throttles, and the twin's solved net turns the way it was asked.
2026-10-09 23:46:33 +02:00
[Fact]
public void ALockedShipTurnsBothWays ( )
{
var f = Build ( 100f ) ;
2026-10-10 13:44:22 +02:00
var twin = Build ( 100f ) ;
2026-10-09 23:46:33 +02:00
using var _ = f . Cache ;
2026-10-10 13:44:22 +02:00
using var __ = twin . Cache ;
2026-10-09 23:46:33 +02:00
f . ActivateLock ( ) ;
2026-10-10 13:44:22 +02:00
var thrusters = twin . Ship . GetBehaviors < Thruster > ( ) . ToArray ( ) ;
2026-10-09 23:46:33 +02:00
foreach ( var turn in new [ ] { 1f , - 1f } )
{
2026-10-10 13:44:22 +02:00
Tick ( f , twin , float2 ( 1 , - 1 ) , turn , 0.01f ) ;
var yaw = thrusters . Sum ( t = > t . Axis * t . Column ( t . NominalThrust ) . z ) ;
Assert . True ( yaw * turn > 1e-4f , "the solved net turns the way it was asked" ) ;
2026-10-09 23:46:33 +02:00
}
}
// Finding non-lock-consumable-unpinned. Mutation: any active consumable locks. A consumable without
2026-10-10 13:44:22 +02:00
// ThrottleLockData leaves the pilot's intent in force: the ship answers as an unlocked ship at the same stick.
2026-10-09 23:46:33 +02:00
[Fact]
public void AConsumableWithoutTheLockDoesNotLock ( )
{
var f = Build ( 100f ) ;
2026-10-10 13:44:22 +02:00
var twin = Build ( 100f ) ;
2026-10-09 23:46:33 +02:00
using var _ = f . Cache ;
2026-10-10 13:44:22 +02:00
using var __ = twin . Cache ;
2026-10-09 23:46:33 +02:00
var plain = new ConsumableItemData { Name = "Plain" , Duration = 100f } ;
f . Cache . Upsert ( plain ) ;
f . Ship . ActivateConsumable ( new ConsumableItem
{
Data = f . Cache . RefOf < ItemData > ( plain ) ,
Lot = f . Items . Lots . Add ( new Lot { Design = f . Cache . RefOf < ItemData > ( plain ) , Origin = new Attributed ( ) , Quality = . 5f , Roles = new List < RoleFill > ( ) } )
} ) ;
Assert . False ( f . Ship . ThrottleLocked ) ;
2026-10-10 13:44:22 +02:00
f . Ship . MovementDirection = twin . Ship . MovementDirection = float2 ( 0 , - 1 ) ;
2026-10-09 23:46:33 +02:00
f . Ship . Update ( 0.01f ) ;
2026-10-10 13:44:22 +02:00
twin . Ship . Update ( 0.01f ) ;
Assert . True ( Apart ( Axes ( f ) , Axes ( twin ) ) < 1e-6f ) ;
Assert . True ( f . Reverse . Axis > . 5f ) ;
Assert . True ( f . Forward . Axis < 1e-6f ) ;
2026-10-09 23:46:33 +02:00
}
2026-10-10 05:26:51 +02:00
// Finding lock-end-boundary-unpinned, finding lock-end-at-90pct-unpinned. Mutations: the lock ends at half
// Duration, at 0.8 or 0.9 of Duration, or one tick late. Duration 1 stepped in 0.25 s ticks (exact in
2026-10-10 13:44:22 +02:00
// floats) answers as the full-forward twin on exactly four ticks, the effect is gone when the fourth ends,
2026-10-10 05:26:51 +02:00
// and the fifth tick follows the stick. The count scales with Duration: 2 s locks eight ticks.
[Theory]
[InlineData(1f, 4)]
[InlineData(2f, 8)]
public void TheLockEndsAtTheExactTickOfItsDuration ( float duration , int lockedTicks )
2026-10-09 23:46:33 +02:00
{
2026-10-10 05:26:51 +02:00
var f = Build ( duration ) ;
2026-10-10 13:44:22 +02:00
var twin = Build ( duration ) ;
2026-10-09 23:46:33 +02:00
using var _ = f . Cache ;
2026-10-10 13:44:22 +02:00
using var __ = twin . Cache ;
2026-10-09 23:46:33 +02:00
f . ActivateLock ( ) ;
2026-10-10 05:26:51 +02:00
for ( var i = 0 ; i < lockedTicks ; i + + )
2026-10-09 23:46:33 +02:00
{
Assert . True ( f . Ship . ThrottleLocked ) ;
2026-10-10 13:44:22 +02:00
Tick ( f , twin , float2 ( 0 , - 1 ) , 0 , . 25f ) ;
Assert . True ( f . Reverse . Axis < 1e-6f ) ;
2026-10-09 23:46:33 +02:00
}
Assert . False ( f . Ship . ThrottleLocked ) ;
2026-10-10 13:44:22 +02:00
f . Ship . MovementDirection = float2 ( 0 , - 1 ) ;
twin . Ship . MovementDirection = float2 ( 0 , 1 ) ;
2026-10-09 23:46:33 +02:00
f . Ship . Update ( . 25f ) ;
2026-10-10 13:44:22 +02:00
twin . Ship . Update ( . 25f ) ;
Assert . True ( f . Reverse . Axis > . 5f ) ;
Assert . True ( f . Forward . Axis < 1e-6f ) ;
2026-10-09 23:46:33 +02:00
}
2026-10-09 20:17:47 +02:00
}