Merge minor-power-factions (eureka/aetheria-release-minor-power-factions, 7d1106a9)

Code only: Aya, Megiddo and Forge as ordinary factions, the Pirates allegiance top-up, ten minor-power products. Also carries the ItemData.ResolveStatField fix: the static stat-field cache is a ConcurrentDictionary (Soul judged ItemData the right owner), because parallel catalog opens raced on it. The branch's GameData/Aetheria.cc is not taken; the catalog is regenerated by replay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-17 17:01:16 +02:00
commit c486b06d04
8 changed files with 365 additions and 15 deletions

View file

@ -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()

BIN
GameData/Aetheria.cc (Stored with Git LFS)

Binary file not shown.

View file

@ -0,0 +1,146 @@
/* 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<Faction>().Where(f => Minor.Contains(f.ShortName)).Select(f => cache.RefOf(f)).ToArray();
Assert.Equal(3, gone.Length);
foreach (var product in cache.GetAll<FactionProductData>().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<Faction>())
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<Faction>()) 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<Faction>().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<Faction>().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.
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<InvalidOperationException>(() => 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<Faction>().ToArray();
var forge = factions.Single(f => f.ShortName == "Forge");
Assert.DoesNotContain(_galaxy.Factions, f => f == forge);
var autocannon = _cache.GetAll<CraftedItemData>().Single(d => d.Name == "Autocannon");
var pattern30 = _cache.GetAll<FactionProductData>().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));
// 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<FactionProductData>().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));
}
}

View file

@ -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<Faction>().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);
@ -105,17 +112,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<Faction>().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<Faction>().Single(f => f.Name == "Latecomer"));
var others = late.Cache.GetAll<Faction>().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<Faction>().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]

View file

@ -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<FactionProductData>(), p => p.Name == row.Name);

View file

@ -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<Faction>().ToArray();
var nameFiles = db.Cache.GetAll<NameFile>().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<Faction>(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<Faction>(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<Faction>(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;
}
}

View file

@ -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<string, Row[]> Lines = new() { ["weapons"] = Weapons };
private static readonly Dictionary<string, Row[]> 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<Row> Table(string line) => Lines[line];

View file

@ -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 <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], product-lines weapons [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], 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<Faction> 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,23 @@ public static class Program
var existing = db.Cache.GetAll<Faction>().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<Faction>());
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("\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($"\nGave the Pirates allegiance 1.0 to {gaps.Length} faction(s) in Aetheria.cc");
return 0;
}
@ -2293,7 +2316,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)[]
{