2026-10-15 00:17:21 +02:00
using System ;
using System.Linq ;
using CultMath ;
using Xunit ;
using static CultMath . math ;
using float2 = CultMath . float2 ;
using float3 = CultMath . float3 ;
using int2 = CultMath . int2 ;
// Visibility 5b (rulings quiet-gear-identifiable-hull-layout-known, ew-aim-under-hull-lie-ruled, reveal-ends-on-perception,
// schematic-and-model-are-separate-spaces): an observer that identifies a hull knows where its mounts are, and can aim at a
2026-10-15 00:31:57 +02:00
// mount it has located without knowing what is fitted there. The fixture hull "Bay" has a reactor mount (the 2x2 block at
// (3,3), the reactor the one cell at (3,3) inside it), a shield mount and a weapon mount, and nothing ever acts unless a test
2026-10-15 00:17:21 +02:00
// makes it. Identified is TargetGearInfoThreshold (.8) of the observer's info on the ship. Every fixture drives the
// production path (Fire, CommitProbability, SchematicRows, TrySelectTargetItem, the AI's Combat state inside Zone.Update).
// Entities and items are compared with ReferenceEquals: a failing xunit assertion formats its arguments, and an Entity's
// graph overflows the test host.
public sealed partial class ContactRevealTests
{
private const float Identify = . 8f ;
// A Bay at `position`: its reactor on the reactor mount, a gun on the weapon mount when asked (with the targeting system
// and reactor tool it needs to fire), and `tools` on free cells.
2026-10-15 01:39:37 +02:00
private Ship NewShip ( string hull )
2026-10-15 00:17:21 +02:00
{
2026-10-15 01:39:37 +02:00
var hullRef = _items . ItemData . RefOf < ItemData > ( _items . ItemData . GetByName < HullData > ( hull ) ) ;
return new Ship ( _items , _zone , new EquippableItem { Data = hullRef , Durability = 1000000 , Lot = _nextLot + + } , new EntitySettings ( ) )
2026-10-15 00:17:21 +02:00
{ Faction = new Faction { Name = "F" + Guid . NewGuid ( ) . ToString ( "N" ) } } ;
2026-10-15 01:39:37 +02:00
}
private Ship Place ( Ship ship , float3 position )
{
_zone . Admit ( ship , piloted : false ) ;
ship . Position = position ;
ship . Aim = float3 ( 0 , 0 , 1 ) ;
_zone . Update ( 0f ) ;
return ship ;
}
private Ship AddBay ( float3 position , bool gun = false , string [ ] tools = null )
{
var ship = NewShip ( "Bay" ) ;
2026-10-15 00:31:57 +02:00
Assert . True ( ship . TryEquip ( Make ( "BayReactor" ) , new int2 ( 3 , 3 ) ) ) ;
2026-10-15 00:17:21 +02:00
if ( gun ) Assert . True ( ship . TryEquip ( Make ( "Gun" ) , new int2 ( 0 , 3 ) ) ) ;
foreach ( var name in ( tools ? ? new string [ 0 ] ) . Concat ( gun ? new [ ] { "Targeting" , "Reactor" } : new string [ 0 ] ) )
{
var item = Make ( name ) ;
Assert . True ( ship . TryFindSpace ( item , out var at ) ) ;
Assert . True ( ship . TryEquip ( item , at ) ) ;
}
2026-10-15 01:39:37 +02:00
return Place ( ship , position ) ;
}
// A Wing at `position` with a gun on each of its two Sensors mounts: gunA on the mount at (0,0), gunB at (2,3).
private ( Ship wing , EquippedItem gunA , EquippedItem gunB ) AddWing ( float3 position )
{
var ship = NewShip ( "Wing" ) ;
Assert . True ( ship . TryEquip ( Make ( "Gun" ) , new int2 ( 0 , 0 ) ) ) ;
Assert . True ( ship . TryEquip ( Make ( "Gun" ) , new int2 ( 2 , 3 ) ) ) ;
Place ( ship , position ) ;
2026-10-15 01:52:44 +02:00
var gunA = ship . Equipment . Single ( i = > i . Data . Name = = "Gun" & & i . Position . x = = 0 ) ;
var gunB = ship . Equipment . Single ( i = > i . Data . Name = = "Gun" & & i . Position . x = = 2 ) ;
2026-10-15 01:39:37 +02:00
Assert . True ( gunA . Hardpoint ! = null & & gunB . Hardpoint ! = null & & ! ReferenceEquals ( gunA . Hardpoint , gunB . Hardpoint ) , "fixture: each gun sits on its own mount" ) ;
return ( ship , gunA , gunB ) ;
2026-10-15 00:17:21 +02:00
}
private static EquippedItem Fitted ( Entity e , string design ) = > e . Equipment . Single ( i = > i . Data . Name = = design ) ;
// An observer at the origin that identifies a Bay 100 ahead (nothing revealed, nothing targeted).
private ( Ship bay , Ship observer , EquippedItem reactor ) Identified ( float info = 1f )
{
var bay = AddBay ( float3 ( 0 , 0 , 100 ) ) ;
var observer = Add ( float3 ( 0 , 0 , 0 ) ) ;
observer . EntityInfoGathered [ bay ] = info ;
return ( bay , observer , Fitted ( bay , "BayReactor" ) ) ;
}
private static void Target ( Entity observer , Entity subject )
{
observer . SetTarget ( subject ) ;
Assert . True ( ReferenceEquals ( subject , observer . Target . Value . Entity ) , "fixture: the observer targets the ship" ) ;
}
// ---- AnIdentifiedHullShowsItsMounts ----
// At the identify threshold the observer's layout of the ship is its hull's hardpoints; just below it is empty.
[Fact]
public void AnIdentifiedHullShowsItsMounts ( )
{
var ( bay , observer , _ ) = Identified ( Identify ) ;
Assert . Equal ( 3 , bay . HullData . Hardpoints . Count ) ;
Assert . True ( bay . HullData . Hardpoints . SequenceEqual ( observer . LayoutOf ( bay ) ) , "the layout is the hull's three hardpoints, in order" ) ;
observer . EntityInfoGathered [ bay ] = Identify - . 01f ;
Assert . Empty ( observer . LayoutOf ( bay ) ) ;
}
2026-10-15 04:42:38 +02:00
// ---- TheReactorMountIsAimedNotRevealed ----
2026-10-15 00:17:21 +02:00
2026-10-15 04:42:38 +02:00
// Identified, the reactor never acted: the observer has located its mount (can aim at it) and learned nothing of the
// item. Below the identify threshold it can aim at no mount, and a mount aim already taken is dropped when the
// identification is lost. An unrevealed item is never an item aim.
2026-10-15 00:17:21 +02:00
[Fact]
2026-10-15 04:42:38 +02:00
public void TheReactorMountIsAimedNotRevealed ( )
2026-10-15 00:17:21 +02:00
{
var ( bay , observer , reactor ) = Identified ( Identify - . 01f ) ;
Target ( observer , bay ) ;
2026-10-15 04:42:38 +02:00
Assert . False ( observer . TrySelectMount ( reactor . Hardpoint ) ) ;
2026-10-15 00:17:21 +02:00
observer . EntityInfoGathered [ bay ] = Identify ;
Assert . False ( FireControl . IsRevealed ( observer , reactor ) ) ;
2026-10-15 04:42:38 +02:00
Assert . False ( observer . TrySelectTargetItem ( reactor ) , "an unrevealed item is not an item aim" ) ;
Assert . True ( observer . TrySelectMount ( reactor . Hardpoint ) ) ;
Assert . True ( ReferenceEquals ( reactor . Hardpoint , observer . ResolvedAim . Mount ) , "the located reactor mount is the aim" ) ;
Assert . True ( observer . ResolvedAim . Item = = null & & observer . ResolvedTargetItem = = null , "a mount aim names no item" ) ;
2026-10-15 00:17:21 +02:00
observer . EntityInfoGathered [ bay ] = Identify - . 01f ;
2026-10-15 04:42:38 +02:00
Assert . True ( observer . ResolvedAim . Mount = = null , "the aim is dropped with the identification" ) ;
2026-10-15 00:17:21 +02:00
}
2026-10-15 04:42:38 +02:00
// ---- AnUnperceivedDestructionLeavesTheMountAimed ----
2026-10-15 00:17:21 +02:00
2026-10-15 04:42:38 +02:00
// The observer perceives a destruction only through its own Sense: the reactor destroyed unseen leaves its mount aimed
// (a live read of the item's Durability would tell the observer what it has not sensed); once Sensed it is gone.
2026-10-15 00:17:21 +02:00
[Fact]
2026-10-15 04:42:38 +02:00
public void AnUnperceivedDestructionLeavesTheMountAimed ( )
2026-10-15 00:17:21 +02:00
{
var ( bay , observer , reactor ) = Identified ( ) ;
Target ( observer , bay ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( observer . TrySelectMount ( reactor . Hardpoint ) , "fixture: the reactor mount is aimed at" ) ;
2026-10-15 00:17:21 +02:00
reactor . EquippableItem . Durability = 0f ;
Assert . True ( reactor . EquippableItem . IsDestroyed , "fixture" ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( ReferenceEquals ( reactor . Hardpoint , observer . ResolvedAim . Mount ) , "nothing sensed yet: the aim stands" ) ;
Assert . True ( observer . TrySelectMount ( reactor . Hardpoint ) ) ;
SenseOf ( observer ) ( ) ;
Assert . True ( observer . ResolvedAim . Mount = = null , "perceived destroyed: the aim goes" ) ;
Assert . False ( observer . TrySelectMount ( reactor . Hardpoint ) ) ;
2026-10-15 00:17:21 +02:00
}
2026-10-15 04:42:38 +02:00
// ---- UnderAHullLieNoGearIsAimed ----
2026-10-15 00:17:21 +02:00
// Ruling ew-aim-under-hull-lie-ruled: while the lie is believed the observer's layout is the claimed hull's and the true
2026-10-15 04:42:38 +02:00
// reactor sits on none of its mounts, so the true item is never an aim: the claimed mount is, and the true reactor's own
// mount is not; the ship stays targetable. The true mount is aimable again when the lie is seen through.
2026-10-15 00:17:21 +02:00
[Fact]
2026-10-15 04:42:38 +02:00
public void UnderAHullLieNoGearIsAimed ( )
2026-10-15 00:17:21 +02:00
{
var ( bay , observer , reactor ) = Identified ( ) ;
var decoy = new HullData { Name = "Decoy" , Hardpoints = { new HardpointData { Type = HardpointType . Reactor , Position = new int2 ( 0 , 0 ) , Shape = new Shape ( ) } } } ;
bay . Claim ( decoy ) ;
Assert . True ( decoy . Hardpoints . SequenceEqual ( observer . LayoutOf ( bay ) ) & & observer . LayoutOf ( bay ) . Count = = 1 , "the layout is the claimed hull's" ) ;
Target ( observer , bay ) ;
2026-10-15 04:42:38 +02:00
Assert . False ( observer . TrySelectTargetItem ( reactor ) , "the true reactor is not an item aim" ) ;
Assert . False ( observer . TrySelectMount ( reactor . Hardpoint ) , "nor is its true mount, which the observer does not believe in" ) ;
Assert . True ( observer . TrySelectMount ( decoy . Hardpoints [ 0 ] ) , "the claimed mount is" ) ;
Assert . True ( ReferenceEquals ( decoy . Hardpoints [ 0 ] , observer . ResolvedAim . Mount ) ) ;
2026-10-15 00:17:21 +02:00
Assert . True ( observer . ResolvedTargetItem = = null ) ;
Assert . True ( ReferenceEquals ( bay , observer . Target . Value . Entity ) , "the ship stays targetable" ) ;
bay . Claim ( null ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( observer . ResolvedAim . Mount = = null , "the claimed mount is not the believed hull's once the lie is seen through" ) ;
Assert . True ( observer . TrySelectMount ( reactor . Hardpoint ) ) ;
2026-10-15 00:17:21 +02:00
}
2026-10-15 01:39:37 +02:00
// The lie is a lookalike: the claimed hull is of the true hull's type and size, with a reactor mount of the same type,
// position and shape as the true one, and a shield mount elsewhere. What the observer knows is the claimed layout, mount
// object by mount object; nothing of the true hull's layout leaks in because the two are alike.
[Fact]
2026-10-15 04:42:38 +02:00
public void UnderALookalikeLieNoGearIsAimed ( )
2026-10-15 01:39:37 +02:00
{
var ( bay , observer , reactor ) = Identified ( ) ;
var truth = bay . HullData ;
var lookalike = new HullData
{
Name = "Lookalike" , HullType = truth . HullType , Shape = new Shape ( truth . Shape . Width , truth . Shape . Height ) ,
Hardpoints =
{
new HardpointData { Type = HardpointType . Reactor , Position = truth . Hardpoints [ 0 ] . Position , Shape = truth . Hardpoints [ 0 ] . Shape } ,
new HardpointData { Type = HardpointType . Shield , Position = new int2 ( 3 , 1 ) , Shape = new Shape ( ) }
}
} ;
bay . Claim ( lookalike ) ;
Assert . True ( truth . Shape . Width = = lookalike . Shape . Width & & truth . Shape . Height = = lookalike . Shape . Height & & truth . HullType = = lookalike . HullType , "fixture: the two hulls are one size and type" ) ;
Assert . True ( lookalike . Hardpoints [ 0 ] . Type = = reactor . Hardpoint . Type & & lookalike . Hardpoints [ 0 ] . Position . Equals ( reactor . Hardpoint . Position ) , "fixture: the claimed reactor mount looks like the true one" ) ;
Assert . False ( ReferenceEquals ( lookalike . Hardpoints [ 0 ] , reactor . Hardpoint ) , "fixture: and is not it" ) ;
Assert . True ( lookalike . Hardpoints . SequenceEqual ( observer . LayoutOf ( bay ) ) , "the layout is the claimed hull's, not the true hull's" ) ;
Target ( observer , bay ) ;
Assert . False ( observer . TrySelectTargetItem ( reactor ) ) ;
2026-10-15 04:42:38 +02:00
Assert . False ( observer . TrySelectMount ( reactor . Hardpoint ) , "the true reactor mount is not the claimed one, though they look alike" ) ;
Assert . True ( observer . TrySelectMount ( lookalike . Hardpoints [ 0 ] ) ) ;
2026-10-15 01:39:37 +02:00
Assert . True ( ReferenceEquals ( bay , observer . Target . Value . Entity ) , "the ship stays targetable" ) ;
Assert . Equal ( 2 , observer . SchematicRows ( bay ) . Count ( r = > r . Mount ! = null ) ) ;
}
2026-10-15 04:42:38 +02:00
// ---- ToolGearIsNotAimedByLayout ----
2026-10-15 00:17:21 +02:00
2026-10-15 04:42:38 +02:00
// Tool gear sits on no mount, so the layout offers nothing of it to aim at; an act reveals it, and then it is an item aim.
2026-10-15 00:17:21 +02:00
[Fact]
2026-10-15 04:42:38 +02:00
public void ToolGearIsNotAimedByLayout ( )
2026-10-15 00:17:21 +02:00
{
var bay = AddBay ( float3 ( 0 , 0 , 100 ) , tools : new [ ] { "Radiator" } ) ;
var observer = Add ( float3 ( 0 , 0 , 0 ) ) ;
observer . EntityInfoGathered [ bay ] = 1f ;
var radiator = Fitted ( bay , "Radiator" ) ;
Assert . True ( radiator . Hardpoint = = null , "fixture: a tool item sits on no mount" ) ;
Target ( observer , bay ) ;
Assert . False ( observer . TrySelectTargetItem ( radiator ) ) ;
2026-10-15 04:42:38 +02:00
Assert . DoesNotContain ( observer . AimCycle ( bay ) , a = > a . Item ! = null ) ;
2026-10-15 00:17:21 +02:00
observer . RecordReveal ( bay , radiator , SignatureChannel . LaunchFlash , radiator . Data ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( observer . TrySelectTargetItem ( radiator ) ) ;
Assert . Contains ( observer . AimCycle ( bay ) , a = > ReferenceEquals ( a . Item , radiator ) ) ;
2026-10-15 00:17:21 +02:00
}
// ---- AMountAimIsTheMountsCells ----
2026-10-15 04:42:38 +02:00
// A shooter that holds a valid solution on a Bay 30 ahead, with the reactor's mount selected as the aim.
2026-10-15 00:17:21 +02:00
private ( Ship shooter , Ship bay , EquippedItem reactor , Weapon weapon , EquippedItem gun ) Gunner ( )
{
var bay = AddBay ( float3 ( 0 , 0 , 30 ) ) ;
2026-10-15 01:39:37 +02:00
return GunnerOn ( bay , Fitted ( bay , "BayReactor" ) ) ;
}
2026-10-15 04:42:38 +02:00
// A shooter that holds a valid solution on `bay`, with the mount of `aimed` (fitted to it) selected as the aim.
2026-10-15 01:39:37 +02:00
private ( Ship shooter , Ship bay , EquippedItem reactor , Weapon weapon , EquippedItem gun ) GunnerOn ( Ship bay , EquippedItem aimed )
{
2026-10-15 00:17:21 +02:00
var shooter = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { "Eye" , "Gun" } ) ;
shooter . SetTarget ( bay ) ;
for ( var i = 0 ; i < 3 ; i + + ) Hold ( shooter , 1 , 1f , bay ) ;
Assert . True ( shooter . VisibleEntities . Contains ( bay ) , "fixture: the shooter tracks the ship" ) ;
var gun = Guns ( shooter ) . Single ( ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( shooter . TrySelectMount ( aimed . Hardpoint ) , "fixture: the mount is aimed at" ) ;
2026-10-15 01:39:37 +02:00
return ( shooter , bay , aimed , ( Weapon ) gun . Behaviors . Single ( b = > b is Weapon ) , gun ) ;
2026-10-15 00:17:21 +02:00
}
private PendingShot FireAt ( Weapon weapon , EquippedItem gun , Entity shooter )
{
var before = _zone . PendingShots . Count ;
FireControl . Fire ( weapon , gun , shooter ) ;
Assert . Equal ( before + 1 , _zone . PendingShots . Count ) ;
return _zone . PendingShots [ before ] ;
}
2026-10-15 00:31:57 +02:00
// The reactor is one cell inside its two by two mount. Aimed at while located, a shot centres on the mount's cells (3.5, 3.5);
// once the reactor is revealed the next shot centres on the reactor's own cell (3, 3). The HUD's forecast reads the same
2026-10-15 00:17:21 +02:00
// aim point as the shot it forecasts.
[Fact]
public void AMountAimIsTheMountsCells ( )
{
var ( shooter , bay , reactor , weapon , gun ) = Gunner ( ) ;
var located = FireControl . Inspect ( weapon , shooter , bay ) ;
var shot = FireAt ( weapon , gun , shooter ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( ReferenceEquals ( reactor . Hardpoint , shot . AimedMount ) & & shot . Aimed = = null , "the shot is aimed at the located reactor's mount, not the item" ) ;
2026-10-15 00:17:21 +02:00
Assert . True ( FireControl . CommitProbability ( shot , _zone . Time , out var mountSil ) > 0f , "fixture: the shot has a price" ) ;
2026-10-15 00:31:57 +02:00
Assert . Equal ( 3.5f , mountSil . AimPoint . x , 4 ) ;
Assert . Equal ( 3.5f , mountSil . AimPoint . y , 4 ) ;
2026-10-15 00:17:21 +02:00
Assert . Equal ( mountSil . POnHull , located . POnHull , 4 ) ;
shooter . RecordReveal ( bay , reactor , SignatureChannel . LaunchFlash , reactor . Data ) ;
Assert . True ( FireControl . IsRevealed ( shooter , reactor ) , "fixture: the reactor is revealed" ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( shooter . TrySelectTargetItem ( reactor ) , "fixture: the revealed reactor is aimed at as an item" ) ;
2026-10-15 00:17:21 +02:00
var revealed = FireControl . Inspect ( weapon , shooter , bay ) ;
var next = FireAt ( weapon , gun , shooter ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( next . AimedMount = = null & & ReferenceEquals ( reactor , next . Aimed ) , "the next shot is aimed at the item" ) ;
2026-10-15 00:17:21 +02:00
Assert . True ( FireControl . CommitProbability ( next , _zone . Time , out var itemSil ) > 0f , "fixture: the shot has a price" ) ;
2026-10-15 00:31:57 +02:00
Assert . Equal ( 3f , itemSil . AimPoint . x , 4 ) ;
Assert . Equal ( 3f , itemSil . AimPoint . y , 4 ) ;
2026-10-15 00:17:21 +02:00
Assert . Equal ( itemSil . POnHull , revealed . POnHull , 4 ) ;
Assert . True ( Math . Abs ( mountSil . POnHull - itemSil . POnHull ) > 1e-3f , "fixture: the two aim points are priced apart" ) ;
}
2026-10-15 01:39:37 +02:00
// The mount is a rectangle off the diagonal: the second Sensors mount covers (2,3) and (3,3), centre (2.5, 3). A position
// read with its axes swapped would centre the shot on (3.5, 2).
[Fact]
public void AMountAimReadsThePositionAxesInOrder ( )
{
var ( wing , _ , gunB ) = AddWing ( float3 ( 0 , 0 , 30 ) ) ;
var ( shooter , _ , _ , weapon , gun ) = GunnerOn ( wing , gunB ) ;
var shot = FireAt ( weapon , gun , shooter ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( ReferenceEquals ( gunB . Hardpoint , shot . AimedMount ) , "fixture: the shot is aimed at the located gun's mount" ) ;
2026-10-15 01:39:37 +02:00
Assert . True ( FireControl . CommitProbability ( shot , _zone . Time , out var sil ) > 0f , "fixture: the shot has a price" ) ;
Assert . Equal ( 2.5f , sil . AimPoint . x , 4 ) ;
Assert . Equal ( 3f , sil . AimPoint . y , 4 ) ;
}
2026-10-15 07:14:35 +02:00
// ---- AMountAimThatLostItsGateDropsOutOfForecastAndFire ----
// The aim slot holds the mount, but what prices and freezes a shot is the aim as it resolves now: a mount the observer has
// since seen destroyed, or whose hull it no longer identifies, is no aim. The forecast prices the unaimed hull and the
// shot is fired aimed at nothing, though nothing wrote the slot at the moment the gate closed.
[Theory]
[InlineData("destroyed")]
[InlineData("unidentified")]
public void AMountAimThatLostItsGateDropsOutOfForecastAndFire ( string how )
{
var ( shooter , bay , reactor , weapon , gun ) = Gunner ( ) ;
var aimedPrice = FireControl . Inspect ( weapon , shooter , bay ) . POnHull ;
shooter . TrySelectTargetItem ( null ) ;
var unaimedPrice = FireControl . Inspect ( weapon , shooter , bay ) . POnHull ;
Assert . True ( Math . Abs ( aimedPrice - unaimedPrice ) > 1e-3f , "fixture: the mount aim and no aim are priced apart" ) ;
Assert . True ( shooter . TrySelectMount ( reactor . Hardpoint ) , "fixture: the mount is aimed at again" ) ;
if ( how = = "destroyed" )
{
reactor . EquippableItem . Durability = 0f ;
SenseOf ( shooter ) ( ) ;
}
else
{
shooter . EntityInfoGathered [ bay ] = Identify - . 01f ;
}
Assert . True ( shooter . ResolvedAim . Mount = = null , "fixture: the aim no longer resolves" ) ;
if ( how = = "destroyed" )
Assert . Equal ( unaimedPrice , FireControl . Inspect ( weapon , shooter , bay ) . POnHull , 4 ) ;
var shot = FireAt ( weapon , gun , shooter ) ;
Assert . True ( shot . AimedMount = = null & & shot . Aimed = = null , "the shot is aimed at nothing" ) ;
}
2026-10-15 00:17:21 +02:00
// ---- TheShotKeepsTheAimItWasFiredWith ----
[Fact]
public void TheShotKeepsTheAimItWasFiredWith ( )
{
var ( shooter , bay , reactor , weapon , gun ) = Gunner ( ) ;
var shot = FireAt ( weapon , gun , shooter ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( ReferenceEquals ( reactor . Hardpoint , shot . AimedMount ) , "fixture: fired at the mount" ) ;
2026-10-15 00:17:21 +02:00
shooter . RecordReveal ( bay , reactor , SignatureChannel . LaunchFlash , reactor . Data ) ;
Assert . True ( FireControl . IsRevealed ( shooter , reactor ) , "fixture: revealed before Commit" ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( shooter . TrySelectTargetItem ( reactor ) , "fixture: and aimed at as an item, which does not move a shot already fired" ) ;
2026-10-15 00:17:21 +02:00
Assert . True ( FireControl . CommitProbability ( shot , _zone . Time , out var sil ) > 0f , "fixture: the shot has a price" ) ;
2026-10-15 00:31:57 +02:00
Assert . Equal ( 3.5f , sil . AimPoint . x , 4 ) ;
Assert . Equal ( 3.5f , sil . AimPoint . y , 4 ) ;
2026-10-15 00:17:21 +02:00
}
// ---- TheSchematicListsUnfilledMounts ----
// Identified, nothing revealed: a mount row for each hardpoint, naming no item and no class and giving no range. After a
2026-10-15 04:42:38 +02:00
// scan the reactor's gear row leads and every mount keeps its row, the reactor's included (rows never compare the true
// items to the believed layout). The aimed mount is marked on its row alone.
2026-10-15 00:17:21 +02:00
[Fact]
public void TheSchematicListsUnfilledMounts ( )
{
2026-10-15 00:31:57 +02:00
var ( bay , observer , reactor ) = Identified ( Identify - . 01f ) ;
2026-10-15 00:17:21 +02:00
var hardpoints = bay . HullData . Hardpoints ;
2026-10-15 00:31:57 +02:00
Assert . Empty ( observer . SchematicRows ( bay ) ) ;
2026-10-15 00:17:21 +02:00
2026-10-15 00:31:57 +02:00
observer . EntityInfoGathered [ bay ] = Identify ;
2026-10-15 00:17:21 +02:00
var rows = observer . SchematicRows ( bay ) . ToArray ( ) ;
Assert . Equal ( 3 , rows . Length ) ;
2026-10-15 00:31:57 +02:00
Assert . Equal ( 3 , rows . Select ( r = > r . Key ) . Distinct ( ) . Count ( ) ) ;
2026-10-15 00:17:21 +02:00
Assert . True ( hardpoints . SequenceEqual ( rows . Select ( r = > r . Mount ) ) , "one mount row per hardpoint, in order" ) ;
Assert . All ( rows , r = >
{
Assert . True ( r . Item = = null & & r . Claimed = = null , "a mount row names no item and no class" ) ;
Assert . False ( r . Range . HasValue ) ;
} ) ;
Target ( observer , bay ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( observer . TrySelectMount ( reactor . Hardpoint ) ) ;
Assert . Equal ( new [ ] { true , false , false } , rows . Select ( r = > r . Marks ( observer . ResolvedAim ) ) . ToArray ( ) ) ;
2026-10-15 00:17:21 +02:00
observer . RecordInspection ( bay ) ;
rows = observer . SchematicRows ( bay ) . ToArray ( ) ;
2026-10-15 04:42:38 +02:00
Assert . Equal ( 4 , rows . Length ) ;
Assert . Equal ( 4 , rows . Select ( r = > r . Key ) . Distinct ( ) . Count ( ) ) ;
2026-10-15 00:17:21 +02:00
Assert . True ( rows [ 0 ] . Mount = = null & & ReferenceEquals ( rows [ 0 ] . Item , reactor ) , "the reactor's gear row leads" ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( hardpoints . SequenceEqual ( rows . Skip ( 1 ) . Select ( r = > r . Mount ) ) , "every mount keeps its mount row, the reactor's too" ) ;
Assert . Equal ( new [ ] { false , true , false , false } , rows . Select ( r = > r . Marks ( observer . ResolvedAim ) ) . ToArray ( ) ) ;
Assert . True ( observer . TrySelectTargetItem ( reactor ) ) ;
Assert . Equal ( new [ ] { true , false , false , false } , rows . Select ( r = > r . Marks ( observer . ResolvedAim ) ) . ToArray ( ) ) ;
2026-10-15 00:17:21 +02:00
}
2026-10-15 01:39:37 +02:00
// Two mounts of one type: the rows tell them apart by mount, not by type. Aiming at one marks its row alone, and revealing
// the other gives way only that mount's row to its gear row, leaving this one a mount row.
[Fact]
public void TwoMountsOfOneTypeAreTwoRows ( )
{
var ( wing , gunA , gunB ) = AddWing ( float3 ( 0 , 0 , 100 ) ) ;
var observer = Add ( float3 ( 0 , 0 , 0 ) ) ;
observer . EntityInfoGathered [ wing ] = 1f ;
var mountA = gunA . Hardpoint ;
var mountB = gunB . Hardpoint ;
Target ( observer , wing ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( observer . TrySelectMount ( mountB ) ) ;
2026-10-15 01:39:37 +02:00
var rows = observer . SchematicRows ( wing ) . ToArray ( ) ;
Assert . Equal ( 2 , rows . Length ) ;
Assert . True ( ReferenceEquals ( mountA , rows [ 0 ] . Mount ) & & ReferenceEquals ( mountB , rows [ 1 ] . Mount ) , "one mount row per mount, in order" ) ;
2026-10-15 04:42:38 +02:00
Assert . Equal ( new [ ] { false , true } , rows . Select ( r = > r . Marks ( observer . ResolvedAim ) ) . ToArray ( ) ) ;
2026-10-15 01:39:37 +02:00
observer . RecordReveal ( wing , gunA , SignatureChannel . LaunchFlash , gunA . Data ) ;
rows = observer . SchematicRows ( wing ) . ToArray ( ) ;
2026-10-15 04:42:38 +02:00
Assert . Equal ( 3 , rows . Length ) ;
2026-10-15 01:39:37 +02:00
Assert . True ( ReferenceEquals ( rows [ 0 ] . Item , gunA ) & & rows [ 0 ] . Mount = = null , "the revealed gun's row leads" ) ;
2026-10-15 04:42:38 +02:00
Assert . True ( ReferenceEquals ( rows [ 1 ] . Mount , mountA ) & & ReferenceEquals ( rows [ 2 ] . Mount , mountB ) , "both mounts keep their mount rows" ) ;
Assert . Equal ( new [ ] { false , false , true } , rows . Select ( r = > r . Marks ( observer . ResolvedAim ) ) . ToArray ( ) ) ;
2026-10-15 01:39:37 +02:00
}
2026-10-15 00:17:21 +02:00
// ---- TheAIStillChoosesOnlyRevealedWeapons ----
// An AI ship that identifies a Bay with a gun on its weapon mount aims at no item: Combat chooses among the weapons it has
// seen, and the located gun is not one. Once the gun fires in sight, Combat selects it.
[Fact]
public void TheAIStillChoosesOnlyRevealedWeapons ( )
{
var bay = AddBay ( float3 ( 0 , 0 , 40 ) , gun : true ) ;
var gun = Guns ( bay ) . Single ( ) ;
var ai = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { "Eye" } ) ;
2026-10-16 20:18:54 +02:00
_zone . Agents . Add ( new Minion ( ai , FactionDoctrine . Default ( ai . ItemManager . GameplaySettings ) . Combatant ) ) ;
2026-10-15 00:17:21 +02:00
ai . SetTarget ( bay ) ;
Hold ( ai , 12 , 1f , bay ) ;
Assert . True ( ReferenceEquals ( bay , ai . Target . Value . Entity ) , "fixture: the AI keeps the ship as its target" ) ;
2026-10-15 04:42:38 +02:00
Assert . False ( FireControl . IsRevealed ( ai , gun ) , "fixture: the gun is not revealed" ) ;
2026-10-15 00:17:21 +02:00
Assert . True ( ai . ResolvedTargetItem = = null , "Combat aims at nothing it has not seen act" ) ;
Fire ( bay , gun ) ;
for ( var i = 0 ; i < 40 & & ai . ResolvedTargetItem = = null ; i + + ) Hold ( ai , 1 , 1f , bay ) ;
Assert . True ( ReferenceEquals ( gun , ai . ResolvedTargetItem ) , "after the gun fires Combat aims at it" ) ;
}
}