2026-10-17 19:10:17 +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 GameCult.Caching ;
using Xunit ;
using Random = CultMath . Random ;
// Each faction names the zones it owns from its own curated list (aetheria-release, cut faction-curated-place-names): the
// home zone takes the capital, the rest follow in distance order, a spent or absent list falls back to catalogue designations,
// and no name is used twice. Galaxies are built the way the game builds them, from the authored settings and the shipped catalog.
public sealed class PlaceNamesTests : IDisposable
{
private static readonly Regex Designation = new ( @"^EAC-\d{1,4}$" ) ;
private static readonly AuthoredSettings Authored = AuthoredSettings . Load ( RunStartTests . FindRepoRoot ( ) ) ;
private static readonly CultCache Shared = RestoredHullsTests . OpenCatalog ( ) ;
private static readonly Dictionary < ( bool , uint ) , Galaxy > Built = new ( ) ;
private readonly List < CultCache > _own = new ( ) ;
public void Dispose ( )
{
foreach ( var cache in _own ) cache . Dispose ( ) ;
foreach ( var root in _scratch ) Directory . Delete ( root , true ) ;
}
private static Galaxy Build ( CultCache cache , bool prelude , uint seed ) = > prelude
? new Galaxy ( Authored . Read < TutorialGenerationSettings > ( "TutorialGenerationSettings" ) , Authored . Read < SectorBackgroundSettings > ( "TutorialBackgroundSettings" ) ,
2026-10-17 19:26:15 +02:00
cache , new PlayerSettings ( ) , new DirectoryInfo ( Path . GetTempPath ( ) ) , _ = > { } , null , seed )
2026-10-17 19:10:17 +02:00
: new Galaxy ( Authored . Read < SectorGenerationSettings > ( "SectorGenerationSettings" ) , Authored . Read < SectorBackgroundSettings > ( "SectorBackgroundSettings" ) ,
2026-10-17 19:26:15 +02:00
cache , _ = > { } , null , seed ) ;
2026-10-17 19:10:17 +02:00
// A prelude galaxy sometimes refuses to generate for a seed (RestoredHullsTests calls it Galaxy's own seed-dependent
// flakiness); those seeds are skipped, and a test that depends on how many built says so.
private static Galaxy TryBuild ( bool prelude , uint seed )
{
lock ( Built )
{
if ( Built . TryGetValue ( ( prelude , seed ) , out var known ) ) return known ;
Galaxy galaxy ;
try { galaxy = Build ( Shared , prelude , seed ) ; }
catch ( Exception ) { galaxy = null ; }
return Built [ ( prelude , seed ) ] = galaxy ;
}
}
private static IEnumerable < Galaxy > Galaxies ( bool prelude , uint last ) = >
Enumerable . Range ( 1 , ( int ) last ) . Select ( seed = > TryBuild ( prelude , ( uint ) seed ) ) . Where ( g = > g ! = null ) ;
private static Faction [ ] Placed ( Galaxy galaxy ) = >
galaxy . Factions . Where ( f = > galaxy . HomeZones . ContainsKey ( f ) & & f . Places . Length ! = 0 ) . ToArray ( ) ;
private static GalaxyZone [ ] Owned ( Galaxy galaxy , Faction faction ) = >
galaxy . Zones . Select ( ( zone , index ) = > ( zone , index ) ) . Where ( z = > z . zone . Owner = = faction )
. OrderBy ( z = > z . zone . Distance [ galaxy . HomeZones [ faction ] ] ) . ThenBy ( z = > z . index ) . Select ( z = > z . zone ) . ToArray ( ) ;
2026-10-17 19:35:04 +02:00
// Two factions can share a home zone (the prelude puts the quest faction on the buffer's); the zone's owner is the
// nearest home in faction order, and that owner's capital is what the zone is called.
2026-10-17 19:10:17 +02:00
[Fact]
2026-10-17 19:35:04 +02:00
public void TheHomeZoneTakesTheOwnersFirstName ( )
2026-10-17 19:10:17 +02:00
{
2026-10-17 19:35:04 +02:00
var own = 0 ;
var shared = 0 ;
2026-10-17 19:10:17 +02:00
foreach ( var galaxy in Galaxies ( true , 10 ) . Concat ( Galaxies ( false , 10 ) ) )
2026-10-17 19:35:04 +02:00
foreach ( var faction in galaxy . Factions . Where ( galaxy . HomeZones . ContainsKey ) )
2026-10-17 19:10:17 +02:00
{
2026-10-17 19:35:04 +02:00
var home = galaxy . HomeZones [ faction ] ;
var owner = home . Owner ;
Assert . NotNull ( owner ) ;
if ( owner . Places . Length = = 0 ) Assert . Matches ( Designation , home . Name ) ;
else Assert . Equal ( owner . Places [ 0 ] . Name , home . Name ) ;
if ( owner = = faction ) own + + ; else shared + + ;
2026-10-17 19:10:17 +02:00
}
2026-10-17 19:35:04 +02:00
Assert . True ( own > = 20 , $"only {own} factions owned their home" ) ;
Assert . True ( shared > 0 , "no galaxy shared a home zone, so the shared case was not exercised" ) ;
2026-10-17 19:10:17 +02:00
}
[Fact]
public void OwnedZonesTakeTheListInDistanceOrder ( )
{
var checkedFactions = 0 ;
foreach ( var galaxy in Galaxies ( true , 10 ) . Concat ( Galaxies ( false , 10 ) ) )
foreach ( var faction in Placed ( galaxy ) )
{
var owned = Owned ( galaxy , faction ) ;
var listed = Math . Min ( owned . Length , faction . Places . Length ) ;
Assert . Equal ( faction . Places . Take ( listed ) . Select ( p = > p . Name ) , owned . Take ( listed ) . Select ( z = > z . Name ) ) ;
2026-10-17 22:14:26 +02:00
Assert . All ( owned . Skip ( listed ) . Select ( z = > z . Name ) , name = > Assert . Matches ( Designation , name ) ) ;
2026-10-17 19:10:17 +02:00
checkedFactions + + ;
}
Assert . True ( checkedFactions > = 20 , $"only {checkedFactions} factions were checked" ) ;
}
[Fact]
public void NoZoneCarriesAnotherFactionsName ( )
{
var curated = Shared . GetAll < Faction > ( ) . SelectMany ( f = > f . Places . Select ( p = > ( p . Name , Faction : f ) ) ) . ToDictionary ( t = > t . Name , t = > t . Faction , StringComparer . OrdinalIgnoreCase ) ;
var zones = 0 ;
foreach ( var galaxy in Galaxies ( true , 10 ) . Concat ( Galaxies ( false , 10 ) ) )
foreach ( var zone in galaxy . Zones . Where ( z = > curated . ContainsKey ( z . Name ) ) )
{
2026-10-17 22:14:26 +02:00
Assert . True ( ReferenceEquals ( curated [ zone . Name ] , zone . Owner ) ,
$"zone '{zone.Name}' is owned by '{zone.Owner?.ShortName}' but curated by '{curated[zone.Name].ShortName}'" ) ;
2026-10-17 19:10:17 +02:00
zones + + ;
}
Assert . True ( zones > = 50 , $"only {zones} curated zones were checked" ) ;
}
[Fact]
public void ASpentListFallsBackToCatalogueDesignations ( )
{
// Its own cache: the faction's list is cut, which must not reach the galaxies the other tests share.
var cache = RestoredHullsTests . OpenCatalog ( ) ;
_own . Add ( cache ) ;
var galaxy = Enumerable . Range ( 1 , 20 ) . Select ( seed = > { try { return Build ( cache , true , ( uint ) seed ) ; } catch ( Exception ) { return null ; } } )
. First ( g = > g ! = null & & Placed ( g ) . Any ( f = > Owned ( g , f ) . Length > = 4 ) ) ;
var faction = Placed ( galaxy ) . First ( f = > Owned ( galaxy , f ) . Length > = 4 ) ;
faction . Places = faction . Places . Take ( 2 ) . ToArray ( ) ;
var random = new Random ( 7 ) ;
galaxy . NamePlaces ( ref random ) ;
var owned = Owned ( galaxy , faction ) ;
Assert . Equal ( faction . Places . Select ( p = > p . Name ) , owned . Take ( 2 ) . Select ( z = > z . Name ) ) ;
2026-10-17 22:14:26 +02:00
Assert . True ( ReferenceEquals ( owned [ 0 ] , galaxy . HomeZones [ faction ] ) , $"the nearest zone '{owned[0].Name}' is not {faction.ShortName}'s home '{galaxy.HomeZones[faction].Name}'" ) ;
Assert . All ( owned . Skip ( 2 ) . Select ( z = > z . Name ) , name = > Assert . Matches ( Designation , name ) ) ;
2026-10-17 19:10:17 +02:00
}
2026-10-17 20:03:27 +02:00
// The table refuses a repeated name, but a list that did not come through it (a package's factions) can still hold
// one: the later faction skips a name the earlier one already used, whatever its case.
[Fact]
public void AnotherFactionsNameIsNotReusedWhateverItsCase ( )
{
var cache = RestoredHullsTests . OpenCatalog ( ) ;
_own . Add ( cache ) ;
var galaxy = Enumerable . Range ( 1 , 20 ) . Select ( seed = > { try { return Build ( cache , true , ( uint ) seed ) ; } catch ( Exception ) { return null ; } } )
. First ( g = > g ! = null & & Placed ( g ) . Count ( f = > Owned ( g , f ) . Length > = 2 ) > = 2 ) ;
var pair = Placed ( galaxy ) . Where ( f = > Owned ( galaxy , f ) . Length > = 2 ) . Take ( 2 ) . ToArray ( ) ;
var ( first , later ) = ( pair [ 0 ] , pair [ 1 ] ) ;
Assert . True ( Array . IndexOf ( galaxy . Factions , first ) < Array . IndexOf ( galaxy . Factions , later ) ) ;
var taken = first . Places [ 0 ] . Name ;
var shouting = taken . ToUpperInvariant ( ) ;
Assert . NotEqual ( taken , shouting ) ;
2026-10-17 22:14:26 +02:00
// Two consecutive names are taken, so the later faction must skip a run, not a single name.
var takenToo = first . Places [ 1 ] . Name ;
2026-10-17 20:03:27 +02:00
var own = later . Places [ 0 ] . Name ;
2026-10-17 22:14:26 +02:00
later . Places = new [ ] { new FactionPlace { Name = shouting } , new FactionPlace { Name = takenToo . ToUpperInvariant ( ) } , new FactionPlace { Name = own } } ;
2026-10-17 20:03:27 +02:00
var random = new Random ( 7 ) ;
galaxy . NamePlaces ( ref random ) ;
Assert . Equal ( taken , Owned ( galaxy , first ) [ 0 ] . Name ) ;
Assert . Equal ( own , Owned ( galaxy , later ) [ 0 ] . Name ) ;
var names = galaxy . Zones . Select ( z = > z . Name ) . ToArray ( ) ;
Assert . Equal ( names . Length , names . Distinct ( StringComparer . OrdinalIgnoreCase ) . Count ( ) ) ;
}
2026-10-17 22:14:26 +02:00
// The rule is one function (FactionPlace.IsValidName); an entry that fails it counts as absent, so the next valid name
// is the capital and nothing invalid ever titles a zone.
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(" Padded")]
[InlineData("eac-6")]
[InlineData("EAC-6")]
[InlineData(null)]
public void AnInvalidEntryCountsAsAbsent ( string bad )
{
var cache = RestoredHullsTests . OpenCatalog ( ) ;
_own . Add ( cache ) ;
var galaxy = Enumerable . Range ( 1 , 20 ) . Select ( seed = > { try { return Build ( cache , true , ( uint ) seed ) ; } catch ( Exception ) { return null ; } } )
. First ( g = > g ! = null & & Placed ( g ) . Any ( f = > Owned ( g , f ) . Length > = 3 ) ) ;
var faction = Placed ( galaxy ) . First ( f = > Owned ( galaxy , f ) . Length > = 3 ) ;
faction . Places = new [ ] { new FactionPlace { Name = bad } , new FactionPlace { Name = "Real" } , null , new FactionPlace { Name = "Second" } } ;
var random = new Random ( 7 ) ;
galaxy . NamePlaces ( ref random ) ;
Assert . Equal ( new [ ] { "Real" , "Second" } , Owned ( galaxy , faction ) . Take ( 2 ) . Select ( z = > z . Name ) ) ;
Assert . Matches ( Designation , Owned ( galaxy , faction ) [ 2 ] . Name ) ;
}
// A faction with no home does not stop the ones after it from naming their zones.
[Fact]
public void AFactionWithoutAHomeDoesNotEndTheNaming ( )
{
var cache = RestoredHullsTests . OpenCatalog ( ) ;
_own . Add ( cache ) ;
var galaxy = Enumerable . Range ( 1 , 20 ) . Select ( seed = > { try { return Build ( cache , true , ( uint ) seed ) ; } catch ( Exception ) { return null ; } } )
. First ( g = > g ! = null & & Placed ( g ) . Count ( f = > g . HomeZones [ f ] . Owner = = f ) > = 2 ) ;
var homeless = galaxy . Factions . First ( galaxy . HomeZones . ContainsKey ) ;
var later = galaxy . Factions . Where ( f = > f ! = homeless & & galaxy . HomeZones . ContainsKey ( f ) & & f . Places . Length ! = 0 & & galaxy . HomeZones [ f ] . Owner = = f )
. First ( f = > Array . IndexOf ( galaxy . Factions , f ) > Array . IndexOf ( galaxy . Factions , homeless ) ) ;
galaxy . HomeZones . Remove ( homeless ) ;
var random = new Random ( 7 ) ;
galaxy . NamePlaces ( ref random ) ;
Assert . Equal ( later . Places [ 0 ] . Name , galaxy . HomeZones [ later ] . Name ) ;
}
2026-10-17 19:10:17 +02:00
[Fact]
public void NoTwoZonesShareAName ( )
{
var checkedGalaxies = 0 ;
foreach ( var galaxy in Galaxies ( true , 40 ) . Concat ( Galaxies ( false , 40 ) ) )
{
var names = galaxy . Zones . Select ( z = > z . Name ) . ToArray ( ) ;
Assert . All ( names , name = > Assert . False ( string . IsNullOrEmpty ( name ) ) ) ;
Assert . Equal ( names . Length , names . Distinct ( StringComparer . OrdinalIgnoreCase ) . Count ( ) ) ;
checkedGalaxies + + ;
}
Assert . True ( checkedGalaxies > = 40 , $"only {checkedGalaxies} galaxies were checked" ) ;
}
[Fact]
public void AFactionWithoutAListGetsCatalogueDesignations ( )
{
2026-10-17 20:35:37 +02:00
// Its own cache: the list is cleared, which must not reach the galaxies the other tests share.
var cache = RestoredHullsTests . OpenCatalog ( ) ;
_own . Add ( cache ) ;
var terris = cache . GetAll < Faction > ( ) . Single ( f = > f . ShortName = = "Miss Terri's" ) ;
Assert . NotEmpty ( terris . Places ) ;
var galaxy = Enumerable . Range ( 1 , 20 ) . Select ( seed = > { try { return Build ( cache , true , ( uint ) seed ) ; } catch ( Exception ) { return null ; } } )
. First ( g = > g ! = null & & g . Zones . Any ( z = > z . Owner = = terris ) ) ;
terris . Places = Array . Empty < FactionPlace > ( ) ;
var random = new Random ( 7 ) ;
galaxy . NamePlaces ( ref random ) ;
var held = galaxy . Zones . Where ( z = > z . Owner = = terris ) . ToArray ( ) ;
Assert . NotEmpty ( held ) ;
2026-10-17 22:14:26 +02:00
Assert . All ( held . Select ( z = > z . Name ) , name = > Assert . Matches ( Designation , name ) ) ;
2026-10-17 19:10:17 +02:00
}
[Fact]
public void TheShippedListsAreTheTable ( )
{
var factions = Shared . GetAll < Faction > ( ) . ToArray ( ) ;
foreach ( var row in PlaceNamesCommand . Table )
{
2026-10-17 22:14:26 +02:00
var matching = factions . Where ( f = > Shared . RefOf ( f ) . Key . Value = = row . RecordKey ) . ToArray ( ) ;
Assert . True ( matching . Length = = 1 , $"{matching.Length} catalog factions carry the key of the {row.ShortName} row" ) ;
var faction = matching [ 0 ] ;
2026-10-17 19:10:17 +02:00
Assert . Equal ( row . ShortName , faction . ShortName ) ;
Assert . Equal ( row . Names , faction . Places . Select ( p = > p . Name ) ) ;
}
Assert . Equal ( PlaceNamesCommand . Table . Length , factions . Count ( f = > f . Places . Length ! = 0 ) ) ;
var names = factions . SelectMany ( f = > f . Places . Select ( p = > p . Name ) ) . ToArray ( ) ;
Assert . All ( names , name = >
{
Assert . False ( string . IsNullOrEmpty ( name ) ) ;
Assert . Equal ( name . Trim ( ) , name ) ;
Assert . False ( name . StartsWith ( "EAC-" , StringComparison . OrdinalIgnoreCase ) ) ;
} ) ;
Assert . Equal ( names . Length , names . Distinct ( StringComparer . OrdinalIgnoreCase ) . Count ( ) ) ;
}
// The command's scratch catalog: the shipped one with every list cleared, so an apply has all of it to author.
private string ScratchWithoutLists ( )
{
var root = Path . Combine ( Path . GetTempPath ( ) , "place-names-" + Guid . NewGuid ( ) . ToString ( "N" ) ) ;
Directory . CreateDirectory ( Path . Combine ( root , "GameData" ) ) ;
var path = Path . Combine ( root , "GameData" , "Aetheria.cc" ) ;
File . Copy ( Path . Combine ( RunStartTests . FindRepoRoot ( ) , "GameData" , "Aetheria.cc" ) , path ) ;
using ( var cache = new CultCache ( TestCatalog . Registry ( ) ) )
{
cache . AddBackingStore ( new GameCult . Caching . MessagePack . SingleFileMessagePackBackingStore ( path ) , AetheriaStores . CatalogTypes ) ;
foreach ( var faction in cache . GetAll < Faction > ( ) . ToArray ( ) )
{
faction . Places = Array . Empty < FactionPlace > ( ) ;
cache . Commit ( batch = > batch . Upsert ( typeof ( Faction ) , faction , cache . RefOf ( faction ) . Key ) ) ;
}
cache . FlushAsync ( ) . Wait ( ) ;
}
_scratch . Add ( root ) ;
return root ;
}
private readonly List < string > _scratch = new ( ) ;
private static string CatalogOf ( string root ) = > Path . Combine ( root , "GameData" , "Aetheria.cc" ) ;
[Fact]
public void TheCommandIsIdempotentAndRefusesAMismatch ( )
{
var root = ScratchWithoutLists ( ) ;
var before = File . ReadAllBytes ( CatalogOf ( root ) ) ;
Assert . Equal ( 0 , PlaceNamesCommand . Run ( new string [ 0 ] , root ) ) ;
Assert . Equal ( before , File . ReadAllBytes ( CatalogOf ( root ) ) ) ;
Assert . Equal ( 0 , PlaceNamesCommand . Run ( new [ ] { "apply" } , root ) ) ;
var landed = File . ReadAllBytes ( CatalogOf ( root ) ) ;
Assert . NotEqual ( before , landed ) ;
var db = AetherDb . Open ( root : root ) ;
try
{
foreach ( var row in PlaceNamesCommand . Table )
Assert . Equal ( row . Names , db . Cache . GetAll < Faction > ( ) . Single ( f = > db . Cache . RefOf ( f ) . Key . Value = = row . RecordKey ) . Places . Select ( p = > p . Name ) ) ;
}
finally { db . Cache . Dispose ( ) ; }
Assert . Equal ( 0 , PlaceNamesCommand . Run ( new [ ] { "apply" } , root ) ) ;
Assert . Equal ( landed , File . ReadAllBytes ( CatalogOf ( root ) ) ) ;
// Aya's ShortName changed under its key: identity is the key, so the row no longer describes that faction.
var squatter = AetherDb . Open ( catalogWritable : true , root : root ) ;
var aya = squatter . Cache . GetAll < Faction > ( ) . Single ( f = > f . ShortName = = "Aya" ) ;
aya . ShortName = "Ayx" ;
squatter . Cache . Commit ( batch = > batch . Upsert ( typeof ( Faction ) , aya , squatter . Cache . RefOf ( aya ) . Key ) ) ;
squatter . Cache . Dispose ( ) ;
var changed = File . ReadAllBytes ( CatalogOf ( root ) ) ;
var refusal = Assert . Throws < InvalidOperationException > ( ( ) = > PlaceNamesCommand . Run ( new [ ] { "apply" } , root ) ) ;
Assert . DoesNotContain ( "Ayx" , refusal . Message ) ;
Assert . Equal ( changed , File . ReadAllBytes ( CatalogOf ( root ) ) ) ;
}
2026-10-17 22:14:26 +02:00
[Fact]
public void ApplyRestoresAnEditedListOfTheSameLength ( )
{
var root = ScratchWithoutLists ( ) ;
Assert . Equal ( 0 , PlaceNamesCommand . Run ( new [ ] { "apply" } , root ) ) ;
var row = PlaceNamesCommand . Table . First ( ) ;
var edited = AetherDb . Open ( catalogWritable : true , root : root ) ;
var faction = edited . Cache . GetAll < Faction > ( ) . Single ( f = > edited . Cache . RefOf ( f ) . Key . Value = = row . RecordKey ) ;
faction . Places = row . Names . Reverse ( ) . Select ( n = > new FactionPlace { Name = n } ) . ToArray ( ) ;
edited . Cache . Commit ( batch = > batch . Upsert ( typeof ( Faction ) , faction , edited . Cache . RefOf ( faction ) . Key ) ) ;
edited . Cache . Dispose ( ) ;
Assert . Equal ( 0 , PlaceNamesCommand . Run ( new [ ] { "apply" } , root ) ) ;
var db = AetherDb . Open ( root : root ) ;
try { Assert . Equal ( row . Names , db . Cache . GetAll < Faction > ( ) . Single ( f = > db . Cache . RefOf ( f ) . Key . Value = = row . RecordKey ) . Places . Select ( p = > p . Name ) ) ; }
finally { db . Cache . Dispose ( ) ; }
}
2026-10-17 19:10:17 +02:00
[Theory]
[InlineData("EAC-12")]
2026-10-17 22:14:26 +02:00
[InlineData("eac-12")]
2026-10-17 19:10:17 +02:00
[InlineData(" Padded")]
[InlineData("")]
[InlineData("shunya")]
public void ATableWithABadNameIsRefusedWithNothingWritten ( string bad )
{
var root = ScratchWithoutLists ( ) ;
var before = File . ReadAllBytes ( CatalogOf ( root ) ) ;
var table = PlaceNamesCommand . Table . Select ( ( row , i ) = > i = = 1 ? row with { List = row . List + " | " + bad } : row ) . ToArray ( ) ;
var refusal = Assert . Throws < InvalidOperationException > ( ( ) = > PlaceNamesCommand . Run ( new [ ] { "apply" } , root , table ) ) ;
if ( bad . Length ! = 0 ) Assert . DoesNotContain ( bad , refusal . Message ) ;
Assert . Equal ( before , File . ReadAllBytes ( CatalogOf ( root ) ) ) ;
}
[Fact]
public void ARowForAKeyTheCatalogDoesNotHoldIsRefusedWithNothingWritten ( )
{
var root = ScratchWithoutLists ( ) ;
var before = File . ReadAllBytes ( CatalogOf ( root ) ) ;
var table = PlaceNamesCommand . Table . Append ( new PlaceNamesCommand . Row ( "00000000-0000-0000-0000-000000000000" , "Nobody" , "Elsewhere" ) ) . ToArray ( ) ;
Assert . Throws < InvalidOperationException > ( ( ) = > PlaceNamesCommand . Run ( new [ ] { "apply" } , root , table ) ) ;
Assert . Equal ( before , File . ReadAllBytes ( CatalogOf ( root ) ) ) ;
}
}