diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs index c1e69fcca..fb826cf8e 100644 --- a/Assets/Scripts/ServerShared/Corporations.cs +++ b/Assets/Scripts/ServerShared/Corporations.cs @@ -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(); +} + +// 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] diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 7026d8a52..41497ad68 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -34,7 +34,6 @@ public class Galaxy public uint Seed { get; } private GalaxyZone[] _exitPath; - private Dictionary _nameGenerators = new Dictionary(); 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 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 progressCallback = null) { - var used = new HashSet(); - for (var i = 0; i < Factions.Length; i++) + progressCallback?.Invoke("Naming places"); + var used = new HashSet(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()).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; } } diff --git a/tests/Aetheria.Shared.Tests/PlaceNamesTests.cs b/tests/Aetheria.Shared.Tests/PlaceNamesTests.cs new file mode 100644 index 000000000..c2d910a25 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PlaceNamesTests.cs @@ -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 _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"), Authored.Read("TutorialBackgroundSettings"), + Authored.Read("NameGeneratorSettings"), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed) + : new Galaxy(Authored.Read("SectorGenerationSettings"), Authored.Read("SectorBackgroundSettings"), + Authored.Read("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 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().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().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().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().ToArray()) + { + faction.Places = Array.Empty(); + cache.Commit(batch => batch.Upsert(typeof(Faction), faction, cache.RefOf(faction).Key)); + } + cache.FlushAsync().Wait(); + } + _scratch.Add(root); + return root; + } + + private readonly List _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().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().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(() => 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(() => 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(() => PlaceNamesCommand.Run(new[] { "apply" }, root, table)); + Assert.Equal(before, File.ReadAllBytes(CatalogOf(root))); + } +} diff --git a/tools/AetherDb/PlaceNamesCommand.cs b/tools/AetherDb/PlaceNamesCommand.cs new file mode 100644 index 000000000..7c08e4150 --- /dev/null +++ b/tools/AetherDb/PlaceNamesCommand.cs @@ -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().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(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().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; + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 9d4fe5251..8c00d3e53 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -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 ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], 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 ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], 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; } }