2026-10-14 02:07:17 +02:00
using System ;
using System.Collections.Generic ;
using System.Linq ;
using CultMath ;
2026-10-14 04:03:20 +02:00
using GameCult.Caching.MessagePack ;
using MessagePack ;
2026-10-14 02:07:17 +02:00
using Xunit ;
using static CultMath . math ;
using float2 = CultMath . float2 ;
using float3 = CultMath . float3 ;
// Inspection (rulings quiet-gear-identifiable-hull-layout-known, scan-held-beam, scan-is-an-act, reveal-ends-on-perception,
// sensor-stat-set): a scanner held on its ship's tracked target within Range for ScanTime reveals everything fitted to it,
// as claimed, and is itself an emission the target is warned of and a third party can hear. The fixtures are the
// catalog's: "Scanner" has Range 200 and ScanTime 2, "QuietScanner" the same with intercept factor 4 (heard at 50). A tick
// is 1/8 s, so a scan builds 1/16 a tick and finishes on the sixteenth with no float drift. The scanner's ship keeps
// its track on the target at .5 each tick (above the detection gate, below the one at which gear is resolved, so
// Sense neither loses the track nor perceives a destruction). Every fixture drives the production path, ticking inside
// Zone.Update.
public sealed partial class ContactRevealTests
{
private const float ScanStep = . 125f ;
private static ScannerData ScannerBehavior ( float range , float scanTime , float? interceptFactor = null ) = > new ScannerData
{
Range = Const ( range ) , ScanTime = Const ( scanTime ) ,
InterceptFactor = interceptFactor . HasValue ? Const ( interceptFactor . Value ) : null
} ;
private static Scanner ScannerOf ( Entity e ) = > e . Equipment . SelectMany ( i = > i . Behaviors ) . OfType < Scanner > ( ) . Single ( ) ;
private static EquippedItem ScannerItemOf ( Entity e ) = > e . Equipment . Single ( i = > i . Behaviors . Any ( b = > b is Scanner ) ) ;
2026-10-14 02:27:19 +02:00
// A scanner ship at the origin, with the named scanner and no sensor (an AI ship with a sensor pings once it loses its
// track, which would paint the target beside the scan), tracking and targeting a ship `distance`
2026-10-14 02:07:17 +02:00
// ahead that carries a reactor, a radiator and a suppressor, none of which ever acts.
2026-10-14 21:19:14 +02:00
private ( Ship scanner , Ship target ) ScanOn ( string scanner = "Scanner" , float distance = 100f , float info = . 5f , string [ ] targetMounts = null )
2026-10-14 02:07:17 +02:00
{
2026-10-14 21:19:14 +02:00
var target = Add ( float3 ( 0 , 0 , distance ) , targetMounts , tools : new [ ] { "Reactor" , "Radiator" , "SuppressorCold" } ) ;
2026-10-14 02:27:19 +02:00
var s = Add ( float3 ( 0 , 0 , 0 ) , tools : new [ ] { scanner } ) ;
2026-10-14 02:41:58 +02:00
s . EntityInfoGathered [ target ] = info ;
2026-10-14 02:07:17 +02:00
s . SetTarget ( target ) ;
2026-10-15 00:17:21 +02:00
Assert . True ( ReferenceEquals ( target , s . Target . Value . Entity ) , "fixture: the scanner's ship targets the ship it scans" ) ; // not Assert.Same: a failing Entity argument is formatted as a graph and overflows the test host
2026-10-14 02:07:17 +02:00
return ( s , target ) ;
}
// Ticks `ticks` steps, holding the observer's track on each of `tracked` at `info` before each.
private void Hold ( Entity observer , int ticks , float info , params Entity [ ] tracked )
{
for ( var i = 0 ; i < ticks ; i + + )
{
foreach ( var t in tracked ) observer . EntityInfoGathered [ t ] = info ;
Tick ( ScanStep ) ;
}
}
private void Hold ( Entity observer , int ticks , params Entity [ ] tracked ) = > Hold ( observer , ticks , . 5f , tracked ) ;
private static EquippedItem [ ] FittedBesidesTheHull ( Entity e ) = > e . Equipment . Where ( i = > i ! = e . EquippedHull ) . ToArray ( ) ;
// ---- AScanRevealsQuietGear ----
[Fact]
public void AScanRevealsQuietGear ( )
{
var ( s , target ) = ScanOn ( ) ;
var quiet = FittedBesidesTheHull ( target ) ;
Assert . Equal ( 3 , quiet . Length ) ;
Assert . Empty ( s . RevealsOf ( target ) ) ; // fixture: nothing acts
Hold ( s , 15 , target ) ;
Assert . Empty ( s . RevealsOf ( target ) ) ;
Assert . Equal ( 15f / 16f , ScannerOf ( s ) . Progress , 4 ) ;
Hold ( s , 1 , target ) ;
foreach ( var item in quiet )
{
var reveal = Assert . Single ( s . RevealsOf ( target ) , r = > r . Item = = item ) ;
Assert . Null ( reveal . Channel ) ;
AssertBearing ( 0f , 1f , reveal . Bearing ) ; // normalize((target - scanner).xz)
Assert . Equal ( _zone . Time , reveal . Time ) ;
}
Assert . Equal ( 0f , ScannerOf ( s ) . Progress ) ; // a finished scan starts over
}
// ---- AScanSkipsTheHull ----
[Fact]
public void AScanSkipsTheHull ( )
{
var ( s , target ) = ScanOn ( ) ;
Assert . Contains ( target . EquippedHull , target . Equipment ) ; // fixture: the hull is an item of Equipment
Hold ( s , 16 , target ) ;
Assert . Equal ( 3 , s . RevealsOf ( target ) . Count ( ) ) ; // fixture: the scan finished
Assert . DoesNotContain ( s . RevealsOf ( target ) , r = > r . Item = = target . EquippedHull ) ;
}
// ---- AScanSkipsDestroyedGear ----
[Fact]
public void AScanSkipsDestroyedGear ( )
{
var ( s , target ) = ScanOn ( ) ;
var items = FittedBesidesTheHull ( target ) ;
items [ 0 ] . EquippableItem . Durability = 0f ;
Assert . True ( items [ 0 ] . EquippableItem . IsDestroyed ) ;
Hold ( s , 16 , target ) ;
Assert . DoesNotContain ( s . RevealsOf ( target ) , r = > r . Item = = items [ 0 ] ) ;
Assert . Equal ( 2 , s . RevealsOf ( target ) . Count ( ) ) ;
}
// ---- AScanRecordsTheClaim ----
[Fact]
public void AScanRecordsTheClaim ( )
{
var ( s , target ) = ScanOn ( ) ;
var reactor = target . Equipment . Single ( i = > i . Behaviors . Any ( b = > b is Reactor ) ) ;
var radiator = DataOf ( "Radiator" ) ;
target . Claim ( reactor , radiator ) ;
Hold ( s , 16 , target ) ;
var reveal = Assert . Single ( s . RevealsOf ( target ) , r = > r . Item = = reactor ) ;
Assert . Same ( radiator , reveal . Claimed ) ;
Assert . NotSame ( reactor . Data , reveal . Claimed ) ;
}
// ---- AScanNeedsATrackAndRange ----
[Theory]
[InlineData(199f, true, true)]
[InlineData(201f, true, false)]
[InlineData(100f, false, false)]
public void AScanNeedsATrackAndRange ( float distance , bool tracked , bool builds )
{
var third = Add ( float3 ( 40 , 0 , 0 ) , new [ ] { "Eye" } ) ; // within 200 of the scanner: it hears an emission
var info = tracked ? . 5f : 0f ;
2026-10-14 02:41:58 +02:00
var ( s , target ) = ScanOn ( distance : distance , info : info ) ;
2026-10-14 02:07:17 +02:00
Assert . Equal ( tracked , s . VisibleEntities . Contains ( target ) ) ; // fixture
Hold ( s , 4 , info , target ) ;
Assert . Equal ( builds , ScannerOf ( s ) . Progress > 0f ) ;
Assert . Equal ( builds , target . IncomingPaints . Count > 0 ) ;
2026-10-14 02:41:58 +02:00
Assert . Equal ( builds , target . RevealsOf ( s ) . Any ( ) ) ;
Assert . Equal ( builds , third . RevealsOf ( s ) . Any ( ) ) ;
2026-10-14 02:07:17 +02:00
}
// ---- ABrokenScanDecays ----
// A scan at .5 broken for half of ScanTime (one second) falls to 0, a quarter in half of that: by range (Execute
// runs and does not hold) and by the item switched off (Execute does not run at all).
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ABrokenScanDecays ( bool byRange )
{
var ( s , target ) = ScanOn ( ) ;
Hold ( s , 8 , target ) ;
Assert . Equal ( . 5f , ScannerOf ( s ) . Progress , 4 ) ;
if ( byRange ) target . Position = float3 ( 0 , 0 , 400 ) ;
else ScannerItemOf ( s ) . Enabled . Value = false ;
Hold ( s , 4 , target ) ;
Assert . Equal ( . 25f , ScannerOf ( s ) . Progress , 4 ) ;
Hold ( s , 4 , target ) ;
Assert . Equal ( 0f , ScannerOf ( s ) . Progress , 4 ) ;
Hold ( s , 2 , target ) ;
Assert . Equal ( 0f , ScannerOf ( s ) . Progress , 4 ) ; // never below 0
}
2026-10-14 04:24:05 +02:00
// ---- ABrokenScanResumesFromWhatDecayed ----
// A scan at .5 loses a sixteenth for each tick it is broken, and a held tick after the gap builds on what is left (the
// held tick itself is not a decay tick): .5, two broken ticks .375, two held ticks .5.
[Fact]
public void ABrokenScanResumesFromWhatDecayed ( )
{
var ( s , target ) = ScanOn ( ) ;
Hold ( s , 8 , target ) ;
Assert . Equal ( . 5f , ScannerOf ( s ) . Progress , 4 ) ;
target . Position = float3 ( 0 , 0 , 400 ) ;
Hold ( s , 2 , target ) ;
Assert . Equal ( . 375f , ScannerOf ( s ) . Progress , 4 ) ;
target . Position = float3 ( 0 , 0 , 100 ) ;
Hold ( s , 2 , target ) ;
Assert . Equal ( . 5f , ScannerOf ( s ) . Progress , 4 ) ;
}
2026-10-14 02:07:17 +02:00
// ---- ATargetChangeResetsTheScan ----
[Fact]
public void ATargetChangeResetsTheScan ( )
{
var ( s , first ) = ScanOn ( ) ;
var second = Add ( float3 ( 100 , 0 , 0 ) ) ;
Hold ( s , 11 , first , second ) ;
Assert . Equal ( 11f / 16f , ScannerOf ( s ) . Progress , 4 ) ;
s . SetTarget ( second ) ;
Hold ( s , 1 , first , second ) ;
Assert . Equal ( 1f / 16f , ScannerOf ( s ) . Progress , 4 ) ; // reset, then one tick on the new target
Assert . Empty ( s . RevealsOf ( first ) ) ;
}
2026-10-15 00:17:21 +02:00
// ---- AScanReadsNothingOnAnotherTarget ----
// A scan built on one ship is that ship's: with the scanner switched off at 11/16 the player retargets, and the progress
// against the new target is 0 at once, not the old scan decaying (the HUD's outgoing indicator reads it).
[Fact]
public void AScanReadsNothingOnAnotherTarget ( )
{
var ( s , first ) = ScanOn ( ) ;
var second = Add ( float3 ( 100 , 0 , 0 ) ) ;
Hold ( s , 11 , first , second ) ;
Assert . Equal ( 11f / 16f , ScannerOf ( s ) . Progress , 4 ) ;
ScannerItemOf ( s ) . Enabled . Value = false ;
s . SetTarget ( second ) ;
Hold ( s , 1 , first , second ) ;
Assert . Equal ( 0f , ScannerOf ( s ) . Progress , 4 ) ;
}
2026-10-14 02:07:17 +02:00
// ---- TheScannedShipIsWarned ----
// A paint made in a tick is published the next one, so after four ticks the target holds the third tick's paint.
[Fact]
public void TheScannedShipIsWarned ( )
{
var ( s , target ) = ScanOn ( ) ;
Hold ( s , 4 , target ) ;
var paint = Assert . Single ( target . IncomingPaints ) ;
Assert . Equal ( PaintMode . Scan , paint . Mode ) ;
Assert . Equal ( 3f / 16f , paint . Strength , 4 ) ;
Assert . Same ( DataOf ( "Scanner" ) , paint . EmitterClass ) ;
AssertBearing ( 0f , - 1f , paint . Bearing ) ; // normalize((scanner - target).xz)
var warned = Assert . Single ( target . RevealsOf ( s ) ) ;
Assert . Same ( ScannerItemOf ( s ) , warned . Item ) ;
Assert . Equal ( SignatureChannel . Emission , warned . Channel ) ;
}
// ---- AThirdPartyHearsTheScanner ----
[Theory]
[InlineData("QuietScanner", 49f, true)]
[InlineData("QuietScanner", 51f, false)]
[InlineData("Scanner", 199f, true)]
[InlineData("Scanner", 201f, false)]
public void AThirdPartyHearsTheScanner ( string scanner , float distance , bool heard )
{
var ( s , target ) = ScanOn ( scanner ) ;
var third = Add ( float3 ( - distance , 0 , 0 ) , new [ ] { "Eye" } ) ;
Hold ( s , 3 , target ) ;
Assert . Empty ( third . IncomingPaints ) ; // fixture: only the target is painted
if ( ! heard )
{
Assert . Empty ( third . RevealsOf ( s ) ) ;
return ;
}
var reveal = Assert . Single ( third . RevealsOf ( s ) ) ;
Assert . Same ( ScannerItemOf ( s ) , reveal . Item ) ;
Assert . Equal ( SignatureChannel . Emission , reveal . Channel ) ;
AssertBearing ( 1f , 0f , reveal . Bearing ) ; // normalize((scanner - third).xz)
}
// ---- AFinishedScanMakesTheReactorSelectable ----
[Fact]
public void AFinishedScanMakesTheReactorSelectable ( )
{
var ( s , target ) = ScanOn ( ) ;
var reactor = target . Equipment . Single ( i = > i . Behaviors . Any ( b = > b is Reactor ) ) ;
Hold ( s , 15 , target ) ;
Assert . False ( FireControl . IsRevealed ( s , reactor ) ) ;
Assert . False ( s . TrySelectTargetItem ( reactor ) ) ;
Hold ( s , 1 , target ) ;
Assert . True ( FireControl . IsRevealed ( s , reactor ) ) ;
Assert . True ( s . TrySelectTargetItem ( reactor ) ) ;
}
// ---- AScanEndsNothing ----
// A gun revealed by firing and then destroyed, while the observer's track stays between the detection and gear
// thresholds (so Sense perceives no destruction): a scan neither ends its reveal nor refreshes it.
[Fact]
public void AScanEndsNothing ( )
{
var target = Add ( float3 ( 0 , 0 , 100 ) , new [ ] { "Gun" } ) ;
var s = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { "Eye" } , new [ ] { "Scanner" } ) ;
var gun = Guns ( target ) [ 0 ] ;
var firedAt = Fire ( target , gun ) ;
var before = Assert . Single ( s . RevealsOf ( target ) , r = > r . Item = = gun ) ;
Assert . Equal ( firedAt , before . Time ) ; // fixture: the gun is in the book
gun . EquippableItem . Durability = 0f ;
s . EntityInfoGathered [ target ] = . 5f ;
s . SetTarget ( target ) ;
Hold ( s , 16 , target ) ;
var after = Assert . Single ( s . RevealsOf ( target ) , r = > r . Item = = gun ) ;
Assert . Equal ( firedAt , after . Time ) ;
Assert . Equal ( SignatureChannel . LaunchFlash , after . Channel ) ;
Assert . Contains ( s . RevealsOf ( target ) , r = > r . Item ! = gun & & r . Channel = = null ) ; // fixture: the scan finished
}
2026-10-14 04:03:20 +02:00
// ---- AScanReadsNoHostility ----
// Ruling ew-hostility-from-belief-ruled: a scan is an act of perception and judges nothing, so it proceeds and records the
// claim whatever the stance between the two ships, hostile or friendly, honest or lying. The stance is set both ways
2026-10-14 21:19:14 +02:00
// through SetIff, which decides IsHostileTo, EntityHostility and VisibleEnemies alike. The target carries a sensor that
// tracks the scanner, so a gate on the target's belief about its scanner would stop the scan; the scan builds at the pace
// of one tick's step, shows nothing a tick before it is done, and a third party hears it, in every stance.
2026-10-14 04:03:20 +02:00
[Theory]
[InlineData(true, false)]
[InlineData(true, true)]
[InlineData(false, false)]
[InlineData(false, true)]
public void AScanReadsNoHostility ( bool hostile , bool lies )
{
2026-10-14 21:19:14 +02:00
var ( s , target ) = ScanOn ( targetMounts : new [ ] { "Eye" } ) ;
var third = Add ( float3 ( - 40 , 0 , 0 ) , new [ ] { "Eye" } ) ; // inside Range 200 of the scanner: it hears the emission
2026-10-14 04:03:20 +02:00
var reactor = target . Equipment . Single ( i = > i . Behaviors . Any ( b = > b is Reactor ) ) ;
var radiator = DataOf ( "Radiator" ) ;
if ( lies ) target . Claim ( reactor , radiator ) ;
s . SetIff ( target , hostile ) ;
target . SetIff ( s , hostile ) ;
2026-10-14 21:19:14 +02:00
target . EntityInfoGathered [ s ] = . 5f ;
2026-10-14 04:03:20 +02:00
Tick ( ScanStep ) ;
Assert . Equal ( hostile , s . IsHostileTo ( target ) ) ; // fixture
Assert . Equal ( hostile , s . VisibleEnemies . Contains ( target ) ) ; // fixture: the stance reached the readers a gate would use
2026-10-14 21:19:14 +02:00
Assert . Equal ( hostile , target . IsHostileTo ( s ) ) ; // fixture: the target's sensor tracks the scanner as an enemy or a friend
var built = ( int ) MathF . Round ( ScannerOf ( s ) . Progress * 16f ) ;
2026-10-14 04:03:20 +02:00
2026-10-14 21:19:14 +02:00
for ( var i = 0 ; i < 15 - built ; i + + )
{
target . EntityInfoGathered [ s ] = . 5f ;
Hold ( s , 1 , target ) ;
}
Assert . Equal ( 15f / 16f , ScannerOf ( s ) . Progress , 4 ) ; // the pace, a tick short of ScanTime
Assert . Empty ( s . RevealsOf ( target ) ) ;
Hold ( s , 1 , target ) ;
2026-10-14 04:03:20 +02:00
2026-10-14 21:19:14 +02:00
Assert . Equal ( 4 , s . RevealsOf ( target ) . Count ( ) ) ; // the reactor, radiator, suppressor and the target's own sensor
Assert . Equal ( SignatureChannel . Emission , Assert . Single ( third . RevealsOf ( s ) ) . Channel ) ;
2026-10-14 04:03:20 +02:00
var reveal = Assert . Single ( s . RevealsOf ( target ) , r = > r . Item = = reactor ) ;
Assert . Same ( lies ? radiator : reactor . Data , reveal . Claimed ) ;
Assert . NotEmpty ( target . IncomingPaints . Where ( p = > p . Mode = = PaintMode . Scan ) ) ;
}
// ---- AScanTakesTheSamePathForAnyPilot ----
// Ruling same-ship-same-inputs: a ship with an AI agent and one with none (the player's) scan the same target through
// the one Scanner and enter the same reveals in their books, claimed alike. Every reveal in a book comes from
// RecordReveal, whose callers are Sense, PublishInbox and RecordInspection: a second scan path for either pilot would
// leave the two books different (or one empty).
[Fact]
public void AScanTakesTheSamePathForAnyPilot ( )
{
var target = Add ( float3 ( 0 , 0 , 100 ) , tools : new [ ] { "Reactor" , "Radiator" , "SuppressorCold" } ) ;
var reactor = target . Equipment . Single ( i = > i . Behaviors . Any ( b = > b is Reactor ) ) ;
target . Claim ( reactor , DataOf ( "Radiator" ) ) ;
var player = Add ( float3 ( - 20 , 0 , 0 ) , tools : new [ ] { "Scanner" } ) ;
2026-10-14 04:23:13 +02:00
var ai = Add ( float3 ( 20 , 0 , 0 ) , tools : new [ ] { "Scanner" } ) ;
2026-10-16 20:18:54 +02:00
var agent = new Minion ( ai , FactionDoctrine . Default ( ai . ItemManager . GameplaySettings ) . Combatant ) ; // the AI pilot, ticked in Zone.Update beside the scan: it takes the combat state once it has a target
2026-10-14 21:19:14 +02:00
_zone . Agents . Add ( agent ) ;
2026-10-14 04:03:20 +02:00
Assert . Single ( _zone . Agents ) ; // fixture: only the second ship has an agent
foreach ( var scanner in new [ ] { player , ai } )
{
scanner . EntityInfoGathered [ target ] = . 5f ;
scanner . SetTarget ( target ) ;
}
2026-10-14 04:04:11 +02:00
void Step ( int ticks )
{
for ( var i = 0 ; i < ticks ; i + + )
{
player . EntityInfoGathered [ target ] = . 5f ;
ai . EntityInfoGathered [ target ] = . 5f ;
Tick ( ScanStep ) ;
}
}
Step ( 15 ) ;
2026-10-14 21:19:14 +02:00
Assert . IsType < CombatState > ( typeof ( Agent ) . GetField ( "_currentState" , System . Reflection . BindingFlags . NonPublic | System . Reflection . BindingFlags . Instance ) . GetValue ( agent ) ) ; // fixture: the AI is fighting while it scans
2026-10-14 04:04:11 +02:00
Assert . Empty ( player . RevealsOf ( target ) ) ; // neither pilot has a scan before ScanTime
Assert . Empty ( ai . RevealsOf ( target ) ) ;
Step ( 1 ) ;
2026-10-14 04:03:20 +02:00
Assert . Equal ( 3 , player . RevealsOf ( target ) . Count ( ) ) ;
Func < Entity , string [ ] > book = o = > o . RevealsOf ( target )
. Select ( r = > r . Item . Data . Name + "|" + r . Claimed . Name + "|" + ( r . Channel ? . ToString ( ) ? ? "scan" ) ) . OrderBy ( x = > x ) . ToArray ( ) ;
Assert . Equal ( book ( player ) , book ( ai ) ) ;
}
2026-10-14 21:19:14 +02:00
// ---- AFinishedScanListsTheGearOnTheSchematic ----
// Ruling visibility-per-capability-reveal: what a scan reveals shows on the enemy schematic, as claimed. The target carries
// a gun, two reactors (the gun's own and a fitted one), a radiator, a suppressor and a targeting system; the fitted reactor
// claims to be a radiator, and the hull's own flash is in the book too. A row is made for each scanned item and none for
// the hull: weapons first with their rated range, then the gear in fitted order, each as claimed and with no range.
[Fact]
public void AFinishedScanListsTheGearOnTheSchematic ( )
{
2026-10-14 21:23:47 +02:00
var target = Add ( float3 ( 30 , 0 , 40 ) , new [ ] { "Gun" } , new [ ] { "Reactor" , "Radiator" , "SuppressorCold" } ) ;
2026-10-14 21:19:14 +02:00
var s = Add ( float3 ( 0 , 0 , 0 ) , new [ ] { "Eye" } , new [ ] { "Scanner" } ) ;
var fitted = FittedBesidesTheHull ( target ) ;
var gun = Guns ( target ) [ 0 ] ;
var reactor = target . Equipment . Where ( i = > i . Behaviors . Any ( b = > b is Reactor ) ) . Last ( ) ;
target . Claim ( reactor , DataOf ( "Radiator" ) ) ;
target . SetSignature ( new object ( ) , SignatureChannel . LaunchFlash , 1000000f , target . EquippedHull ) ;
2026-10-14 21:23:47 +02:00
Tick ( . 1f ) ;
2026-10-14 21:19:14 +02:00
Assert . Equal ( target . EquippedHull , Assert . Single ( s . RevealsOf ( target ) ) . Item ) ; // fixture: the hull is in the book, as a hull class
Assert . IsAssignableFrom < HullData > ( s . RevealsOf ( target ) . Single ( ) . Claimed ) ;
2026-10-14 21:23:47 +02:00
s . EntityInfoGathered [ target ] = . 5f ;
s . SetTarget ( target ) ;
Hold ( s , 15 , target ) ;
2026-10-14 21:19:14 +02:00
Assert . Empty ( s . SchematicRows ( target ) ) ; // fixture: no row before the scan is done
Hold ( s , 1 , target ) ;
var rows = s . SchematicRows ( target ) . ToArray ( ) ;
Assert . Equal ( fitted . Length , rows . Length ) ; // every scanned item
Assert . DoesNotContain ( rows , r = > r . Item = = target . EquippedHull | | r . Claimed is HullData ) ;
Assert . Same ( gun , rows [ 0 ] . Item ) ; // weapons first
Assert . Equal ( ( ( WeaponData ) DataOf ( "Gun" ) . Behaviors . Single ( b = > b is WeaponData ) ) . RatedRange , rows [ 0 ] . Range ) ;
var gear = rows . Skip ( 1 ) . ToArray ( ) ;
Assert . Equal ( fitted . Where ( i = > i ! = gun ) . ToArray ( ) , gear . Select ( r = > r . Item ) . ToArray ( ) ) ; // fitted order
Assert . All ( gear , r = > Assert . Null ( r . Range ) ) ;
Assert . All ( gear , r = > Assert . Same ( r . Item = = reactor ? DataOf ( "Radiator" ) : r . Item . Data , r . Claimed ) ) ;
}
2026-10-14 04:03:20 +02:00
// ---- AConsumableScannerDecaysOnTheSameTick ----
2026-10-14 04:23:13 +02:00
// The two hosts order Execute and the always-updated pass differently (ConsumableItemEffect.Update runs the pass first),
// so the scan's decay must not depend on that order: a scan broken by range loses dt/ScanTime on the tick it breaks, and
// .5 falls to .25 in four ticks, as ABrokenScanDecays has it for gear.
2026-10-14 04:03:20 +02:00
[Fact]
public void AConsumableScannerDecaysOnTheSameTick ( )
{
var data = _openCache . GetByName < ConsumableItemData > ( "ScannerCharge" ) ;
var target = Add ( float3 ( 0 , 0 , 100 ) , tools : new [ ] { "Reactor" } ) ;
var s = Add ( float3 ( 0 , 0 , 0 ) ) ;
s . ActivateConsumable ( Assert . IsType < ConsumableItem > ( _items . CreateInstance ( _items . CreateLot ( data , _maker , . 5f ) ) ) ) ;
var scanner = s . FindActiveConsumable ( data ) . Behaviors . OfType < Scanner > ( ) . Single ( ) ;
s . EntityInfoGathered [ target ] = . 5f ;
s . SetTarget ( target ) ;
Hold ( s , 8 , target ) ;
Assert . Equal ( . 5f , scanner . Progress , 4 ) ; // fixture: the consumable scans like gear
target . Position = float3 ( 0 , 0 , 400 ) ;
Hold ( s , 4 , target ) ;
Assert . Equal ( . 25f , scanner . Progress , 4 ) ;
Hold ( s , 4 , target ) ;
Assert . Equal ( 0f , scanner . Progress , 4 ) ;
}
// ---- ScannerDataRoundTripsAsUnionKey51 ----
[Fact]
public void ScannerDataRoundTripsAsUnionKey51 ( )
{
var options = CultDocumentMessagePackSerialization . OptionsFor ( typeof ( ZonePack ) . Assembly ) ;
var data = new ScannerData { Range = Const ( 200f ) , ScanTime = Const ( 2f ) , InterceptFactor = Const ( 4f ) } ;
var bytes = MessagePackSerializer . Serialize < BehaviorData > ( data , options ) ;
var reader = new MessagePackReader ( bytes ) ;
Assert . Equal ( 2 , reader . ReadArrayHeader ( ) ) ;
Assert . Equal ( 51 , reader . ReadInt32 ( ) ) ; // sensor-catalog holds 50; a key reused across branches would collide on the wire
Assert . Equal ( 4 , reader . ReadArrayHeader ( ) ) ; // Group is key 0, then the three stats
reader . Skip ( ) ;
Assert . Equal ( 200f , MessagePackSerializer . Deserialize < PerformanceStat > ( ref reader , options ) . Min ) ; // key 1 Range
Assert . Equal ( 2f , MessagePackSerializer . Deserialize < PerformanceStat > ( ref reader , options ) . Min ) ; // key 2 ScanTime
Assert . Equal ( 4f , MessagePackSerializer . Deserialize < PerformanceStat > ( ref reader , options ) . Min ) ; // key 3 InterceptFactor
var back = Assert . IsType < ScannerData > ( MessagePackSerializer . Deserialize < BehaviorData > ( bytes , options ) ) ;
Assert . Equal ( 200f , back . Range . Min ) ;
Assert . Equal ( 2f , back . ScanTime . Min ) ;
Assert . Equal ( 4f , back . InterceptFactor . Min ) ;
var absent = Assert . IsType < ScannerData > ( MessagePackSerializer . Deserialize < BehaviorData > (
MessagePackSerializer . Serialize < BehaviorData > ( new ScannerData { Range = Const ( 1f ) , ScanTime = Const ( 1f ) } , options ) , options ) ) ;
Assert . Null ( absent . InterceptFactor ) ;
var unions = typeof ( BehaviorData ) . GetCustomAttributes ( typeof ( UnionAttribute ) , false ) . Cast < UnionAttribute > ( ) . ToArray ( ) ;
Assert . Single ( unions , u = > u . Key = = 51 ) ;
Assert . Equal ( unions . Length , unions . Select ( u = > u . Key ) . Distinct ( ) . Count ( ) ) ;
}
2026-10-14 02:07:17 +02:00
}