Place names: Faction.Places, Galaxy.NamePlaces and the place-names command

Each faction names the zones it owns from its own curated list (ruling
faction-curated-place-names): the home zone takes Places[0], the rest follow
in distance order, a spent or absent list falls back to unique catalogue
designations. Galaxy.NamePlaces replaces the Markov GenerateNames body. The
place-names command authors the 15-row table and, until commit 3, purges the
NameFile records and GeonameFile links while their type still exists.

The shipped catalog carries no Places yet (commit 2), so the tests that read
shipped lists fail at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-17 19:10:17 +02:00
commit 740e1ee781
5 changed files with 435 additions and 24 deletions

View file

@ -63,6 +63,19 @@ public class Faction
// The faction's rules of engagement; null fights as FactionDoctrine.Default.
[Inspectable, JsonProperty("doctrine"), Key(17)]
public FactionDoctrine Doctrine;
// Curated place names in authored order; the home zone takes the first (ruling faction-curated-place-names).
// Authored by AetherDb place-names, which restores the table on every apply.
[Inspectable, JsonProperty("places"), Key(21)]
public FactionPlace[] Places = Array.Empty<FactionPlace>();
}
// Historical, narrative and strategic intent join as keys 2+ when economics-driven placement lands (ruling faction-curated-place-names).
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class FactionPlace
{
[Inspectable, JsonProperty("name"), Key(1)]
public string Name;
}
[CultDocument("aetheria.namefile", "1"), Inspectable, MessagePackObject]

View file

@ -34,7 +34,6 @@ public class Galaxy
public uint Seed { get; }
private GalaxyZone[] _exitPath;
private Dictionary<Faction, MarkovNameGenerator> _nameGenerators = new Dictionary<Faction, MarkovNameGenerator>();
private readonly CultCache _cache;
public GalaxyZone[] ExitPath
@ -131,7 +130,7 @@ public class Galaxy
CalculateFactionInfluence(progressCallback);
GenerateNames(cache, nameGeneratorSettings, ref random, progressCallback);
NamePlaces(ref random, progressCallback);
progressCallback?.Invoke("Done!");
if(progressCallback!=null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
@ -259,7 +258,7 @@ public class Galaxy
CalculateFactionInfluence(progressCallback);
GenerateNames(cache, nameGeneratorSettings, ref random, progressCallback);
NamePlaces(ref random, progressCallback);
// progressCallback?.Invoke("Weaving Narrative");
// var processor = new StoryProcessor(playerSettings, narrativeDirectory, this, ref random, Log);
@ -350,31 +349,37 @@ public class Galaxy
}
}
private void GenerateNames(CultCache cache,
NameGeneratorSettings nameGeneratorSettings,
ref Random random,
Action<string> progressCallback = null)
// Each faction names the zones it owns from its own curated list (ruling faction-curated-place-names): nearest its
// home first, so the home zone takes the capital, which is Places[0]. A zone left over when a list is spent, and every
// zone nobody owns, takes a catalogue designation. A name is never used twice, ignoring case.
internal void NamePlaces(ref Random random, Action<string> progressCallback = null)
{
var used = new HashSet<string>();
for (var i = 0; i < Factions.Length; i++)
progressCallback?.Invoke("Naming places");
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var zone in Zones) zone.Name = null;
foreach (var faction in Factions)
{
progressCallback?.Invoke($"Feeding Markov Chains: {i + 1} / {Factions.Length}");
//if(progressCallback!=null) Thread.Sleep(250); // Inserting Delay to make it seem like it's doing more work lmao
var faction = Factions[i];
_nameGenerators[faction] = new MarkovNameGenerator(ref random, cache.Get(faction.GeonameFile).Names, nameGeneratorSettings, used);
if (!HomeZones.TryGetValue(faction, out var home)) continue;
var places = (faction.Places ?? Array.Empty<FactionPlace>()).Select(p => p?.Name).Where(n => !string.IsNullOrEmpty(n)).ToArray();
var next = 0;
var owned = Zones.Select((zone, index) => (zone, index)).Where(z => z.zone.Owner == faction)
.OrderBy(z => z.zone.Distance[home]).ThenBy(z => z.index);
foreach (var (zone, _) in owned)
{
while (next < places.Length && used.Contains(places[next])) next++;
if (next == places.Length) break;
zone.Name = places[next];
used.Add(places[next++]);
}
}
// Generate zone name using the owner's name generator, otherwise assign catalogue ID
foreach (var zone in Zones)
foreach (var zone in Zones.Where(z => z.Name == null))
{
if (zone.Owner != null)
{
zone.Name = _nameGenerators[zone.Owner].NextName.Trim();
}
else
{
zone.Name = $"EAC-{random.NextInt(9999).ToString()}";
}
string designation;
do designation = $"EAC-{random.NextInt(9999)}";
while (!used.Add(designation));
zone.Name = designation;
}
}

View file

@ -0,0 +1,257 @@
/* 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"),
Authored.Read<NameGeneratorSettings>("NameGeneratorSettings"), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed)
: new Galaxy(Authored.Read<SectorGenerationSettings>("SectorGenerationSettings"), Authored.Read<SectorBackgroundSettings>("SectorBackgroundSettings"),
Authored.Read<NameGeneratorSettings>("NameGeneratorSettings"), cache, _ => { }, null, seed);
// 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();
[Fact]
public void TheHomeZoneTakesTheFirstName()
{
var checkedHomes = 0;
foreach (var galaxy in Galaxies(true, 10).Concat(Galaxies(false, 10)))
foreach (var faction in Placed(galaxy))
{
Assert.Equal(faction.Places[0].Name, galaxy.HomeZones[faction].Name);
checkedHomes++;
}
Assert.True(checkedHomes >= 20, $"only {checkedHomes} homes were checked");
}
[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));
Assert.All(owned.Skip(listed), zone => Assert.Matches(Designation, zone.Name));
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)))
{
Assert.Same(curated[zone.Name], zone.Owner);
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));
Assert.Same(owned[0], galaxy.HomeZones[faction]);
Assert.All(owned.Skip(2), zone => Assert.Matches(Designation, zone.Name));
}
[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()
{
var terris = Shared.GetAll<Faction>().Single(f => f.ShortName == "Miss Terri's");
Assert.Empty(terris.Places);
var held = 0;
foreach (var galaxy in Galaxies(true, 10))
foreach (var zone in galaxy.Zones.Where(z => z.Owner == terris))
{
Assert.Matches(Designation, zone.Name);
held++;
}
Assert.True(held > 0, "Miss Terri's held no zone in the first ten prelude galaxies");
}
[Fact]
public void TheShippedListsAreTheTable()
{
var factions = Shared.GetAll<Faction>().ToArray();
foreach (var row in PlaceNamesCommand.Table)
{
var faction = Assert.Single(factions, f => Shared.RefOf(f).Key.Value == row.RecordKey);
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)));
}
[Theory]
[InlineData("EAC-12")]
[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)));
}
}

View file

@ -0,0 +1,133 @@
/* 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.Linq;
using System.Text;
using GameCult.Caching;
// place-names [apply] (aetheria-release, cut faction-curated-place-names; ruling faction-curated-place-names):
// authors every curated faction's Places from the table below, which is the one source of those names. A faction is
// identified by its record key, and its ShortName must agree (ghostlight-verse ruling faction-identity-is-record-key).
// A faction without a row keeps the catalogue designations Galaxy.NamePlaces falls back to (question miss-terris-place-names).
// Order is meaning: the first name is the faction's capital, and the home zone takes it. Dry run unless passed "apply".
public static class PlaceNamesCommand
{
// List holds the names separated by " | ", in authored order.
internal sealed record Row(string RecordKey, string ShortName, string List)
{
public string[] Names => List.Split(" | ");
}
internal static readonly Row[] Table =
{
new("bbd619ed-76c1-464c-829d-3f419cdc2525", "Adrasteia",
"Shunya | Unlisted | Tamas | Silent Address | Maya | The Warm Hour | Nishabda | The Vouch | Akasha | No Such Node | Chhaya | Heat Death Reach | Santulan | Cold Margin | Antara | Without Light | Nirvata | Redacted Deep | Quiet Reach | Last Known"),
new("8443f966-0d88-4ec7-a20e-8edf73fa62f6", "AU",
"Ceres Ramp | Pallas Provisional | Ramp Two | Claimshare | Vesta Works | Hygiea | Bloom Seven | Half Measure | Further Out | Semi-Permanent | Under Construction | Drawing Board | Survey Pending | Ceres Control | Margin of Error | Proof of Concept | Ramp Nine | Good Enough | Ambition | Not Yet Named"),
new("a1f76631-d6d1-443a-9be1-c9b1267d6ca2", "Alakrita",
"Aurea | Celeritas | Elegantia | Via Alakrita | Fatum | Quiet Bay | Laurea | Gloria | Prima Linea | Destiny Yard | Aequor | Corona | Vela | Lumen | Nobilis | Portus Aureus | Inscription | Tessera | Proportio | Decus"),
new("48bbb7537ff9ed0a73c3c353598e8f69", "Aya",
"Hellas | Sanctuary | Common Kitchen | Open Courtyard | Enceladus Commons | Mutual | Assembly Hall | Repair Circle | Refuge Route | Not Inevitable | Free Clinic | Shared Table | Drawings Included | All Welcome | Congress | Keep It Running | Terracotta | Open Door | Freehold Garden | Green Shade"),
// DME's voice is lowercase on purpose.
new("8419f9bc-0167-46ec-86c3-3265d2905fbc", "DME",
"the choir loft | encore | burn it down | turn it up | void communion | last call | margin poem | basement tape | loud room | second encore | feedback | no permit | the pit | spare amp | open mic | after hours | smoke machine | b-side | green room | yara's version"),
new("a9672650-c335-484a-9555-71a35c841aa1", "Ewan Hart",
"Homestead | Second Harvest | Convoy's End | Invented Spring | Grange Hall | Long Furrow | Silo Nine | Good Dog | Feed and Seed | False October | County Line | Still Growing | Tractor Pull | Prairie Bloom | Rain Check | Elevator | Hayloft | Late Frost | Sealed Summer | Old Blue"),
new("1dfaa629-6c63-42a7-91e1-f1ae90f92f09", "Finch",
"Tycho | Serenity Ward | Tranquility | Far Side | Nectaris Clinic | Grace | Crisium | Mireille | Copernicus | Imbrium | Full Coverage | Aitken | Fecunditas | Vaporum | Recovery Ring | Clavius | Earthrise | Second Opinion | Frigoris | Plato"),
new("2f74e6b4-d268-48a9-b2a8-24591c680e84", "Lightsail",
"Mile Zero | Ganymede Stop | Last Anchorage | Dispatch | The Drop Book | Truck Stop | Route Choir | On Time | Callisto Crossing | Haul Road | Weigh Station | Long Haul | Bill of Lading | Overnight | Deadhead | Jake Brake | Signed For | The Line | Breaker Nineteen | Final Mile"),
new("a012e0f4-4b22-47b7-8b24-ff3e1439c949", "Lucent",
"Ishtar Studios | Prime Time | Maxwell Heights | Cloud Nine Live | Channel Aphrodite | Clout | Buzz Market | Ratings Tower | Lagrange Feed | Spotlight | Exclusive | Trending | Broadcast Row | Red Carpet | Season Finale | Sponsored | Headliner | Viral | Golden Hour | Breaking News"),
new("087df3f89e59beab14f9d41c849b202f", "Megiddo",
"First Vigil | Earth Stars | Continuity | The Long Silence | Deferred Ground | Unverified | Boundary Watch | Custody | Not Our World | Pleiades Watch | Second Vault | Presumption | Under Orion | Night Watch | Witness | Flatland Edge | Old Sky | Record Keeper | Patience | Revelation Pending"),
new("73d368dd-58a8-4431-b9ba-2d44754d36a1", "NiteLife",
"Caloris Array | Arete Grid | Malam | Cahaya | Graveyard Shift | Lights On | Bintang | Night Count | Pelita | Uptime Pro | Bulan | Grid Plus | Kilat | Afterglow | Load Balance | Peak Hours | Terang | Version Two | Midnight Blue | Keep On"),
new("e34d0354f46873df7ec27273fa2abe3d", "Forge",
"Hollow Pallas | Cinderlace | Stubborn | Open Drawing | Pattern Yard | Bored Through | Slagside | Tolerance | Free Standard | Lathe | Anvil Rock | Copied Twice | Mandrel | Inner Shell | Quench | Forge Light | Billet | Rivet | Blueprint | Worlds Enough"),
// The two emoji are U+1F3F4 and U+1F99C (ruling pirates-record-content).
new("1d8a168def994a76a1ce7cb18f59c038", "Pirates",
"Trojan Nest | Genuine Rescue | Ring Shadow | Patched Cavity | \U0001F3F4 Nobody's | Repainted | Finders Keepers | Hidden Dock | Free Parking | Legit Waystation | New Management | No Questions | Second Hand | False Beacon | Salvage Rights | The Fence | Elegance. Ours. | \U0001F99C Roost | Maintenance Due | Not A Base"),
new("adfb9b08-aabc-45c6-9586-915698a9c4b0", "R&D",
"Prüfstand | Findings | Recertification | Configuration B | Asterisk | Freigabe | Friendly | Sicherheit | Compliance | Revision C | Normal Operation | Abnahme | Test Bench Four | Fine Print | Hold Music | Reassurance | Warranty | Grundlage | Standard Procedure | Kinderleicht"),
new("16bad4f9-7fb5-41c5-8da0-dc76709eb7d3", "Zhestokost",
"Cimmeria | Tharsis Bastion | Olimp | Molot | Valles Forward | Arsenal Commission | Eternal Council | Moneta | Kuznya | Overkill | Assignment Board | Stal | Krepost | Gromada | Campaign | Frontline | Beton | Plamya | Zhelezo | Pobeda"),
};
public static int Run(string[] args, string root = null) => Run(args, root, Table);
// The table is a parameter so a test can hand it a malformed one; production always passes Table.
internal static int Run(string[] args, string root, Row[] table)
{
var apply = args.Contains("apply");
var db = AetherDb.Open(catalogWritable: apply, root: root);
var factions = db.Cache.GetAll<Faction>().ToArray();
var keyOf = factions.ToDictionary(f => db.Cache.RefOf(f).Key.Value, StringComparer.Ordinal);
// Refuse before any write. A refusal never echoes the offending value.
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in table)
{
if (!keyOf.TryGetValue(row.RecordKey, out var faction))
throw new InvalidOperationException("Place names row: its key is not a faction in the catalog (missing).");
if (faction.ShortName != row.ShortName)
throw new InvalidOperationException("Place names row: its ShortName differs from the faction under its key (conflict).");
foreach (var name in row.Names)
{
if (string.IsNullOrEmpty(name) || name != name.Trim())
throw new InvalidOperationException("Place names row: a name is empty or untrimmed (invalid).");
if (name.StartsWith("EAC-", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Place names row: a name is a catalogue designation (invalid).");
if (!seen.Add(name))
throw new InvalidOperationException("Place names table: a name is repeated (conflict).");
}
}
var rows = table.ToDictionary(r => r.RecordKey, StringComparer.Ordinal);
var changed = table.Where(r => !keyOf[r.RecordKey].Places.Select(p => p.Name).SequenceEqual(r.Names)).ToArray();
var nameFiles = db.Cache.GetAll<NameFile>().ToArray();
var geoned = factions.Where(f => f.GeonameFile.IsSet()).ToArray();
foreach (var faction in factions.OrderBy(f => f.Name, StringComparer.Ordinal))
{
if (!rows.TryGetValue(db.Cache.RefOf(faction).Key.Value, out var row))
{
Console.WriteLine($"{faction.Name}: no list: catalogue designations");
continue;
}
Console.WriteLine($"{faction.Name}: {row.Names.Length} places, capital {row.Names[0]}, {(changed.Contains(row) ? "changed" : "unchanged")}");
}
if (nameFiles.Length != 0 || geoned.Length != 0)
Console.WriteLine($"Retired Markov data: {nameFiles.Length} name files, {geoned.Length} geoname links.");
if (changed.Length == 0 && nameFiles.Length == 0 && geoned.Length == 0)
{
Console.WriteLine("nothing to do");
return 0;
}
if (!apply)
{
Console.WriteLine($"\nDry run. Pass \"apply\" to author {changed.Length} place lists.");
return 0;
}
if (!db.Cache.Commit(batch =>
{
// Commit 1 only: the Markov data goes while its type still exists; commit 3 deletes the type and this leg.
foreach (var faction in factions.Where(f => geoned.Contains(f) || changed.Any(r => r.RecordKey == db.Cache.RefOf(f).Key.Value)))
{
if (rows.TryGetValue(db.Cache.RefOf(faction).Key.Value, out var row))
faction.Places = row.Names.Select(n => new FactionPlace { Name = n }).ToArray();
faction.GeonameFile = default;
batch.Upsert(typeof(Faction), faction, db.Cache.RefOf(faction).Key);
}
foreach (var file in nameFiles) batch.Remove(db.Cache.RefOf(file).Key);
}))
throw new InvalidOperationException("The catalog changed while place names were being authored; nothing was written. Run it again.");
Console.WriteLine($"\nAuthored {changed.Length} place lists in Aetheria.cc");
return 0;
}
}

View file

@ -57,9 +57,12 @@ public static class Program
case "inspection-scanner": return InspectionScannerCatalog(args.Contains("apply"));
case "product-lines": return ProductLinesCommand.Run(args.Skip(1).ToArray());
case "minor-power-factions": return MinorPowerFactionsCommand.Run(args.Skip(1).ToArray());
case "place-names":
Console.OutputEncoding = new System.Text.UTF8Encoding(false);
return PlaceNamesCommand.Run(args.Skip(1).ToArray());
case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out);
default:
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], product-lines weapons|minor-powers|gear [apply], doctrine-catalog [apply]");
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear [apply], doctrine-catalog [apply]");
return 1;
}
}