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)[] {