From ac0b39f84e2313d204cbce30d2649c13206957f4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 15:54:31 +0200 Subject: [PATCH 01/10] minor-power-factions command, MakerProfiles rows, Pirates allegiance top-up (ported from 57083412) MinorPowerFactionsCommand authors Aya Collective, Megiddo and Orbital Forge as ordinary factions plus the gains on other factions' Allegiance. pirates-faction on an existing record now tops up allegiance under its key. Unbuilt here; verified at the head on Yggdrasil. Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/MinorPowerFactionsCommand.cs | 122 ++++++++++++++++++++ tools/AetherDb/Program.cs | 31 ++++- 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 tools/AetherDb/MinorPowerFactionsCommand.cs diff --git a/tools/AetherDb/MinorPowerFactionsCommand.cs b/tools/AetherDb/MinorPowerFactionsCommand.cs new file mode 100644 index 000000000..fbe0a39b6 --- /dev/null +++ b/tools/AetherDb/MinorPowerFactionsCommand.cs @@ -0,0 +1,122 @@ +/* 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.Security.Cryptography; +using System.Text; +using CultMath; +using GameCult.Caching; + +// minor-power-factions [apply] (aetheria-release, cut minor-power-factions; rulings no-territory-ban, miss-terris-belongs-to-emily-r3): +// authors the Aya Collective, Megiddo and Orbital Forge as ordinary factions, and the allegiance every existing faction but the +// Pirates and Miss Terri's holds to them. The Pirates' allegiance is pirates-faction's alone (it tops itself up on a re-run). +// A faction already in the catalog under its name-derived key is skipped, as is a gain already held, so the same command is the +// first apply, the second apply and the replay onto a merged catalog. Dry run unless passed "apply". +public static class MinorPowerFactionsCommand +{ + // Own: the weights this faction names for the other minor powers; every other faction but the Pirates gets Default. + // Gain: the weight every existing faction (bar the Pirates and Miss Terri's) gains toward this faction. + // Geonames is the name file the faction draws place names from (question minor-power-geonames: the nearest existing file). + internal sealed record Def(string Name, string ShortName, string Description, float3 Primary, float3 Secondary, + string Geonames, int Influence, float Gain, float Default, (string ShortName, float Weight)[] Own); + + internal static readonly Def[] Factions = + { + new("Aya Collective", "Aya", + "A federation of communes, clinics and repair crews. Its equipment comes with the drawings, so the people it protects can keep it running.", + new float3(.72f, .36f, .22f), new float3(.12f, .36f, .25f), "Congo", 3, .3f, .3f, + new[] { ("Forge", 1f), ("Megiddo", .5f) }), + new("Megiddo", "Megiddo", + "A defended fleet that holds its boundary until the question is answered. Others buy its protection without sharing its faith.", + new float3(.07f, .09f, .15f), new float3(.90f, .85f, .74f), "pleiades", 4, .3f, .2f, + new[] { ("Aya", .5f), ("Forge", .5f) }), + new("Orbital Forge", "Forge", + "Belt engineers who hollow asteroids into homes and yards. Their standards leave room to adapt them, and their drawings travel further than their ships.", + new float3(.42f, .30f, .22f), new float3(.30f, .38f, .52f), "pleiades", 3, .5f, .5f, + Array.Empty<(string, float)>()), + }; + + private const string Pirates = "Pirates"; + private const string MissTerris = "Miss Terri's"; + + // A faction's record key is derived from its name, so a replay reproduces the same key and the same bytes. + internal static CultRecordKey KeyOf(Def def) => + new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("faction:" + def.Name)).AsSpan(0, 16)).ToString("N")); + + public static int Run(string[] args, string root = null) + { + var apply = args.Contains("apply"); + var db = AetherDb.Open(catalogWritable: apply, root: root); + var existing = db.Cache.GetAll().ToArray(); + var nameFiles = db.Cache.GetAll().ToArray(); + + // Refuse before any write: a name or short name held by a faction under another key, and a missing name file. + foreach (var def in Factions) + { + var key = KeyOf(def); + foreach (var other in existing.Where(f => f.Name == def.Name || f.ShortName == def.ShortName)) + if (!db.Cache.RefOf(other).Key.Equals(key)) + throw new InvalidOperationException("Faction Name or ShortName: held by a faction under another key (conflict)."); + if (nameFiles.Count(n => n.Name == def.Geonames) != 1) + throw new InvalidOperationException("Faction GeonameFile: expected exactly one name file (missing)."); + } + + var minorKeys = Factions.ToDictionary(d => d.ShortName, KeyOf); + var created = Factions.Where(d => !existing.Any(f => db.Cache.RefOf(f).Key.Equals(KeyOf(d)))).ToArray(); + + // Every existing faction that is not a minor power, the Pirates or Miss Terri's holds each minor power it lacks. + var holders = existing.Where(f => f.ShortName != Pirates && f.ShortName != MissTerris && !minorKeys.ContainsKey(f.ShortName)).ToArray(); + var gains = holders + .Select(h => (Holder: h, Missing: Factions.Where(d => !h.Allegiance.ContainsKey(new CultRecordRef(minorKeys[d.ShortName]))).ToArray())) + .Where(g => g.Missing.Length != 0).ToArray(); + + if (created.Length == 0 && gains.Length == 0) + { + Console.WriteLine("The minor powers are already authored; nothing to do."); + return 0; + } + + foreach (var def in created) + Console.WriteLine($"New faction \"{def.Name}\" ({def.ShortName}): influence {def.Influence}, names \"{def.Geonames}\", colours ({def.Primary.x:0.00}, {def.Primary.y:0.00}, {def.Primary.z:0.00}) / ({def.Secondary.x:0.00}, {def.Secondary.y:0.00}, {def.Secondary.z:0.00})\n {def.Description}\n allegiance: {string.Join(", ", def.Own.Select(o => $"{o.ShortName} {o.Weight:0.##}"))} others {def.Default:0.##}"); + foreach (var (holder, missing) in gains) + Console.WriteLine($" {holder.ShortName} gains {string.Join(", ", missing.Select(d => $"{d.ShortName} {d.Gain:0.##}"))}"); + + if (!apply) + { + Console.WriteLine($"\nDry run. Pass \"apply\" to author {created.Length} factions and {gains.Sum(g => g.Missing.Length)} allegiance gains."); + return 0; + } + + // A new faction names every faction but the Pirates, the other minor powers included. + var everyone = existing.Where(f => f.ShortName != Pirates && !minorKeys.ContainsKey(f.ShortName)).Select(f => db.Cache.RefOf(f)).ToArray(); + if (!db.Cache.Commit(batch => + { + foreach (var def in created) batch.Expect(KeyOf(def), null); + foreach (var def in created) + { + var faction = new Faction + { + Name = def.Name, ShortName = def.ShortName, Description = def.Description, + PrimaryColor = def.Primary, SecondaryColor = def.Secondary, + GeonameFile = db.Cache.RefOf(nameFiles.Single(n => n.Name == def.Geonames)), InfluenceDistance = def.Influence, + }; + foreach (var reference in everyone) faction.Allegiance[reference] = def.Default; + foreach (var other in Factions.Where(o => o != def)) + faction.Allegiance[new CultRecordRef(minorKeys[other.ShortName])] = + def.Own.Where(o => o.ShortName == other.ShortName).Select(o => o.Weight).DefaultIfEmpty(def.Default).First(); + batch.Upsert(typeof(Faction), faction, KeyOf(def)); + } + foreach (var (holder, missing) in gains) + { + foreach (var def in missing) holder.Allegiance[new CultRecordRef(minorKeys[def.ShortName])] = def.Gain; + batch.Upsert(typeof(Faction), holder, db.Cache.RefOf(holder).Key); + } + })) + throw new InvalidOperationException("The catalog changed while the minor powers were being authored; nothing was written. Run it again."); + Console.WriteLine($"\nAuthored {created.Length} factions and {gains.Sum(g => g.Missing.Length)} allegiance gains in Aetheria.cc"); + return 0; + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 691fe3420..924fa1526 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -56,9 +56,10 @@ public static class Program case "generic-designs": return GenericDesigns(args.Contains("apply")); 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 "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], product-lines weapons [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], product-lines weapons|minor-powers [apply], doctrine-catalog [apply]"); return 1; } } @@ -258,6 +259,7 @@ public static class Program ["Zhestokost"] = (.50f, .08f), ["AU"] = (.50f, .12f), ["Lightsail"] = (.55f, .10f), ["R&D"] = (.60f, .15f), ["Lucent"] = (.60f, .15f), ["NiteLife"] = (.55f, .12f), ["Finch"] = (.65f, .20f), ["Alakrita"] = (.60f, .14f), ["DME"] = (.55f, .20f), ["Adrasteia"] = (.60f, .10f), + ["Aya"] = (.50f, .08f), ["Megiddo"] = (.75f, .05f), ["Forge"] = (.45f, .15f), }; private static readonly (float Mean, float Dev) PirateProfile = (.45f, .22f); @@ -2259,6 +2261,11 @@ public static class Program // only, written here as \U escapes so no editor or shell mangles them; the dry run prints them raw and as code points. // // Dry run unless passed "apply", same contract as the other *-migrate commands. + // The factions the Pirates do not yet hold allegiance to: every faction but themselves and those already named. The one + // rule of both the first apply and the top-up, so they cannot diverge. + private static Faction[] AllegianceGaps(AetherDb db, Faction pirates, IEnumerable factions) => + factions.Where(f => !ReferenceEquals(f, pirates) && !pirates.Allegiance.ContainsKey(db.Cache.RefOf(f))).ToArray(); + public static int PiratesFaction(bool apply, string root = null) { var db = AetherDb.Open(catalogWritable: apply, root: root); @@ -2266,7 +2273,25 @@ public static class Program var existing = db.Cache.GetAll().FirstOrDefault(f => f.Name == "Pirates"); if (existing != null) { - Console.WriteLine("A faction named \"Pirates\" already exists; nothing to do."); + // A Pirates record already in the catalog is topped up, never rewritten: it gains allegiance 1.0 to each faction added + // since, under its existing key, and nothing else changes. + var gaps = AllegianceGaps(db, existing, db.Cache.GetAll()); + if (gaps.Length == 0) + { + Console.WriteLine("The Pirates' allegiance already covers every faction; nothing to do."); + return 0; + } + Console.WriteLine($"The Pirates lack allegiance to {gaps.Length} faction(s): {string.Join(", ", gaps.Select(f => f.Name))}."); + if (!apply) + { + Console.WriteLine(" +Dry run. Pass \"apply\" to give them allegiance 1.0 to each."); + return 0; + } + foreach (var gap in gaps) existing.Allegiance[db.Cache.RefOf(gap)] = 1f; + db.Cache.Commit(batch => batch.Upsert(typeof(Faction), existing, db.Cache.RefOf(existing).Key)); + Console.WriteLine($" +Gave the Pirates allegiance 1.0 to {gaps.Length} faction(s) in Aetheria.cc"); return 0; } @@ -2293,7 +2318,7 @@ public static class Program GeonameFile = db.Cache.RefOf(nameFile), InfluenceDistance = 3, }; - foreach (var other in others) pirates.Allegiance[db.Cache.RefOf(other)] = 1f; + foreach (var other in AllegianceGaps(db, pirates, others)) pirates.Allegiance[db.Cache.RefOf(other)] = 1f; var rebrands = new (string Design, string Name, string Description)[] { From fbd8ab091514f7b240f45885d4e9ebe2cee48047 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 15:54:34 +0200 Subject: [PATCH 02/10] product-lines minor-powers: ten products of Aya, Megiddo and Forge Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/ProductLinesCommand.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs index d0fd7ce41..ccb69f7ae 100644 --- a/tools/AetherDb/ProductLinesCommand.cs +++ b/tools/AetherDb/ProductLinesCommand.cs @@ -44,8 +44,23 @@ public static class ProductLinesCommand new("Mine Launcher", "Tripwire", "Zhestokost", "Closes the road behind the column."), }; + // The Aya Collective, Megiddo and Orbital Forge (cut minor-power-factions): ordinary makers, applied after minor-power-factions. + private static readonly Row[] MinorPowers = + { + new("Point Defense Gun", "Common Guard", "Aya", "Standard mount, removable feed box, a barrel you can swap at the bench. Drawings included."), + new("Point Defense Laser", "Porch Light", "Aya", "Keeps the dark off the doorstep. The manual is longer than the warranty, on purpose."), + new("Flak Gun", "Canopy", "Aya", "Shade for the whole convoy. Every part replaceable by the people it covers."), + new("Point Defense Gun", "Watchman", "Megiddo", "Holds the line it is given. Every round accounted for."), + new("Point Defense Laser", "Vigil", "Megiddo", "It does not sleep while the boundary stands."), + new("Flak Gun", "Perimeter", "Megiddo", "Nothing crosses that we have not seen."), + new("Light Machine Gun", "Pattern 12", "Forge", "Drawings ship with the gun. Build the next one yourself."), + new("Autocannon", "Pattern 30", "Forge", "Standard bore, standard feed, standard anywhere a Belt yard can cut steel."), + new("Slug Rifle", "Pattern 6", "Forge", "Licensed or not, it fits the same mount."), + new("Point Defense Gun", "Pattern 2", "Forge", "Close-in defence to an open standard. No license check, ever."), + }; + // Every line the command knows, by the word that follows "product-lines". - private static readonly Dictionary Lines = new() { ["weapons"] = Weapons }; + private static readonly Dictionary Lines = new() { ["weapons"] = Weapons, ["minor-powers"] = MinorPowers }; // A line's rows, for the catalog tests that check the shipped records against the table the command authors from. internal static IReadOnlyList Table(string line) => Lines[line]; From 7e857cd6e5be7dd1151af0864153d9650c0fe8e7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 15:58:49 +0200 Subject: [PATCH 03/10] Fix the Pirates top-up's two string literals (ac0b39f8 does not build) Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 924fa1526..08ae41cb9 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -2284,14 +2284,12 @@ public static class Program Console.WriteLine($"The Pirates lack allegiance to {gaps.Length} faction(s): {string.Join(", ", gaps.Select(f => f.Name))}."); if (!apply) { - Console.WriteLine(" -Dry run. Pass \"apply\" to give them allegiance 1.0 to each."); + Console.WriteLine("\nDry run. Pass \"apply\" to give them allegiance 1.0 to each."); return 0; } foreach (var gap in gaps) existing.Allegiance[db.Cache.RefOf(gap)] = 1f; db.Cache.Commit(batch => batch.Upsert(typeof(Faction), existing, db.Cache.RefOf(existing).Key)); - Console.WriteLine($" -Gave the Pirates allegiance 1.0 to {gaps.Length} faction(s) in Aetheria.cc"); + Console.WriteLine($"\nGave the Pirates allegiance 1.0 to {gaps.Length} faction(s) in Aetheria.cc"); return 0; } From f54dada78694b764d05a6ff3f186f367213063f6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 15:59:01 +0200 Subject: [PATCH 04/10] Tests: minor powers are ordinary factions, command idempotence and conflict, Pirates top-up, shipped minor-powers rows These fail until the catalog commit lands. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MinorPowersTests.cs | 138 ++++++++++++++++++ .../PiratesFactionCommandTests.cs | 39 ++++- .../ProductLinesCatalogTests.cs | 10 +- 3 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/MinorPowersTests.cs diff --git a/tests/Aetheria.Shared.Tests/MinorPowersTests.cs b/tests/Aetheria.Shared.Tests/MinorPowersTests.cs new file mode 100644 index 000000000..8990ae9fa --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MinorPowersTests.cs @@ -0,0 +1,138 @@ +/* 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.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// The Aya Collective, Megiddo and Orbital Forge (aetheria-release, cut minor-power-factions; ruling no-territory-ban): three +// ordinary factions in the shipped catalog, authored by AetherDb's minor-power-factions command, which a scratch copy of the +// catalog without them proves idempotent and conflict-refusing. +public sealed class MinorPowersTests : IDisposable +{ + private static readonly string[] Minor = { "Aya", "Megiddo", "Forge" }; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "minor-powers-" + Guid.NewGuid().ToString("N")); + private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + + public void Dispose() + { + if (Directory.Exists(_root)) Directory.Delete(_root, true); + } + + // A scratch copy of the shipped catalog with the three, their products and every allegiance toward them removed, through a + // registry scoped to the shipped assembly (as PiratesFactionCommandTests does), so the command has all of it to author. + private void StripTheMinorPowers() + { + Directory.CreateDirectory(Path.Combine(_root, "GameData")); + File.Copy(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), CatalogPath); + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes); + var gone = cache.GetAll().Where(f => Minor.Contains(f.ShortName)).Select(f => cache.RefOf(f)).ToArray(); + Assert.Equal(3, gone.Length); + foreach (var product in cache.GetAll().Where(p => gone.Any(g => g.Key.Equals(p.Manufacturer.Key))).ToArray()) + Assert.True(cache.Remove(cache.RefOf(product).Key)); + foreach (var reference in gone) Assert.True(cache.Remove(reference.Key)); + foreach (var faction in cache.GetAll()) + foreach (var key in faction.Allegiance.Keys.Where(k => gone.Any(g => g.Key.Equals(k.Key))).ToArray()) + faction.Allegiance.Remove(key); + cache.Commit(batch => + { + foreach (var faction in cache.GetAll()) batch.Upsert(typeof(Faction), faction, cache.RefOf(faction).Key); + }); + cache.FlushAsync().Wait(); + } + + [Fact] + public void TheMinorPowersAreOrdinaryFactions() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var factions = cache.GetAll().ToArray(); + foreach (var shortName in Minor) + { + var faction = Assert.Single(factions, f => f.ShortName == shortName); + Assert.NotNull(cache.Get(faction.GeonameFile)); + Assert.True(faction.InfluenceDistance > 0, shortName); + var named = faction.Allegiance.Keys.Select(k => k.Key).ToArray(); + var expected = factions.Where(f => f != faction && f.ShortName != "Pirates").Select(f => cache.RefOf(f).Key).ToArray(); + Assert.Equal(expected.Length, named.Length); + Assert.All(expected, key => Assert.Contains(named, n => n.Equals(key))); + } + // Territory is nothing a faction can say of itself: no member of Faction marks a maker-only or landless power. + Assert.DoesNotContain(typeof(Faction).GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic), + m => m.Name.Contains("ManufacturerOnly") || m.Name.Contains("Territory")); + } + + [Fact] + public void TheCommandIsIdempotentAndRefusesAConflict() + { + StripTheMinorPowers(); + var before = File.ReadAllBytes(CatalogPath); + Assert.Equal(0, MinorPowerFactionsCommand.Run(new string[0], _root)); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + + Assert.Equal(0, MinorPowerFactionsCommand.Run(new[] { "apply" }, _root)); + var landed = File.ReadAllBytes(CatalogPath); + Assert.NotEqual(before, landed); + var db = AetherDb.Open(root: _root); + try + { + var factions = db.Cache.GetAll().ToArray(); + foreach (var shortName in Minor) Assert.Single(factions, f => f.ShortName == shortName); + var forge = db.Cache.RefOf(factions.Single(f => f.ShortName == "Forge")); + Assert.Equal(.5f, factions.Single(f => f.ShortName == "AU").Allegiance[forge]); + // The Pirates' allegiance is pirates-faction's; Miss Terri's is her own. + Assert.All(factions.Where(f => f.ShortName is "Pirates" or "Miss Terri's"), f => Assert.DoesNotContain(f.Allegiance.Keys, k => k.Equals(forge))); + } + finally { db.Cache.Dispose(); } + + Assert.Equal(0, MinorPowerFactionsCommand.Run(new[] { "apply" }, _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + } + + [Fact] + public void ANameHeldUnderAnotherKeyIsRefusedWithNothingWritten() + { + StripTheMinorPowers(); + var squatter = AetherDb.Open(catalogWritable: true, root: _root); + squatter.Cache.Commit(batch => batch.Upsert(typeof(Faction), new Faction { Name = "Megiddo", ShortName = "Megiddo" })); + squatter.Cache.Dispose(); + var before = File.ReadAllBytes(CatalogPath); + Assert.Throws(() => MinorPowerFactionsCommand.Run(new[] { "apply" }, _root)); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + } +} + +public sealed partial class RunStartTests +{ + // Forge holds no zone in the prelude, so its gear reaches AU only through allegiance (ruling gear-without-presence); a + // design-only slot draws among the makers AU can reach (ruling design-only-weighted-draw). Miss Terri's names none of them. + [Fact] + public void TheirGearSellsWhereTheyAreAbsent() + { + var factions = _cache.GetAll().ToArray(); + var forge = factions.Single(f => f.ShortName == "Forge"); + Assert.DoesNotContain(_galaxy.Factions, f => f == forge); + var autocannon = _cache.GetAll().Single(d => d.Name == "Autocannon"); + var pattern30 = _cache.GetAll().Single(p => p.Name == "Pattern 30"); + + LoadoutGenerator For(string shortName, uint seed) + { + _items.Random = new CultMath.Random(seed); + return new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, factions.Single(f => f.ShortName == shortName), .5f); + } + Assert.True(For("AU", 1).IsAvailable(pattern30)); + Assert.Contains(Enumerable.Range(1, 64), seed => For("AU", (uint) seed).ChooseProduct(autocannon)?.Name == pattern30.Name); + + var terri = For("Miss Terri's", 1); + var theirs = _cache.GetAll().Where(p => new[] { "Aya", "Megiddo", "Forge" }.Contains(_cache.Get(p.Manufacturer).ShortName)).ToArray(); + Assert.Equal(10, theirs.Length); + Assert.All(theirs, product => Assert.False(terri.IsAvailable(product), product.Name)); + } +} diff --git a/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs b/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs index 7ccce7cc8..67e91bde1 100644 --- a/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs @@ -105,17 +105,52 @@ public sealed class PiratesFactionCommandTests : IDisposable Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); } + // An existing hand-authored Pirates record is topped up, never rewritten (follow-up pirates-allegiance-and-identity): it keeps + // its description, short name and record key and gains no products; after a faction is added, apply gives allegiance 1.0 to + // that faction alone; a second apply leaves the file bytes unchanged and a dry run writes nothing. [Fact] - public void An_existing_Pirates_faction_is_left_as_it_is() + public void An_existing_Pirates_faction_is_topped_up_with_allegiance_and_nothing_else() { var db = AetherDb.Open(catalogWritable: true, root: _root); db.Cache.Commit(batch => batch.Upsert(typeof(Faction), new Faction { Name = "Pirates", ShortName = "Mine", Description = "authored by hand" })); + var key = db.Cache.RefOf(db.Cache.GetAll().Single(f => f.Name == "Pirates")).Key; db.Cache.Dispose(); + var beforeDry = File.ReadAllBytes(CatalogPath); + Assert.Equal(0, Program.PiratesFaction(apply: false, root: _root)); + Assert.Equal(beforeDry, File.ReadAllBytes(CatalogPath)); + Assert.Equal(0, Program.PiratesFaction(apply: true, root: _root)); var (pirates, products) = Read(); - Assert.Equal("authored by hand", Assert.Single(pirates).Description); + var record = Assert.Single(pirates); + Assert.Equal("authored by hand", record.Description); + Assert.Equal("Mine", record.ShortName); Assert.Empty(products); + var topped = File.ReadAllBytes(CatalogPath); + + var late = AetherDb.Open(catalogWritable: true, root: _root); + late.Cache.Commit(batch => batch.Upsert(typeof(Faction), new Faction { Name = "Latecomer", ShortName = "Late" })); + var lateRef = late.Cache.RefOf(late.Cache.GetAll().Single(f => f.Name == "Latecomer")); + var others = late.Cache.GetAll().Count(f => f.Name != "Pirates"); + late.Cache.Dispose(); + + Assert.Equal(0, Program.PiratesFaction(apply: true, root: _root)); + var check = AetherDb.Open(root: _root); + try + { + var after = check.Cache.GetAll().Single(f => f.Name == "Pirates"); + Assert.Equal(key, check.Cache.RefOf(after).Key); + Assert.Equal("authored by hand", after.Description); + Assert.Equal(others, after.Allegiance.Count); + Assert.Equal(1f, after.Allegiance[lateRef]); + Assert.All(after.Allegiance.Values, value => Assert.Equal(1f, value)); + } + finally { check.Cache.Dispose(); } + + var settled = File.ReadAllBytes(CatalogPath); + Assert.NotEqual(topped, settled); + Assert.Equal(0, Program.PiratesFaction(apply: true, root: _root)); + Assert.Equal(settled, File.ReadAllBytes(CatalogPath)); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs index 756e22355..0d5095da7 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs @@ -45,12 +45,14 @@ public sealed class ProductLinesCatalogTests Assert.Empty(shared); } - [Fact] - public void WeaponLineRowsAreShipped() + [Theory] + [InlineData("weapons", 20)] + [InlineData("minor-powers", 10)] + public void LineRowsAreShipped(string line, int rows) { using var cache = RestoredHullsTests.OpenCatalog(); - var table = ProductLinesCommand.Table("weapons"); - Assert.Equal(20, table.Count); + var table = ProductLinesCommand.Table(line); + Assert.Equal(rows, table.Count); foreach (var row in table) { var product = Assert.Single(cache.GetAll(), p => p.Name == row.Name); From d91fa1a6e91e3a9b1a7f57a6d6925bb49f9a7db5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 16:01:09 +0200 Subject: [PATCH 05/10] Catalog: minor-power-factions apply, pirates-faction apply, product-lines minor-powers apply (alone) Three factions, 33 allegiance gains, the Pirates topped up to 1.0, ten products; a second apply of all three writes nothing. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index ba5bd1466..38c9fca57 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:191864d6c0c3bb533015c3c036632a172bed34d432bfa0be79d8bdeb456ad18b -size 12745613 +oid sha256:e46402fd429cec5d1a3bd598f75737693c0907d91516700e21a2260ae62fa403 +size 12753329 From 1191aa1423cd3498cf26c27d82656e813e130af9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 16:02:14 +0200 Subject: [PATCH 06/10] Test: a minor power's own allegiance names every faction but the Pirates Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/MinorPowersTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/MinorPowersTests.cs b/tests/Aetheria.Shared.Tests/MinorPowersTests.cs index 8990ae9fa..89e77844b 100644 --- a/tests/Aetheria.Shared.Tests/MinorPowersTests.cs +++ b/tests/Aetheria.Shared.Tests/MinorPowersTests.cs @@ -85,6 +85,13 @@ public sealed class MinorPowersTests : IDisposable { var factions = db.Cache.GetAll().ToArray(); foreach (var shortName in Minor) Assert.Single(factions, f => f.ShortName == shortName); + foreach (var shortName in Minor) + { + var own = factions.Single(f => f.ShortName == shortName); + var expected = factions.Where(f => f != own && f.ShortName != "Pirates").Select(f => db.Cache.RefOf(f).Key).ToArray(); + Assert.Equal(expected.Length, own.Allegiance.Count); + Assert.All(expected, key => Assert.Contains(own.Allegiance.Keys, k => k.Key.Equals(key))); + } var forge = db.Cache.RefOf(factions.Single(f => f.ShortName == "Forge")); Assert.Equal(.5f, factions.Single(f => f.ShortName == "AU").Allegiance[forge]); // The Pirates' allegiance is pirates-faction's; Miss Terri's is her own. From 51fc64de31918ebef4d5081aa7ceeb544e942fa3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 16:22:01 +0200 Subject: [PATCH 07/10] Tests: Pirates allegiance count is the other factions' count, not 12; gear draw seeds spread by a hash Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/MinorPowersTests.cs | 3 ++- .../Aetheria.Shared.Tests/PiratesFactionCommandTests.cs | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MinorPowersTests.cs b/tests/Aetheria.Shared.Tests/MinorPowersTests.cs index 89e77844b..7416f08df 100644 --- a/tests/Aetheria.Shared.Tests/MinorPowersTests.cs +++ b/tests/Aetheria.Shared.Tests/MinorPowersTests.cs @@ -135,7 +135,8 @@ public sealed partial class RunStartTests return new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, factions.Single(f => f.ShortName == shortName), .5f); } Assert.True(For("AU", 1).IsAvailable(pattern30)); - Assert.Contains(Enumerable.Range(1, 64), seed => For("AU", (uint) seed).ChooseProduct(autocannon)?.Name == pattern30.Name); + // Seeds 1-64 spread by a multiplicative hash: the generator's first draws of adjacent small seeds are close together. + Assert.Contains(Enumerable.Range(1, 64), seed => For("AU", unchecked((uint) seed * 2654435761u)).ChooseProduct(autocannon)?.Name == pattern30.Name); var terri = For("Miss Terri's", 1); var theirs = _cache.GetAll().Where(p => new[] { "Aya", "Megiddo", "Forge" }.Contains(_cache.Get(p.Manufacturer).ShortName)).ToArray(); diff --git a/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs b/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs index 67e91bde1..0a3cb161d 100644 --- a/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs @@ -39,6 +39,13 @@ public sealed class PiratesFactionCommandTests : IDisposable public void Dispose() => Directory.Delete(_root, true); + private int AllFactionsButThePirates() + { + var db = AetherDb.Open(root: _root); + try { return db.Cache.GetAll().Count(f => f.Name != "Pirates"); } + finally { db.Cache.Dispose(); } + } + private (Faction[] Pirates, FactionProductData[] Products) Read() { var db = AetherDb.Open(root: _root); @@ -69,7 +76,7 @@ public sealed class PiratesFactionCommandTests : IDisposable Assert.Equal(3, faction.InfluenceDistance); Assert.Equal(new CultMath.float3(.42f, .40f, .37f), faction.PrimaryColor); Assert.Equal(new CultMath.float3(1f, .45f, .05f), faction.SecondaryColor); - Assert.Equal(12, faction.Allegiance.Count); + Assert.Equal(AllFactionsButThePirates(), faction.Allegiance.Count); Assert.All(faction.Allegiance.Values, value => Assert.Equal(1f, value)); var db = AetherDb.Open(root: _root); From 16eef88455115250fe677632f4194f05c8447707 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 16:22:40 +0200 Subject: [PATCH 08/10] Test: gear draw failure names the weights Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/MinorPowersTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/MinorPowersTests.cs b/tests/Aetheria.Shared.Tests/MinorPowersTests.cs index 7416f08df..ce41f62f5 100644 --- a/tests/Aetheria.Shared.Tests/MinorPowersTests.cs +++ b/tests/Aetheria.Shared.Tests/MinorPowersTests.cs @@ -136,7 +136,10 @@ public sealed partial class RunStartTests } Assert.True(For("AU", 1).IsAvailable(pattern30)); // Seeds 1-64 spread by a multiplicative hash: the generator's first draws of adjacent small seeds are close together. - Assert.Contains(Enumerable.Range(1, 64), seed => For("AU", unchecked((uint) seed * 2654435761u)).ChooseProduct(autocannon)?.Name == pattern30.Name); + var picks = Enumerable.Range(1, 64).Select(seed => For("AU", unchecked((uint) seed * 2654435761u)).ChooseProduct(autocannon)?.Name).ToArray(); + var gen = For("AU", 1); + var weights = string.Join(", ", _cache.GetAll().Where(p => p.Design.Key.Equals(_cache.RefOf(autocannon).Key) && gen.IsAvailable(p)).Select(p => $"{p.Name}={gen.ProductWeight(p)}")); + Assert.True(picks.Contains(pattern30.Name), $"picks {string.Join("|", picks.Distinct())}; weights {weights}"); var terri = For("Miss Terri's", 1); var theirs = _cache.GetAll().Where(p => new[] { "Aya", "Megiddo", "Forge" }.Contains(_cache.Get(p.Manufacturer).ShortName)).ToArray(); From cfba303afa938f72ffe4aeffa2a2fbc748e5f8a3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 16:23:48 +0200 Subject: [PATCH 09/10] Test: gear draw assertion back to Assert.Contains Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/MinorPowersTests.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MinorPowersTests.cs b/tests/Aetheria.Shared.Tests/MinorPowersTests.cs index ce41f62f5..7416f08df 100644 --- a/tests/Aetheria.Shared.Tests/MinorPowersTests.cs +++ b/tests/Aetheria.Shared.Tests/MinorPowersTests.cs @@ -136,10 +136,7 @@ public sealed partial class RunStartTests } Assert.True(For("AU", 1).IsAvailable(pattern30)); // Seeds 1-64 spread by a multiplicative hash: the generator's first draws of adjacent small seeds are close together. - var picks = Enumerable.Range(1, 64).Select(seed => For("AU", unchecked((uint) seed * 2654435761u)).ChooseProduct(autocannon)?.Name).ToArray(); - var gen = For("AU", 1); - var weights = string.Join(", ", _cache.GetAll().Where(p => p.Design.Key.Equals(_cache.RefOf(autocannon).Key) && gen.IsAvailable(p)).Select(p => $"{p.Name}={gen.ProductWeight(p)}")); - Assert.True(picks.Contains(pattern30.Name), $"picks {string.Join("|", picks.Distinct())}; weights {weights}"); + Assert.Contains(Enumerable.Range(1, 64), seed => For("AU", unchecked((uint) seed * 2654435761u)).ChooseProduct(autocannon)?.Name == pattern30.Name); var terri = For("Miss Terri's", 1); var theirs = _cache.GetAll().Where(p => new[] { "Aya", "Megiddo", "Forge" }.Contains(_cache.Get(p.Manufacturer).ShortName)).ToArray(); From 7d1106a907f5ffff1af978e67c1709b26c520c00 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 16:25:49 +0200 Subject: [PATCH 10/10] Stat-field resolution cache is concurrent: parallel catalog opens corrupted the shared Dictionary Two test classes opening the catalog at once threw 'concurrent update' from ValidateStatModifiers; the cache is static. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ItemData.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index d921c1227..d7b2ee9b3 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -818,8 +818,8 @@ public static class StatValidation // ApplyModifier threw an unhelpful NullReferenceException at random equip time. Resolving it here, once, // memoized, and failing loudly at catalog load or Upsert (through ValidateStatModifiers below) names the // record instead of leaving the bug for whoever equips the item first. - private static readonly Dictionary<(string Target, string Stat), (Type Type, FieldInfo Field)> _resolvedStatFields = - new Dictionary<(string, string), (Type, FieldInfo)>(); + private static readonly System.Collections.Concurrent.ConcurrentDictionary<(string Target, string Stat), (Type Type, FieldInfo Field)> _resolvedStatFields = + new System.Collections.Concurrent.ConcurrentDictionary<(string, string), (Type, FieldInfo)>(); private static Type[] _statReferenceTypes; private static Type[] StatReferenceTypes => _statReferenceTypes ??= typeof(BehaviorData).GetAllChildClasses()