2026-10-07 01:13:08 +02:00
/ * This Source Code Form is subject to the terms of the Mozilla Public
* License , v . 2.0 . If a copy of the MPL was not distributed with this
* file , You can obtain one at https : //mozilla.org/MPL/2.0/. */
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
using System.Text.RegularExpressions ;
using CultMath ;
2026-10-18 21:18:49 +02:00
using GameCult.Caching ;
2026-10-18 21:19:14 +02:00
using GameCult.Caching.MessagePack ;
2026-10-07 01:13:08 +02:00
using UniRx ;
using Xunit ;
// The demo's goal (aetheria-release, cut boss-gate): the boss zone spawns its faction's boss, and the exit gate opens
2026-10-18 21:17:05 +02:00
// only when that boss is dead. The boss flies its faction's boss Loadout (cut boss-loadout). Every input is the production one: the demo galaxy, GenerateZone, the Zone death path,
2026-10-07 01:13:08 +02:00
// and the run save.
public sealed partial class RunStartTests
{
2026-10-18 21:17:05 +02:00
private ZonePack BossZonePack ( Galaxy galaxy , ItemManager items , GalaxyZone zone , bool ambient = true , uint seed = 7 )
2026-10-07 01:13:08 +02:00
{
2026-10-18 21:17:05 +02:00
items . Random = new CultMath . Random ( seed ) ;
2026-10-17 04:55:57 +02:00
return ZoneGenerator . GenerateZone ( items , _zoneSettings , _planetSettings , galaxy , zone , galaxy . IsPrelude , ambient ) ;
2026-10-07 01:13:08 +02:00
}
private Zone ZoneOf ( Galaxy galaxy , GalaxyZone zone ) = >
new Zone ( _items , _planetSettings , BossZonePack ( galaxy , _items , zone ) , zone , galaxy ) ;
private static Faction Zhestokost ( Galaxy galaxy ) = > galaxy . Factions . Single ( faction = > faction . Name = = "Zhestokost" ) ;
private ItemManager LoggingItems ( List < string > log ) = >
2026-10-21 19:01:41 +02:00
new ItemManager ( 1 u , _cache , new ProvenanceLedger ( ) , AuthoredSettings . Load ( FindRepoRoot ( ) ) . Read < GameplaySettings > ( "GameplaySettings" ) , log . Add ) ;
2026-10-07 01:13:08 +02:00
private static string HullName ( ItemManager items , EntityPack pack ) = > items . GetData ( pack . Hull ) . Name ;
private HullData [ ] SoldShipHulls ( ) = > _cache . GetAll < FactionProductData > ( )
. Where ( product = > product . Manufacturer . IsSet ( ) )
. Select ( product = > _cache . Get ( product . Design ) as HullData )
. Where ( hull = > hull ! = null & & hull . HullType = = HullType . Ship & & hull . Price > 0 )
. Distinct ( ) . ToArray ( ) ;
2026-10-18 21:17:05 +02:00
// A hull no product makes, so the market never offers it: a MessagePack copy of the first sold ship hull by name, renamed. It has
// no authored model, which a boss does not need. Stored once; asking again returns the same record.
private HullData BossTestHull ( )
{
var existing = _cache . GetAll < HullData > ( ) . FirstOrDefault ( hull = > hull . Name = = "Boss Test Hull" ) ;
if ( existing ! = null ) return existing ;
var options = CultDocumentMessagePackSerialization . OptionsFor ( typeof ( HullData ) . Assembly ) ;
var like = SoldShipHulls ( ) . OrderBy ( hull = > hull . Name , StringComparer . Ordinal ) . First ( ) ;
var boss = MessagePack . MessagePackSerializer . Deserialize < HullData > ( MessagePack . MessagePackSerializer . Serialize ( like , options ) , options ) ;
boss . Name = "Boss Test Hull" ;
boss . Prefab = null ;
boss . Visual = default ;
return _cache . Get ( _cache . Upsert ( boss ) ) ;
}
// A Loadout record at Loadouts.KeyOf(Name), the key Loadouts.Commit writes it under.
private void StoreLoadout ( Loadout loadout ) = >
Assert . True ( _cache . Commit ( batch = > batch . Upsert ( typeof ( Loadout ) , loadout , Loadouts . KeyOf ( loadout . Name ) ) ) ) ;
// A boss's fixed Loadout (ruling boss-gear-is-a-loadout), by the production capture: a seed-11 generated fit of the first sold
// ship hull by name, captured (Loadouts.Capture), cut down to its weapons so it is smaller than any generated fit (the tractor
// pickup and the rest are not in it), moved onto the given hull with no hull product, and stored under its name.
private Loadout MakeBossLoadout ( HullData hull , string name )
{
var like = SoldShipHulls ( ) . OrderBy ( h = > h . Name , StringComparer . Ordinal ) . First ( ) ;
var random = new CultMath . Random ( 11 ) ;
var generator = new LoadoutGenerator ( ref random , _items , null , null , null , . 5f ) ;
var ship = ( Ship ) EntitySerializer . Unpack ( _items , null , generator . GenerateShipLoadout ( h = > h = = like ) ) ;
var fit = Loadouts . Capture ( _items , ship , name ) ;
var armed = fit . Slots . Select ( ( slot , index ) = > ( slot , index ) )
. Where ( entry = > _cache . Get ( entry . slot . Design ) . Behaviors . Any ( behavior = > behavior is WeaponData ) ) . ToArray ( ) ;
Assert . NotEmpty ( armed ) ;
Assert . True ( armed . Length < fit . Slots . Count , "the generated fit needs gear besides weapons for this fixture to tell a Loadout from a fit" ) ;
var renumbered = armed . Select ( ( entry , number ) = > ( entry . index , number ) ) . ToDictionary ( entry = > entry . index , entry = > entry . number ) ;
fit . WeaponGroups = fit . WeaponGroups . Select ( group = > group . Where ( renumbered . ContainsKey ) . Select ( index = > renumbered [ index ] ) . ToArray ( ) ) . ToArray ( ) ;
fit . Slots = armed . Select ( entry = > entry . slot ) . ToList ( ) ;
fit . Hull = _cache . RefOf ( hull ) ;
fit . HullProduct = default ;
StoreLoadout ( fit ) ;
return fit ;
}
private static void SetBoss ( Faction faction , Loadout loadout ) = > faction . BossLoadout = new CultRecordRef < Loadout > ( Loadouts . KeyOf ( loadout . Name ) ) ;
// The demo's antagonist gets a boss Loadout on a hull nothing sells: the shipped catalog authors none.
private Loadout GiveAntagonistABoss ( Galaxy galaxy )
{
var boss = MakeBossLoadout ( BossTestHull ( ) , "Antagonist Boss" ) ;
SetBoss ( Zhestokost ( galaxy ) , boss ) ;
return boss ;
}
// What a ship carries, cell by cell, as a Loadout would name it: position, rotation, design, product.
private static string [ ] Cells ( ItemManager items , EntityPack pack )
{
var captured = Loadouts . Capture ( items , ( Ship ) EntitySerializer . Unpack ( items , null , pack ) , "probe" ) ;
return captured . Slots . OrderBy ( slot = > slot . Position . x ) . ThenBy ( slot = > slot . Position . y )
. Select ( slot = > $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{items.ItemData.Get(slot.Design).Name},{(slot.Product.IsSet() ? items.ItemData.Get(slot.Product).Name : " - ")}" ) . ToArray ( ) ;
}
2026-10-18 21:18:20 +02:00
private string [ ] LoadoutCells ( Loadout loadout ) = >
2026-10-18 21:17:05 +02:00
loadout . Slots . OrderBy ( slot = > slot . Position . x ) . ThenBy ( slot = > slot . Position . y )
2026-10-18 21:18:20 +02:00
. Select ( slot = > $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{_cache.Get(slot.Design).Name},{(slot.Product.IsSet() ? _cache.Get(slot.Product).Name : " - ")}" ) . ToArray ( ) ;
2026-10-18 21:17:05 +02:00
private LoadoutGenerator GeneratorFor ( Galaxy galaxy , ItemManager items , Faction faction , uint seed )
{
var random = new CultMath . Random ( seed ) ;
return new LoadoutGenerator ( ref random , items , galaxy , galaxy . Exit , faction , . 5f ) ;
}
// What the pack holds, in order, by hull and faction: two zone packs that drew the same stream hold the same list.
private static string [ ] Roster ( ItemManager items , ZonePack pack ) = >
pack . Entities . Select ( entity = > $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : " - ")}" ) . ToArray ( ) ;
2026-10-07 01:13:08 +02:00
[Fact]
public void Boss_spawns_once_in_boss_zone ( )
{
foreach ( var seed in DemoSeeds . Take ( 3 ) )
{
var galaxy = DemoGalaxy ( seed ) ;
2026-10-18 21:17:05 +02:00
GiveAntagonistABoss ( galaxy ) ;
2026-10-07 01:13:08 +02:00
var antagonist = Zhestokost ( galaxy ) ;
foreach ( var zone in galaxy . Zones )
{
var bosses = BossZonePack ( galaxy , _items , zone ) . Entities . Where ( entity = > entity . Boss ) . ToArray ( ) ;
if ( zone ! = galaxy . Exit ) { Assert . Empty ( bosses ) ; continue ; }
var boss = Assert . Single ( bosses ) ;
Assert . IsType < ShipPack > ( boss ) ;
Assert . Same ( antagonist , _cache . Get ( boss . Faction ) ) ;
2026-10-18 21:17:05 +02:00
Assert . Equal ( "Boss Test Hull" , HullName ( _items , boss ) ) ;
2026-10-07 01:13:08 +02:00
// Generation is repeatable per zone: the same zone yields the same boss hull.
var again = Assert . Single ( BossZonePack ( galaxy , _items , zone ) . Entities , entity = > entity . Boss ) ;
Assert . Equal ( HullName ( _items , boss ) , HullName ( _items , again ) ) ;
}
}
}
// A zone that is not ambient still has its boss: the boss is the goal, not traffic.
[Fact]
public void Boss_spawns_in_a_zone_that_is_not_ambient ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
2026-10-18 21:17:05 +02:00
GiveAntagonistABoss ( galaxy ) ;
2026-10-07 01:13:08 +02:00
var pack = BossZonePack ( galaxy , _items , galaxy . Exit , ambient : false ) ;
Assert . Single ( pack . Entities , entity = > entity . Boss ) ;
Assert . DoesNotContain ( pack . Entities , entity = > ! entity . Boss & & entity is ShipPack ) ;
}
2026-10-18 21:17:05 +02:00
// Faction.BossLoadout owns the boss. A decoy Loadout sorts before it by name and by key, so a resolver that takes the first
// Loadout in the cache, or any Loadout but the referenced one, flies the wrong hull.
[Fact]
public void Boss_flies_its_factions_boss_loadout ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
var antagonist = Zhestokost ( galaxy ) ;
var sold = SoldShipHulls ( ) . OrderBy ( hull = > hull . Name , StringComparer . Ordinal ) . First ( ) ;
StoreLoadout ( new Loadout { Name = "Aaa Decoy" , Hull = _cache . RefOf ( sold ) } ) ;
var loadout = GiveAntagonistABoss ( galaxy ) ;
Assert . Same ( _cache . Get ( antagonist . BossLoadout ) , Loadouts . BossOf ( _cache , antagonist , out var refusal ) ) ;
Assert . Equal ( BossRefusal . None , refusal ) ;
Assert . Equal ( "Antagonist Boss" , loadout . Name ) ;
var boss = Assert . Single ( BossZonePack ( galaxy , _items , galaxy . Exit ) . Entities , entity = > entity . Boss ) ;
Assert . IsType < ShipPack > ( boss ) ;
Assert . Same ( antagonist , _cache . Get ( boss . Faction ) ) ;
Assert . Equal ( "Boss Test Hull" , HullName ( _items , boss ) ) ;
var again = Assert . Single ( BossZonePack ( galaxy , _items , galaxy . Exit ) . Entities , entity = > entity . Boss ) ;
Assert . Equal ( Cells ( _items , boss ) , Cells ( _items , again ) ) ;
}
// Ruling boss-gear-is-a-loadout: the boss carries exactly its Loadout, nothing drawn. The Loadout is weapons only, so any
// pickup, extra gear, cargo or consumable on the boss is a generator pass that should not run.
[Fact]
public void Boss_carries_exactly_its_loadouts_cells ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
var loadout = GiveAntagonistABoss ( galaxy ) ;
Assert . True ( loadout . Slots . Count > 1 ) ;
Assert . All ( loadout . Slots , slot = > Assert . True ( slot . Product . IsSet ( ) , "a captured fit pins every product" ) ) ;
foreach ( var seed in new uint [ ] { 7 , 8 } )
{
var items = LoggingItems ( new List < string > ( ) ) ;
var boss = Assert . Single ( BossZonePack ( galaxy , items , galaxy . Exit , seed : seed ) . Entities , entity = > entity . Boss ) ;
2026-10-18 21:18:20 +02:00
Assert . Equal ( LoadoutCells ( loadout ) , Cells ( items , boss ) ) ;
2026-10-18 21:17:05 +02:00
Assert . All ( boss . Equipment , entry = > Assert . DoesNotContain ( items . GetData ( entry . item ) . Behaviors , behavior = > behavior is PickupData ) ) ;
Assert . Empty ( boss . CargoContents . SelectMany ( bay = > bay ) ) ;
Assert . Empty ( boss . DockingBayContents . SelectMany ( bay = > bay ) ) ;
var captured = Loadouts . Capture ( items , ( Ship ) EntitySerializer . Unpack ( items , null , boss ) , "probe" ) ;
string [ ] [ ] Groups ( Loadout l ) = > l . WeaponGroups . Select ( group = > group . Select ( i = > $"{l.Slots[i].Position.x},{l.Slots[i].Position.y}" ) . OrderBy ( x = > x , StringComparer . Ordinal ) . ToArray ( ) ) . Where ( group = > group . Length > 0 ) . ToArray ( ) ;
Assert . Equal ( Groups ( loadout ) , Groups ( captured ) ) ;
}
}
// Ruling loadout-product-optional-context-chooses: a design-only slot takes the brand the boss's faction can get. The design has
// an outsider's product that sorts first by name, so a chooser that takes the first product by name builds the wrong brand.
[Fact]
public void A_design_only_boss_slot_takes_a_brand_its_faction_can_get ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
var antagonist = Zhestokost ( galaxy ) ;
var loadout = GiveAntagonistABoss ( galaxy ) ;
var outfitter = GeneratorFor ( galaxy , _items , antagonist , 1 ) ;
var open = loadout . Slots . First ( slot = > _cache . GetAll < FactionProductData > ( ) . Any ( product = > product . Design . Key . Equals ( slot . Design . Key ) & & outfitter . IsAvailable ( product ) ) ) ;
var template = _cache . GetAll < FactionProductData > ( ) . First ( product = > product . Design . Key . Equals ( open . Design . Key ) ) ;
open . Product = default ;
StoreLoadout ( loadout ) ;
var options = CultDocumentMessagePackSerialization . OptionsFor ( typeof ( HullData ) . Assembly ) ;
var outsider = MessagePack . MessagePackSerializer . Deserialize < FactionProductData > ( MessagePack . MessagePackSerializer . Serialize ( template , options ) , options ) ;
outsider . Name = "Aaa Outsider" ;
foreach ( var faction in _cache . GetAll < Faction > ( ) . OrderBy ( faction = > faction . Name , StringComparer . Ordinal ) )
{
outsider . Manufacturer = _cache . RefOf ( faction ) ;
if ( ! outfitter . IsAvailable ( outsider ) ) break ;
}
Assert . False ( outfitter . IsAvailable ( outsider ) , "the outsider's maker is neither Zhestokost nor in its allegiance" ) ;
_cache . Upsert ( outsider ) ;
var gettable = _cache . GetAll < FactionProductData > ( ) . Where ( product = > product . Design . Key . Equals ( open . Design . Key ) & & outfitter . IsAvailable ( product ) ) . Select ( product = > product . Name ) . ToArray ( ) ;
Assert . NotEmpty ( gettable ) ;
foreach ( var seed in new uint [ ] { 1 , 2 , 3 , 4 , 5 } )
{
var items = LoggingItems ( new List < string > ( ) ) ;
var boss = Assert . Single ( BossZonePack ( galaxy , items , galaxy . Exit , seed : seed ) . Entities , entity = > entity . Boss ) ;
var cell = Assert . Single ( Cells ( items , boss ) , c = > c . StartsWith ( $"{open.Position.x},{open.Position.y}," ) ) ;
var brand = cell . Split ( ',' ) . Last ( ) ;
Assert . NotEqual ( "Aaa Outsider" , brand ) ;
Assert . Contains ( brand , gettable ) ;
}
}
// No generated fallback (rulings boss-gear-is-a-loadout, demo-boss-hull-invented): the shipped catalog authors no boss Loadout,
// so Zhestokost has no boss. The refusal happens before anything is drawn.
[Fact]
public void Boss_without_a_boss_loadout_is_refused ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
var antagonist = Zhestokost ( galaxy ) ;
Assert . Null ( Loadouts . BossOf ( _cache , antagonist , out var refusal ) ) ;
Assert . Equal ( BossRefusal . Unset , refusal ) ;
var log = new List < string > ( ) ;
var items = LoggingItems ( log ) ;
var pack = BossZonePack ( galaxy , items , galaxy . Exit ) ;
Assert . False ( pack . BossSpawned ) ;
Assert . DoesNotContain ( pack . Entities , entity = > entity . Boss ) ;
Assert . Contains ( log , line = > line . Contains ( "Zhestokost has no boss" ) & & line . Contains ( "sets no Faction.BossLoadout" ) ) ;
Assert . Contains ( log , line = > line . Contains ( "Galaxy.Exit" ) & & line . Contains ( "exit-without-boss" ) ) ;
Assert . False ( RunGoal . ExitOpen ( new Zone ( items , _planetSettings , pack , galaxy . Exit , galaxy ) ) ) ;
// Nothing was drawn for the refused boss: the zone is the zone with no boss mapped at all.
galaxy . BossZones . Clear ( ) ;
var bare = BossZonePack ( galaxy , LoggingItems ( new List < string > ( ) ) , galaxy . Exit ) ;
Assert . NotEmpty ( bare . Entities ) ;
Assert . Equal ( Roster ( items , bare ) , Roster ( items , pack ) ) ;
}
// A reference to a Loadout the catalog does not hold is refused like no reference, and the log names the field, not the value.
[Fact]
public void A_missing_boss_loadout_is_refused ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
var antagonist = Zhestokost ( galaxy ) ;
antagonist . BossLoadout = new CultRecordRef < Loadout > ( Loadouts . KeyOf ( "No Such Loadout" ) ) ;
Assert . Null ( Loadouts . BossOf ( _cache , antagonist , out var refusal ) ) ;
Assert . Equal ( BossRefusal . Missing , refusal ) ;
var log = new List < string > ( ) ;
var items = LoggingItems ( log ) ;
var pack = BossZonePack ( galaxy , items , galaxy . Exit ) ;
Assert . False ( pack . BossSpawned ) ;
Assert . DoesNotContain ( pack . Entities , entity = > entity . Boss ) ;
Assert . Contains ( log , line = > line . Contains ( "Zhestokost has no boss" ) & & line . Contains ( "names a Faction.BossLoadout that is not in the catalog" ) ) ;
Assert . DoesNotContain ( log , line = > line . Contains ( "No Such Loadout" ) | | line . Contains ( "sets no" ) ) ;
Assert . DoesNotContain ( log , line = > line . Contains ( "Zhestokost has no boss" ) & & line . Contains ( "No Such" ) ) ;
}
// A boss Loadout whose slots do not fit gives no boss: the log names the Loadout and every fit failure, and the gate stays sealed.
[Fact]
public void An_unbuildable_boss_loadout_spawns_no_boss ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
var loadout = GiveAntagonistABoss ( galaxy ) ;
loadout . Slots [ 0 ] . Position = new int2 ( 999 , 999 ) ;
loadout . Slots [ 1 ] . Position = new int2 ( 998 , 998 ) ;
StoreLoadout ( loadout ) ;
var log = new List < string > ( ) ;
var items = LoggingItems ( log ) ;
var pack = BossZonePack ( galaxy , items , galaxy . Exit , ambient : false ) ;
Assert . False ( pack . BossSpawned ) ;
Assert . DoesNotContain ( pack . Entities , entity = > entity . Boss ) ;
var refusal = Assert . Single ( log , line = > line . Contains ( "Antagonist Boss" ) & & line . Contains ( "does not fit" ) ) ;
Assert . Equal ( 2 , Regex . Matches ( refusal , "does not fit" ) . Count ) ;
Assert . Contains ( "; " , refusal ) ;
Assert . False ( RunGoal . ExitOpen ( new Zone ( items , _planetSettings , pack , galaxy . Exit , galaxy ) ) ) ;
}
// The existing market rule (ruling off-market-is-general): an item is on offer iff a product of it is. A hull no product makes
// is never drawn, stocked or chosen, yet the boss whose Loadout names it flies it.
[Fact]
public void A_hull_no_product_makes_is_never_on_offer ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
var hull = BossTestHull ( ) ;
GiveAntagonistABoss ( galaxy ) ;
Assert . DoesNotContain ( hull , LoadoutGenerator . SoldHulls ( _cache ) ) ;
var items = LoggingItems ( new List < string > ( ) ) ;
for ( uint seed = 1 ; seed < = 20 ; seed + + )
{
var generator = GeneratorFor ( galaxy , items , Zhestokost ( galaxy ) , seed ) ;
var ship = generator . GenerateShipLoadout ( ) ;
Assert . NotNull ( ship ) ;
Assert . NotEqual ( hull . Name , HullName ( items , ship ) ) ;
var station = generator . GenerateStationLoadout ( ) ;
Assert . DoesNotContain ( EntitySerializer . Items ( station ) , item = > items . GetData ( item ) . Name = = hull . Name ) ;
Assert . Null ( generator . ChooseProduct ( hull ) ) ;
Assert . Null ( generator . GenerateShipLoadout ( candidate = > candidate = = hull ) ) ;
}
var boss = Assert . Single ( BossZonePack ( galaxy , items , galaxy . Exit ) . Entities , entity = > entity . Boss ) ;
Assert . Equal ( hull . Name , HullName ( items , boss ) ) ;
}
// PlaceFactionsMain reads BossOf: with no reference set there is no boss zone, a reference to nothing places none, and the one
// faction whose boss Loadout resolves is the one entry though another faction sorts first.
[Fact]
public void Main_galaxy_bosses_are_factions_with_a_boss_loadout ( )
{
Assert . Empty ( RunStart . Generate ( new MainGalaxy ( ) , Inputs ( ( ) = > 1 ) ) . BossZones ) ;
var factions = _cache . GetAll < Faction > ( ) . OrderBy ( faction = > faction . Name , StringComparer . Ordinal ) . ToArray ( ) ;
factions [ 0 ] . BossLoadout = new CultRecordRef < Loadout > ( Loadouts . KeyOf ( "No Such Loadout" ) ) ;
Assert . Empty ( RunStart . Generate ( new MainGalaxy ( ) , Inputs ( ( ) = > 1 ) ) . BossZones ) ;
var armed = MakeBossLoadout ( BossTestHull ( ) , "Main Boss" ) ;
SetBoss ( factions [ 1 ] , armed ) ;
var galaxy = RunStart . Generate ( new MainGalaxy ( ) , Inputs ( ( ) = > 1 ) ) ;
var bossFaction = Assert . Single ( galaxy . BossZones . Keys ) ;
Assert . Equal ( factions [ 1 ] . Name , bossFaction . Name ) ;
Assert . NotNull ( Loadouts . BossOf ( _cache , bossFaction , out _ ) ) ;
}
2026-10-07 01:13:08 +02:00
[Fact]
public void Exit_sealed_until_boss_dies ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
2026-10-18 21:17:05 +02:00
GiveAntagonistABoss ( galaxy ) ;
2026-10-07 01:13:08 +02:00
var exit = ZoneOf ( galaxy , galaxy . Exit ) ;
var boss = Assert . Single ( exit . Entities , entity = > entity . IsBoss ) ;
Assert . True ( RunGoal . BossAlive ( exit ) ) ;
Assert . False ( RunGoal . ExitOpen ( exit ) ) ;
// Death removes the boss from the zone (the one death path), and that alone opens the gate.
boss . HeatstrokeDeath . OnNext ( Unit . Default ) ;
2026-10-15 01:39:37 +02:00
Assert . False ( exit . Entities . Any ( x = > ReferenceEquals ( x , boss ) ) , "the boss is removed from the exit" ) ;
2026-10-07 01:13:08 +02:00
Assert . False ( RunGoal . BossAlive ( exit ) ) ;
Assert . True ( RunGoal . ExitOpen ( exit ) ) ;
// No other zone is ever an exit, boss or no boss.
foreach ( var zone in galaxy . Zones . Where ( zone = > zone ! = galaxy . Exit ) )
Assert . False ( RunGoal . ExitOpen ( ZoneOf ( galaxy , zone ) ) ) ;
}
// Continue: the mark is saved with the zone and restored, so a boss alive when the run was saved is alive after.
[Fact]
public void Boss_mark_survives_continue ( )
{
var ( galaxy , arena , staged , failures ) = Launch ( new DemoTerminus ( ) , Inputs ( ( ) = > 1 ) ) ;
Assert . True ( failures . Count = = 0 , string . Join ( "; " , failures ) ) ;
2026-10-18 21:17:05 +02:00
GiveAntagonistABoss ( galaxy ) ;
2026-10-07 01:13:08 +02:00
galaxy . Entrance . Contents = arena ;
galaxy . Exit . Contents = ZoneOf ( galaxy , galaxy . Exit ) ;
var before = Assert . Single ( galaxy . Exit . Contents . Entities , entity = > entity . IsBoss ) ;
Assert . False ( RunGoal . ExitOpen ( galaxy . Exit . Contents ) ) ;
2026-10-08 14:09:22 +02:00
var ( game , zones ) = RunSave . Capture ( _cache , galaxy , arena , staged . Player , new SavedActionBarBinding [ 0 ] , 0 ) ;
2026-10-07 01:13:08 +02:00
RunSave . Commit ( _cache , game , zones , _items . Lots ) ;
var restored = new Galaxy ( _cache , _cache . GetGlobal < SavedGame > ( ) , _ = > { } ) ;
var exit = new Zone ( _items , _planetSettings , restored . Exit . PackedContents , restored . Exit , restored ) ;
var after = Assert . Single ( exit . Entities , entity = > entity . IsBoss ) ;
Assert . Equal ( before . Name , after . Name ) ;
Assert . Equal ( before . Faction . Name , after . Faction . Name ) ;
Assert . False ( RunGoal . ExitOpen ( exit ) ) ;
after . HeatstrokeDeath . OnNext ( Unit . Default ) ;
Assert . True ( RunGoal . ExitOpen ( exit ) ) ;
}
2026-10-07 06:41:33 +02:00
// The gate fails closed. The main galaxy puts its bosses on choke points, not at its exit, so its exit zone never gets
// one: the gate stays sealed on arrival and when every boss in the galaxy dies, because a boss that never existed is
// not a dead one.
[Fact]
public void Exit_sealed_where_no_boss_was_spawned_main_galaxy ( )
{
foreach ( var seed in new uint [ ] { 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 } )
{
var galaxy = RunStart . Generate ( new MainGalaxy ( ) , Inputs ( ( ) = > seed ) ) ;
var exit = ZoneOf ( galaxy , galaxy . Exit ) ;
Assert . False ( RunGoal . ExitOpen ( exit ) , $"seed {seed}: the gate is open on arrival" ) ;
foreach ( var boss in exit . Entities . Where ( entity = > entity . IsBoss ) . ToArray ( ) )
boss . HeatstrokeDeath . OnNext ( Unit . Default ) ;
Assert . False ( RunGoal . ExitOpen ( exit ) , $"seed {seed}: the gate is open with no boss spawned" ) ;
}
}
2026-10-18 21:17:05 +02:00
// The same for a demo exit whose boss could not be built: the zone logs the gap by name and the gate stays sealed.
2026-10-07 06:41:33 +02:00
[Fact]
public void Exit_sealed_and_logged_when_the_boss_cannot_be_generated ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
galaxy . BossZones . Clear ( ) ;
var log = new List < string > ( ) ;
2026-10-07 06:49:39 +02:00
var items = LoggingItems ( log ) ;
var pack = BossZonePack ( galaxy , items , galaxy . Exit ) ;
2026-10-07 06:41:33 +02:00
Assert . False ( pack . BossSpawned ) ;
Assert . Contains ( log , line = > line . Contains ( "Galaxy.Exit" ) & & line . Contains ( "exit-without-boss" ) ) ;
2026-10-07 06:49:39 +02:00
var exit = new Zone ( items , _planetSettings , pack , galaxy . Exit , galaxy ) ;
2026-10-07 06:41:33 +02:00
Assert . False ( RunGoal . ExitOpen ( exit ) ) ;
}
2026-10-16 21:18:52 +02:00
// The gate is the exit zone's alone. The main galaxy puts bosses on choke points that are not its exit: such a zone's
// boss spawns and dies, and no gate opens, because the exit-zone condition is what keeps a slain boss from winning.
[Fact]
public void Exit_open_only_in_the_exit_zone ( )
{
var galaxy = RunStart . Generate ( new MainGalaxy ( ) , Inputs ( ( ) = > 1 ) ) ;
2026-10-18 21:17:05 +02:00
// The shipped catalog sets no boss Loadout, so the main galaxy places no boss: give a faction one and a boss zone that is
2026-10-18 21:25:21 +02:00
// not the exit, as the generator would for a faction with a boss Loadout. This Loadout pins its hull's brand, so any faction
// builds it whatever it can get.
var faction = galaxy . Factions . First ( ) ;
2026-10-18 21:22:05 +02:00
var hull = SoldShipHulls ( ) . OrderBy ( h = > h . Name , StringComparer . Ordinal ) . First ( ) ;
2026-10-18 21:25:21 +02:00
var gate = MakeBossLoadout ( hull , "Gate Boss" ) ;
gate . HullProduct = _cache . RefOf ( _cache . GetAll < FactionProductData > ( ) . OrderBy ( product = > product . Name , StringComparer . Ordinal ) . First ( product = > _cache . Get ( product . Design ) = = hull ) ) ;
StoreLoadout ( gate ) ;
SetBoss ( faction , gate ) ;
2026-10-16 21:39:05 +02:00
var bossZone = galaxy . Zones . First ( zone = > zone ! = galaxy . Exit ) ;
galaxy . BossZones [ faction ] = bossZone ;
2026-10-18 21:24:46 +02:00
var log = new List < string > ( ) ;
var items = LoggingItems ( log ) ;
var zone = new Zone ( items , _planetSettings , BossZonePack ( galaxy , items , bossZone ) , bossZone , galaxy ) ;
Assert . True ( zone . Pack . BossSpawned , string . Join ( "; " , log ) ) ;
2026-10-16 21:39:05 +02:00
Assert . NotEmpty ( zone . Entities . Where ( entity = > entity . IsBoss ) ) ;
foreach ( var boss in zone . Entities . Where ( entity = > entity . IsBoss ) . ToArray ( ) )
boss . HeatstrokeDeath . OnNext ( Unit . Default ) ;
Assert . False ( RunGoal . BossAlive ( zone ) ) ;
Assert . False ( RunGoal . ExitOpen ( zone ) ) ;
2026-10-16 21:18:52 +02:00
}
2026-10-07 06:41:33 +02:00
// A boss killed before the save stays dead after it, and the gate stays open: the spawn fact travels with the zone.
[Fact]
public void Exit_open_after_save_of_a_slain_boss ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
2026-10-18 21:17:05 +02:00
GiveAntagonistABoss ( galaxy ) ;
2026-10-07 06:41:33 +02:00
var exit = ZoneOf ( galaxy , galaxy . Exit ) ;
Assert . Single ( exit . Entities , entity = > entity . IsBoss ) . HeatstrokeDeath . OnNext ( Unit . Default ) ;
var repacked = exit . PackZone ( ) ;
Assert . True ( repacked . BossSpawned ) ;
Assert . DoesNotContain ( repacked . Entities , entity = > entity . Boss ) ;
Assert . True ( RunGoal . ExitOpen ( new Zone ( _items , _planetSettings , repacked , galaxy . Exit , galaxy ) ) ) ;
}
2026-10-07 01:13:08 +02:00
// The gate shares no point with an adjacency wormhole: every one is farther from it than a ship's wormhole reach.
[Fact]
public void Exit_gate_shares_no_point_with_a_wormhole ( )
{
var ratio = float . Parse ( File . ReadLines ( Path . Combine ( FindRepoRoot ( ) , "Assets" , "Resources" , "Settings.asset" ) )
. Select ( line = > Regex . Match ( line , @"^ WormholeDistanceRatio: (.+)$" ) ) . First ( match = > match . Success ) . Groups [ 1 ] . Value . Trim ( ) ,
System . Globalization . CultureInfo . InvariantCulture ) ;
var reach = AuthoredSettings . Load ( FindRepoRoot ( ) ) . Read < GameplaySettings > ( "GameplaySettings" ) . WormholeExitRadius ;
foreach ( var seed in DemoSeeds )
{
var galaxy = DemoGalaxy ( seed ) ;
var zone = ZoneOf ( galaxy , galaxy . Exit ) ;
var gate = RunGoal . ExitGatePosition ( zone , ratio ) ;
foreach ( var adjacent in galaxy . Exit . AdjacentZones )
{
var wormhole = math . normalize ( adjacent . Position - galaxy . Exit . Position ) * zone . Pack . Radius * ratio ;
Assert . True ( math . length ( gate - wormhole ) > 2 * reach , $"seed {seed}: the exit gate sits {math.length(gate - wormhole)} from a wormhole" ) ;
}
}
}
2026-10-07 03:54:04 +02:00
// The gate sits on the wormholes' radius, in the middle of the widest gap between neighbours, whatever the
// neighbours' number and order: an empty ring, one, two straddling the +-pi seam, and eight spread evenly (every gap
// under one radian). Checked against a brute-force sweep of every direction.
[Theory]
[InlineData(new double[0] ) ]
[InlineData(new[] { 40.0 } ) ]
[InlineData(new[] { 170.0 , - 170.0 } ) ]
[InlineData(new[] { 100.0 , 10.0 , 20.0 , 350.0 , - 90.0 } ) ]
[InlineData(new[] { 0.0 , 45.0 , 90.0 , 135.0 , 180.0 , - 135.0 , - 90.0 , - 45.0 } ) ]
public void Exit_gate_sits_in_the_widest_gap_at_the_wormhole_radius ( double [ ] neighbourDegrees )
{
var galaxy = DemoGalaxy ( 1 ) ;
var zone = ZoneOf ( galaxy , galaxy . Exit ) ;
const float ratio = 0.75f ;
var radius = zone . Pack . Radius * ratio ;
2026-10-07 04:38:36 +02:00
// Away from the origin and with close neighbours, so a direction taken from raw positions instead of the
// offset from the zone cannot pass.
var originalPosition = galaxy . Exit . Position ;
var origin = galaxy . Exit . Position = math . float2 ( 500 , - 300 ) ;
2026-10-07 03:54:04 +02:00
var original = galaxy . Exit . AdjacentZones ;
galaxy . Exit . AdjacentZones = neighbourDegrees
. Select ( degrees = > new GalaxyZone
{
2026-10-07 04:38:36 +02:00
Position = origin + math . float2 ( ( float ) Math . Cos ( degrees * Math . PI / 180 ) , ( float ) Math . Sin ( degrees * Math . PI / 180 ) ) * 10
2026-10-07 03:54:04 +02:00
} ) . ToList ( ) ;
float2 gate ;
try { gate = RunGoal . ExitGatePosition ( zone , ratio ) ; }
2026-10-07 04:38:36 +02:00
finally { galaxy . Exit . AdjacentZones = original ; galaxy . Exit . Position = originalPosition ; }
2026-10-07 03:54:04 +02:00
Assert . Equal ( radius , math . length ( gate ) , 2 ) ;
if ( neighbourDegrees . Length = = 0 ) { Assert . Equal ( radius , gate . x , 2 ) ; Assert . Equal ( 0 , gate . y , 2 ) ; return ; }
double Separation ( double angle ) = > neighbourDegrees . Min ( degrees = >
{
var difference = Math . Abs ( angle - degrees * Math . PI / 180 ) % ( 2 * Math . PI ) ;
return difference > Math . PI ? 2 * Math . PI - difference : difference ;
} ) ;
var best = Enumerable . Range ( 0 , 7200 ) . Max ( step = > Separation ( step * Math . PI / 3600 ) ) ;
Assert . True ( Separation ( Math . Atan2 ( gate . y , gate . x ) ) > = best - 0.002 ,
$"the gate's nearest wormhole is {Separation(Math.Atan2(gate.y, gate.x))} rad away; the widest gap's middle is {best}" ) ;
}
2026-10-16 21:18:52 +02:00
// Two neighbours straight across leave two gaps of exactly pi: the first in ascending angle (from -pi) wins, so the
// gate sits at +radius on y, not -radius.
[Fact]
public void Exit_gate_takes_the_first_of_equal_gaps ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
var zone = ZoneOf ( galaxy , galaxy . Exit ) ;
const float ratio = 0.75f ;
var originalPosition = galaxy . Exit . Position ;
var origin = galaxy . Exit . Position = math . float2 ( 500 , - 300 ) ;
var original = galaxy . Exit . AdjacentZones ;
galaxy . Exit . AdjacentZones = new List < GalaxyZone >
{
new GalaxyZone { Position = math . float2 ( 510 , - 300 ) } ,
new GalaxyZone { Position = math . float2 ( 490 , - 300 ) } ,
} ;
float2 gate ;
try { gate = RunGoal . ExitGatePosition ( zone , ratio ) ; }
finally { galaxy . Exit . AdjacentZones = original ; galaxy . Exit . Position = originalPosition ; }
Assert . Equal ( 0 , gate . x , 3 ) ;
Assert . Equal ( zone . Pack . Radius * ratio , gate . y , 2 ) ;
}
2026-10-19 00:04:45 +02:00
// Soul's probes of cut boss-loadout pass s1, kept as behaviour: the rules the first eight tests let a mutant past.
[Fact]
public void The_boss_is_the_loadout_its_faction_names_even_when_another_loadout_shares_its_hull ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
var decoy = MakeBossLoadout ( BossTestHull ( ) , "Aaa Same Hull" ) ;
decoy . Slots = decoy . Slots . Take ( 1 ) . ToList ( ) ;
decoy . WeaponGroups = new int [ 0 ] [ ] ;
StoreLoadout ( decoy ) ;
var boss = GiveAntagonistABoss ( galaxy ) ;
Assert . True ( boss . Slots . Count > decoy . Slots . Count ) ;
var pack = Assert . Single ( BossZonePack ( galaxy , _items , galaxy . Exit ) . Entities , e = > e . Boss ) ;
Assert . Equal ( LoadoutCells ( boss ) , Cells ( _items , pack ) ) ;
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void A_refused_boss_flies_no_catalog_loadout_and_draws_nothing ( bool dangling )
{
var galaxy = DemoGalaxy ( 1 ) ;
var hull = SoldShipHulls ( ) . OrderBy ( h = > h . Name , StringComparer . Ordinal ) . First ( ) ;
StoreLoadout ( new Loadout { Name = "Aaa Decoy" , Hull = _cache . RefOf ( hull ) } ) ;
if ( dangling ) Zhestokost ( galaxy ) . BossLoadout = new CultRecordRef < Loadout > ( Loadouts . KeyOf ( "CANARY-7f3a" ) ) ;
var log = new List < string > ( ) ;
var items = LoggingItems ( log ) ;
var pack = BossZonePack ( galaxy , items , galaxy . Exit ) ;
var next = items . Random . NextFloat ( ) ;
Assert . False ( pack . BossSpawned ) ;
Assert . DoesNotContain ( pack . Entities , e = > e . Boss ) ;
Assert . DoesNotContain ( log , l = > l . Contains ( "CANARY" ) ) ;
galaxy . BossZones . Clear ( ) ;
var bareItems = LoggingItems ( new List < string > ( ) ) ;
var bare = BossZonePack ( galaxy , bareItems , galaxy . Exit ) ;
Assert . Equal ( Roster ( bareItems , bare ) , Roster ( items , pack ) ) ;
Assert . Equal ( bareItems . Random . NextFloat ( ) , next ) ;
}
[Fact]
public void A_design_only_boss_slot_whose_design_only_outsiders_make_refuses_the_boss ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
var faction = Zhestokost ( galaxy ) ;
var loadout = GiveAntagonistABoss ( galaxy ) ;
var outfitter = GeneratorFor ( galaxy , _items , faction , 1 ) ;
var open = loadout . Slots . First ( slot = > _cache . GetAll < FactionProductData > ( ) . Any ( p = > p . Design . Key . Equals ( slot . Design . Key ) & & outfitter . IsAvailable ( p ) ) ) ;
var outsider = _cache . GetAll < Faction > ( ) . OrderBy ( f = > f . Name , StringComparer . Ordinal ) . First ( f = > f . Name ! = faction . Name & & ! faction . Allegiance . ContainsKey ( _cache . RefOf ( f ) ) ) ;
foreach ( var p in _cache . GetAll < FactionProductData > ( ) . Where ( p = > p . Design . Key . Equals ( open . Design . Key ) ) ) p . Manufacturer = _cache . RefOf ( outsider ) ;
Assert . Null ( outfitter . ChooseProduct ( _cache . Get ( open . Design ) ) ) ;
open . Product = default ;
StoreLoadout ( loadout ) ;
var log = new List < string > ( ) ;
var pack = BossZonePack ( galaxy , LoggingItems ( log ) , galaxy . Exit , ambient : false ) ;
Assert . False ( pack . BossSpawned ) ;
Assert . DoesNotContain ( pack . Entities , e = > e . Boss ) ;
Assert . Contains ( log , l = > l . Contains ( "Antagonist Boss" ) & & l . Contains ( "no available product" ) ) ;
}
[Fact]
public void The_boss_path_draws_only_what_building_its_loadout_draws ( )
{
var galaxy = DemoGalaxy ( 1 ) ;
var loadout = GiveAntagonistABoss ( galaxy ) ;
var withBoss = LoggingItems ( new List < string > ( ) ) ;
Assert . True ( BossZonePack ( galaxy , withBoss , galaxy . Exit , ambient : false ) . BossSpawned ) ;
var next = withBoss . Random . NextFloat ( ) ;
galaxy . BossZones . Clear ( ) ;
var expected = LoggingItems ( new List < string > ( ) ) ;
BossZonePack ( galaxy , expected , galaxy . Exit , ambient : false ) ;
var failures = new List < string > ( ) ;
Assert . NotNull ( Loadouts . Materialize ( expected , null , loadout , GeneratorFor ( galaxy , expected , Zhestokost ( galaxy ) , 7 ) . ChooseProduct , failures ) ) ;
Assert . Empty ( failures ) ;
Assert . Equal ( expected . Random . NextFloat ( ) , next ) ;
}
2026-10-19 00:21:11 +02:00
2026-10-19 01:30:10 +02:00
// Everything a generated zone exposes afterwards: the serialized ledger, the roster and the gear of its ships, and
2026-10-19 01:22:38 +02:00
// the next draw of the item stream. A faction's generator stream shows in the ships it outfitted, so it is in the pack.
private string ZoneFacts ( ItemManager items , ZonePack pack )
{
var options = CultDocumentMessagePackSerialization . OptionsFor ( typeof ( HullData ) . Assembly ) ;
2026-10-19 01:30:10 +02:00
var bytes = Convert . ToHexString ( System . Security . Cryptography . SHA256 . HashData ( MessagePack . MessagePackSerializer . Serialize ( items . Lots , options ) ) ) ;
2026-10-19 01:25:40 +02:00
var gear = pack . Entities . Select ( entity = > $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : " - ")}|{(EntitySerializer.Unpack(items, null, entity) is Ship ? string.Join(" ; ", Cells(items, entity)) : " - ")}" ) ;
2026-10-19 01:30:10 +02:00
return $"ledger {bytes} NextLot={items.Lots.NextLot} lots={items.Lots.Lots.Count}\n{string.Join(" \ n ", gear)}\nnext item draw {items.Random.NextFloat():R}" ;
2026-10-19 01:22:38 +02:00
}
// The boss of the named faction, whose Loadout is made unbuildable by unbuild, refuses before anything is minted or drawn: the
// zone is the zone with no boss in every state it exposes. The faction need not be the zone's nearest.
private void AssertUnbuildableBossIsAsIfAbsent ( string bossFaction , Action < Loadout > unbuild )
2026-10-19 00:21:11 +02:00
{
var galaxy = DemoGalaxy ( 1 ) ;
var loadout = GiveAntagonistABoss ( galaxy ) ;
2026-10-19 01:22:38 +02:00
unbuild ( loadout ) ;
2026-10-19 00:21:11 +02:00
StoreLoadout ( loadout ) ;
2026-10-19 01:22:38 +02:00
var boss = galaxy . Factions . Single ( faction = > faction . Name = = bossFaction ) ;
Zhestokost ( galaxy ) . BossLoadout = default ;
SetBoss ( boss , loadout ) ;
2026-10-19 00:21:11 +02:00
galaxy . BossZones . Clear ( ) ;
2026-10-19 01:22:38 +02:00
galaxy . BossZones [ boss ] = galaxy . Exit ;
var log = new List < string > ( ) ;
var items = LoggingItems ( log ) ;
var pack = BossZonePack ( galaxy , items , galaxy . Exit ) ;
Assert . False ( pack . BossSpawned , bossFaction ) ;
Assert . Contains ( log , line = > line . Contains ( "Antagonist Boss" ) & & line . Contains ( "cannot be built" ) ) ;
var bare = DemoGalaxy ( 1 ) ;
bare . BossZones . Clear ( ) ;
2026-10-19 00:21:11 +02:00
var bareItems = LoggingItems ( new List < string > ( ) ) ;
2026-10-19 01:22:38 +02:00
var barePack = BossZonePack ( bare , bareItems , bare . Exit ) ;
2026-10-19 01:29:03 +02:00
var expected = ZoneFacts ( bareItems , barePack ) ;
var actual = ZoneFacts ( items , pack ) ;
Assert . True ( expected = = actual , $"{bossFaction}: the zone with an unbuildable boss differs from the zone with none.\nno boss:\n{expected}\nunbuildable boss:\n{actual}\nlog: {string.Join(" / ", log)}" ) ;
2026-10-19 01:22:38 +02:00
}
private IEnumerable < string > AllFactionNames ( ) = > DemoGalaxy ( 1 ) . Factions . Select ( faction = > faction . Name ) . OrderBy ( name = > name , StringComparer . Ordinal ) ;
private static void MoveOut ( Loadout loadout , int slots )
{
for ( var i = 0 ; i < slots ; i + + ) loadout . Slots [ i ] . Position = new int2 ( 999 - i , 999 - i ) ;
}
[Fact]
public void An_unbuildable_boss_loadout_with_one_failure_leaves_the_zone_as_if_it_had_no_boss ( )
{
foreach ( var faction in AllFactionNames ( ) ) AssertUnbuildableBossIsAsIfAbsent ( faction , loadout = > MoveOut ( loadout , 1 ) ) ;
}
[Fact]
public void An_unbuildable_boss_loadout_with_several_failures_leaves_the_zone_as_if_it_had_no_boss ( )
{
foreach ( var faction in AllFactionNames ( ) ) AssertUnbuildableBossIsAsIfAbsent ( faction , loadout = > MoveOut ( loadout , 3 ) ) ;
}
// A design-only slot asks the chooser, which draws its faction's stream; the fit is decided before it is asked.
[Fact]
public void An_unbuildable_boss_loadout_with_design_only_slots_leaves_the_zone_as_if_it_had_no_boss ( )
{
foreach ( var faction in AllFactionNames ( ) )
AssertUnbuildableBossIsAsIfAbsent ( faction , loadout = >
{
foreach ( var slot in loadout . Slots ) slot . Product = default ;
Assert . Contains ( loadout . Slots , slot = > _cache . GetAll < FactionProductData > ( ) . Any ( p = > p . Design . Key . Equals ( slot . Design . Key ) ) ) ;
MoveOut ( loadout , 1 ) ;
loadout . Slots [ loadout . Slots . Count - 1 ] . Position = new int2 ( 900 , 900 ) ;
} ) ;
2026-10-19 00:21:11 +02:00
}
2026-10-07 01:13:08 +02:00
}