Merge eureka/aetheria-release-loadout-commit: AetherDb loadout-commit with --boss-of; Loadouts.Commit accepts package designs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # tools/AetherDb/Program.cs
This commit is contained in:
commit
5584c0d7c8
7 changed files with 596 additions and 22 deletions
|
|
@ -96,20 +96,28 @@ public static class Loadouts
|
|||
_ => ""
|
||||
};
|
||||
|
||||
// The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it
|
||||
// after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the
|
||||
// file; a cache that already has the catalog open sees the loadout only once it reloads.
|
||||
// Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a loadout of this name
|
||||
// exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional
|
||||
// on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then.
|
||||
public static bool Commit(string catalogPath, Loadout loadout, bool replace)
|
||||
// The only writer of Loadout records, and of the Faction.BossLoadout that names one. It opens its own short-lived cache
|
||||
// over the catalog file, writable, and disposes it after the one commit. No other cache is touched, so no catalog
|
||||
// instance play holds (or has mutated) can reach the file; a cache that already has the catalog open sees the loadout
|
||||
// only once it reloads. The catalog is a stack of packages, the shipped content included (ruling
|
||||
// catalog-is-a-package-stack), so a loadout may name any design the stack holds; one whose package is later removed fails
|
||||
// closed where it is built, with its reason logged. With bossOf set, the same batch makes the loadout that faction's boss
|
||||
// (Faction.BossLoadout), so the loadout and the reference land together or not at all.
|
||||
// Throws and writes nothing when the file is missing (capture never creates a catalog), when bossOf names no faction in
|
||||
// it, when a loadout of this name exists under another key, or when one exists under KeyOf(Name) and replace is not set.
|
||||
// The commit is conditional on the record at KeyOf(Name) and on the faction, so a record changed on disk since this open
|
||||
// is not clobbered: returns false then, and neither is written.
|
||||
public static bool Commit(string catalogPath, Loadout loadout, bool replace, CultRecordRef<Faction> bossOf = default)
|
||||
{
|
||||
var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray();
|
||||
if (modDesigns.Length > 0)
|
||||
throw new InvalidOperationException($"Loadout '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a loadout.");
|
||||
if (!File.Exists(catalogPath))
|
||||
throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one.");
|
||||
using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);
|
||||
return Commit(cache, loadout, replace, bossOf);
|
||||
}
|
||||
|
||||
// The one batch Commit(path, ...) makes, over a writable catalog cache the caller opened.
|
||||
internal static bool Commit(CultCache cache, Loadout loadout, bool replace, CultRecordRef<Faction> bossOf)
|
||||
{
|
||||
var key = KeyOf(loadout.Name);
|
||||
var namesake = cache.GetAll<Loadout>().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key));
|
||||
if (namesake != null)
|
||||
|
|
@ -117,10 +125,17 @@ public static class Loadouts
|
|||
var existing = cache.Get<Loadout>(key);
|
||||
if (existing != null && !replace)
|
||||
throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly.");
|
||||
var boss = bossOf.IsSet()
|
||||
? cache.Get(bossOf) ?? throw new InvalidOperationException($"Loadout '{loadout.Name}': the boss faction is not in the catalog.")
|
||||
: null;
|
||||
return cache.Commit(batch =>
|
||||
{
|
||||
batch.Expect(key, existing);
|
||||
batch.Upsert(typeof(Loadout), loadout, key);
|
||||
if (boss == null) return;
|
||||
batch.Expect(bossOf.Key, boss);
|
||||
boss.BossLoadout = new CultRecordRef<Loadout>(key);
|
||||
batch.Upsert(boss.GetType(), boss, bossOf.Key);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ public static class ShipModCatalog
|
|||
|
||||
public static CultRecordKey ProductKey(string id, CultRecordKey maker) => new CultRecordKey("mod-product:" + id + ":" + maker.Value);
|
||||
|
||||
// A key the mods own. The shipped catalog and its authored records never reference one: a mod may be uninstalled.
|
||||
// A key a package owns. Any record may reference one (ruling catalog-is-a-package-stack); one whose package is removed
|
||||
// fails closed where it is read.
|
||||
public static bool IsModKey(CultRecordKey key) =>
|
||||
key.IsSet() && (key.Value.StartsWith("mod-hull:", StringComparison.Ordinal) || key.Value.StartsWith("mod-ship:", StringComparison.Ordinal) || key.Value.StartsWith("mod-product:", StringComparison.Ordinal));
|
||||
|
||||
|
|
|
|||
278
tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs
Normal file
278
tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using Xunit;
|
||||
|
||||
// AetherDb's loadout-commit command (aetheria-release, cut loadout-commit; rulings boss-loadouts-agent-authored,
|
||||
// catalog-is-a-package-stack), run on a scratch copy of the shipped catalog with a scratch GameData/Mods. Commit writes one
|
||||
// Loadout at Loadouts.KeyOf(name) with the records its names resolve to in the composed catalog the game boots, and with
|
||||
// --boss-of makes it that faction's boss in the same commit; every refusal leaves the catalog byte-identical.
|
||||
public sealed class LoadoutCommitCommandTests : IDisposable
|
||||
{
|
||||
private const string PackageId = "mod.loadoutcommit";
|
||||
private const string PackageProduct = "Loadout Commit Skiff Mk I";
|
||||
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "loadout-commit-" + Guid.NewGuid().ToString("N"));
|
||||
private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc");
|
||||
private string Mods => Path.Combine(_root, "GameData", "Mods");
|
||||
|
||||
public LoadoutCommitCommandTests()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_root, "GameData"));
|
||||
Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources"));
|
||||
var shipped = AetherDb.FindRoot();
|
||||
File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath);
|
||||
File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset"));
|
||||
}
|
||||
|
||||
public void Dispose() => Directory.Delete(_root, true);
|
||||
|
||||
private CultCache Open()
|
||||
{
|
||||
var cache = new CultCache(TestCatalog.Registry());
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
|
||||
return cache;
|
||||
}
|
||||
|
||||
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
|
||||
|
||||
// A hull product and a weapon slot of the shipped catalog that fit together, found the production way: a generated fit,
|
||||
// captured. Every name in them is unique in the catalog, so the command resolves it exactly.
|
||||
private (string hullProduct, string slot, string openSlot, string designName, string productName) Fit()
|
||||
{
|
||||
using var cache = Open();
|
||||
var settings = AuthoredSettings.Load(_root).Read<GameplaySettings>("GameplaySettings");
|
||||
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
||||
bool UniqueProduct(string name) => cache.GetAll<FactionProductData>().Count(item => item.Name == name) == 1;
|
||||
bool UniqueDesign(string name) => cache.GetAll<EquippableItemData>().Count(item => item.Name == name) == 1;
|
||||
foreach (var hull in cache.GetAll<HullData>().Where(h => h.HullType == HullType.Ship && h.Price > 0).OrderBy(h => h.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var random = new CultMath.Random(1);
|
||||
var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f);
|
||||
var pack = generator.GenerateShipLoadout(candidate => candidate == hull);
|
||||
if (pack == null) continue;
|
||||
var fit = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, pack), "probe");
|
||||
var hullProduct = cache.Get(fit.HullProduct);
|
||||
if (hullProduct == null || !UniqueProduct(hullProduct.Name)) continue;
|
||||
foreach (var slot in fit.Slots.Where(s => s.Product.IsSet() && cache.Get(s.Design).Behaviors.Any(b => b is WeaponData)))
|
||||
{
|
||||
var design = cache.Get(slot.Design);
|
||||
var product = cache.Get(slot.Product);
|
||||
if (!UniqueDesign(design.Name) || !UniqueProduct(product.Name)) continue;
|
||||
var cell = $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{design.Name}";
|
||||
return (hullProduct.Name, $"{cell}@{product.Name}", cell, design.Name, product.Name);
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException("the shipped catalog has no ship hull with a uniquely named weapon fit");
|
||||
}
|
||||
|
||||
// A package under the scratch Mods root whose ship.cc alone holds its hull and its one product, made by the first shipped
|
||||
// faction by name: the shipped catalog holds neither.
|
||||
private void WritePackage()
|
||||
{
|
||||
CultRecordKey maker;
|
||||
using (var cache = Open())
|
||||
maker = cache.RefOf(cache.GetAll<Faction>().OrderBy(f => f.Name, StringComparer.Ordinal).First()).Key;
|
||||
var directory = ShipFixture.WritePackage(Mods, PackageId, "Loadout Commit Skiff", tweak: ship => ship.Hull.Price = 10);
|
||||
using var store = ShipAuthoringStore.Open(Path.Combine(directory, "ship.cc"), writable: true);
|
||||
ShipAuthoringStore.WriteProduct(store, PackageId, new FactionProductData
|
||||
{
|
||||
Name = PackageProduct,
|
||||
Design = new CultRecordRef<CraftedItemData>(ShipModCatalog.HullKey(PackageId)),
|
||||
Manufacturer = new CultRecordRef<Faction>(maker)
|
||||
});
|
||||
store.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
private int Run(out string output, params string[] args)
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
var code = LoadoutCommitCommand.Run(args, _root, writer);
|
||||
output = writer.ToString();
|
||||
return code;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_arguments_print_the_usage_and_fail()
|
||||
{
|
||||
Assert.Equal(1, Run(out var output));
|
||||
Assert.StartsWith("usage: loadout-commit ", output);
|
||||
Assert.Contains("--boss-of <faction>", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_commit_writes_one_loadout_with_the_records_its_names_resolve_to()
|
||||
{
|
||||
var fit = Fit();
|
||||
Assert.Equal(0, Run(out var output, "Test boss", fit.hullProduct, fit.slot, "--group", "0"));
|
||||
Assert.StartsWith("Test boss: committed, hull ", output);
|
||||
|
||||
using var cache = Open();
|
||||
var loadout = Assert.Single(cache.GetAll<Loadout>());
|
||||
Assert.Equal(Loadouts.KeyOf("Test boss"), cache.RefOf(loadout).Key);
|
||||
var product = cache.GetAll<FactionProductData>().Single(p => p.Name == fit.hullProduct);
|
||||
Assert.Equal(cache.RefOf(product).Key, loadout.HullProduct.Key);
|
||||
Assert.Equal(product.Design.Key, loadout.Hull.Key);
|
||||
var slot = Assert.Single(loadout.Slots);
|
||||
Assert.Equal(fit.designName, cache.Get(slot.Design).Name);
|
||||
Assert.Equal(fit.productName, cache.Get(slot.Product).Name);
|
||||
Assert.Equal(new[] { new[] { 0 } }, loadout.WeaponGroups);
|
||||
Assert.DoesNotContain(cache.GetAll<Faction>(), faction => faction.BossLoadout.IsSet() && faction.BossLoadout.Key.Equals(Loadouts.KeyOf("Test boss")));
|
||||
}
|
||||
|
||||
// @product is optional: a design-only slot commits with no product. Replace overwrites only when asked.
|
||||
[Fact]
|
||||
public void A_design_only_slot_commits_with_no_product_and_replace_overwrites()
|
||||
{
|
||||
var fit = Fit();
|
||||
Assert.Equal(0, Run(out _, "Test boss", fit.hullProduct, fit.slot));
|
||||
var written = Hash(CatalogPath);
|
||||
Assert.Equal(1, Run(out var refused, "Test boss", fit.hullProduct, fit.openSlot));
|
||||
Assert.Contains("already exists", refused);
|
||||
Assert.Equal(written, Hash(CatalogPath));
|
||||
|
||||
Assert.Equal(0, Run(out _, "Test boss", fit.hullProduct, fit.openSlot, "--replace"));
|
||||
using var cache = Open();
|
||||
var loadout = Assert.Single(cache.GetAll<Loadout>());
|
||||
Assert.False(Assert.Single(loadout.Slots).Product.IsSet());
|
||||
Assert.True(loadout.WeaponGroups == null || loadout.WeaponGroups.Length == 0);
|
||||
}
|
||||
|
||||
// Every refusal happens before Loadouts.Commit, so the catalog is byte-identical; none echoes the name it was given.
|
||||
[Theory]
|
||||
[InlineData("unknown-hull-product")]
|
||||
[InlineData("ambiguous-hull-product")]
|
||||
[InlineData("unknown-design")]
|
||||
[InlineData("ambiguous-design")]
|
||||
[InlineData("unknown-slot-product")]
|
||||
[InlineData("rotation-not-a-name")]
|
||||
[InlineData("rotation-a-number")]
|
||||
[InlineData("slot-does-not-fit")]
|
||||
[InlineData("group-names-no-slot")]
|
||||
[InlineData("unknown-boss-faction")]
|
||||
public void A_name_that_does_not_resolve_or_a_loadout_that_cannot_be_built_is_refused_and_nothing_is_written(string refusal)
|
||||
{
|
||||
var fit = Fit();
|
||||
var canary = "Canary9c1e";
|
||||
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
|
||||
object Copy(object document) => MessagePack.MessagePackSerializer.Deserialize(document.GetType(), MessagePack.MessagePackSerializer.Serialize(document.GetType(), document, options), options);
|
||||
using (var cache = Open())
|
||||
{
|
||||
// Under a key of its own, so the copy is a second record of the same name, not the first overwritten.
|
||||
void Add(object document) => Assert.True(cache.Commit(batch => batch.Upsert(document.GetType(), document, new CultRecordKey("duplicate:" + Guid.NewGuid().ToString("N")))));
|
||||
switch (refusal)
|
||||
{
|
||||
case "ambiguous-hull-product":
|
||||
Add(Copy(cache.GetAll<FactionProductData>().Single(p => p.Name == fit.hullProduct)));
|
||||
break;
|
||||
case "ambiguous-design":
|
||||
Add(Copy(cache.GetAll<EquippableItemData>().Single(d => d.Name == fit.designName)));
|
||||
break;
|
||||
}
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
var cell = fit.openSlot.Split(',');
|
||||
var args = refusal switch
|
||||
{
|
||||
"unknown-hull-product" => new[] { "Test boss", canary, fit.slot },
|
||||
"unknown-design" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},{cell[2]},{canary}" },
|
||||
"unknown-slot-product" => new[] { "Test boss", fit.hullProduct, $"{fit.openSlot}@{canary}" },
|
||||
"rotation-not-a-name" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},{canary},{fit.designName}" },
|
||||
"rotation-a-number" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},1,{fit.designName}" },
|
||||
"slot-does-not-fit" => new[] { "Test boss", fit.hullProduct, $"999,999,{cell[2]},{fit.designName}" },
|
||||
"group-names-no-slot" => new[] { "Test boss", fit.hullProduct, fit.slot, "--group", "5" },
|
||||
"unknown-boss-faction" => new[] { "Test boss", fit.hullProduct, fit.slot, "--boss-of", canary },
|
||||
_ => new[] { "Test boss", fit.hullProduct, fit.slot }
|
||||
};
|
||||
var before = Hash(CatalogPath);
|
||||
|
||||
Assert.Equal(1, Run(out var output, args));
|
||||
|
||||
Assert.StartsWith("refused: ", output);
|
||||
Assert.DoesNotContain(canary, output);
|
||||
Assert.Equal(before, Hash(CatalogPath));
|
||||
using var after = Open();
|
||||
Assert.Empty(after.GetAll<Loadout>());
|
||||
}
|
||||
|
||||
// Ruling catalog-is-a-package-stack: a hull product only a package under Mods holds resolves, because names resolve
|
||||
// against the composed catalog the game boots, and the loadout is committed into the shipped file naming the package's keys.
|
||||
// The shipped file gains the loadout and nothing of the package.
|
||||
[Fact]
|
||||
public void A_hull_product_held_only_by_a_package_resolves_and_commits()
|
||||
{
|
||||
WritePackage();
|
||||
using (var cache = Open())
|
||||
Assert.DoesNotContain(cache.GetAll<FactionProductData>(), p => p.Name == PackageProduct);
|
||||
|
||||
Assert.Equal(0, Run(out var output, "Package boss", PackageProduct));
|
||||
Assert.StartsWith("Package boss: committed, hull Loadout Commit Skiff (Loadout Commit Skiff Mk I), 0 slots", output);
|
||||
|
||||
using var after = Open();
|
||||
var loadout = Assert.Single(after.GetAll<Loadout>());
|
||||
Assert.Equal(ShipModCatalog.HullKey(PackageId), loadout.Hull.Key);
|
||||
Assert.StartsWith("mod-product:" + PackageId + ":", loadout.HullProduct.Key.Value);
|
||||
Assert.Null(after.Get(loadout.Hull));
|
||||
Assert.Null(after.Get(loadout.HullProduct));
|
||||
}
|
||||
|
||||
// --boss-of, by Name or by ShortName, makes the loadout that faction's boss in the same commit; no other faction changes.
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Boss_of_sets_the_factions_boss_loadout_in_the_same_commit(bool byShortName)
|
||||
{
|
||||
var fit = Fit();
|
||||
string factionName, argument;
|
||||
CultRecordKey[] othersBefore;
|
||||
using (var cache = Open())
|
||||
{
|
||||
var faction = cache.GetAll<Faction>().Where(f => !f.BossLoadout.IsSet())
|
||||
.Where(f => cache.GetAll<Faction>().Count(o => o.Name == f.Name || o.ShortName == f.Name) == 1)
|
||||
.Where(f => cache.GetAll<Faction>().Count(o => o.Name == f.ShortName || o.ShortName == f.ShortName) == 1)
|
||||
.OrderBy(f => f.Name, StringComparer.Ordinal).First();
|
||||
factionName = faction.Name;
|
||||
argument = byShortName ? faction.ShortName : faction.Name;
|
||||
othersBefore = cache.GetAll<Faction>().Where(f => f != faction).OrderBy(f => f.Name, StringComparer.Ordinal).Select(f => f.BossLoadout.Key).ToArray();
|
||||
}
|
||||
|
||||
Assert.Equal(0, Run(out var output, "Test boss", fit.hullProduct, fit.slot, "--boss-of", argument));
|
||||
Assert.EndsWith($", boss of {factionName}", output.TrimEnd());
|
||||
|
||||
using var after = Open();
|
||||
var boss = after.GetAll<Faction>().Single(f => f.Name == factionName);
|
||||
var loadout = Loadouts.BossOf(after, boss, out var refusal);
|
||||
Assert.Equal(BossRefusal.None, refusal);
|
||||
Assert.Equal("Test boss", loadout.Name);
|
||||
Assert.Equal(othersBefore, after.GetAll<Faction>().Where(f => f != boss).OrderBy(f => f.Name, StringComparer.Ordinal).Select(f => f.BossLoadout.Key).ToArray());
|
||||
}
|
||||
|
||||
// A refused commit with --boss-of leaves the faction as it was: the loadout and the reference land together or not at all.
|
||||
[Fact]
|
||||
public void A_refused_boss_commit_leaves_the_faction_unchanged()
|
||||
{
|
||||
var fit = Fit();
|
||||
Assert.Equal(0, Run(out _, "Test boss", fit.hullProduct, fit.slot));
|
||||
string factionName;
|
||||
CultRecordKey bossBefore;
|
||||
using (var cache = Open())
|
||||
{
|
||||
var faction = cache.GetAll<Faction>().Where(f => cache.GetAll<Faction>().Count(o => o.Name == f.Name || o.ShortName == f.Name) == 1)
|
||||
.OrderBy(f => f.Name, StringComparer.Ordinal).First();
|
||||
factionName = faction.Name;
|
||||
bossBefore = faction.BossLoadout.Key;
|
||||
}
|
||||
var before = Hash(CatalogPath);
|
||||
|
||||
Assert.Equal(1, Run(out var output, "Test boss", fit.hullProduct, fit.openSlot, "--boss-of", factionName));
|
||||
|
||||
Assert.Contains("already exists", output);
|
||||
Assert.Equal(before, Hash(CatalogPath));
|
||||
using var after = Open();
|
||||
Assert.Equal(bossBefore, after.GetAll<Faction>().Single(f => f.Name == factionName).BossLoadout.Key);
|
||||
}
|
||||
}
|
||||
|
|
@ -122,27 +122,112 @@ public sealed class LoadoutTests : IDisposable
|
|||
Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout"));
|
||||
}
|
||||
|
||||
// The shipped catalog never references a mod: a mod may be uninstalled, and a preset naming one would dangle. The
|
||||
// refusal is at the only writer, so no caller (the editor command included) can get one into the file.
|
||||
// The catalog is a stack of packages (ruling catalog-is-a-package-stack): a preset may name a package's design, hull or
|
||||
// slot, and the only writer commits it under the package's key. Whether the design resolves is decided where the preset is
|
||||
// built, not here.
|
||||
[Theory]
|
||||
[InlineData("hull", "mod-hull:mod.skiff")]
|
||||
[InlineData("slot", "mod-hull:mod.skiff")]
|
||||
[InlineData("slot", "mod-ship:mod.skiff")]
|
||||
public void APresetNamingAModDesignIsRefusedAndNothingIsWritten(string where, string key)
|
||||
public void APresetNamingAPackageDesignIsCommitted(string where, string key)
|
||||
{
|
||||
var mod = new CultRecordRef<EquippableItemData>(new CultRecordKey(key));
|
||||
using var cache = Open();
|
||||
var loadout = HandBuilt(cache);
|
||||
if (where == "hull") loadout.Hull = new CultRecordRef<HullData>(new CultRecordKey(key));
|
||||
else loadout.Slots[1].Design = mod;
|
||||
else loadout.Slots[1].Design = new CultRecordRef<EquippableItemData>(new CultRecordKey(key));
|
||||
|
||||
Assert.True(Loadouts.Commit(Catalog, loadout, replace: false));
|
||||
|
||||
using var reopened = Open();
|
||||
var written = Assert.Single(reopened.GetAll<Loadout>());
|
||||
Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value);
|
||||
}
|
||||
|
||||
// With bossOf, one commit writes the loadout and makes it that faction's boss: Faction.BossLoadout names its key.
|
||||
[Fact]
|
||||
public void ABossCommitWritesTheLoadoutAndTheFactionsBossReference()
|
||||
{
|
||||
using (var cache = Open())
|
||||
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, cache.RefOf(cache.GetByName<Faction>("Maker"))));
|
||||
|
||||
using (var cache = Open())
|
||||
{
|
||||
var loadout = Assert.Single(cache.GetAll<Loadout>());
|
||||
Assert.Equal(Loadouts.KeyOf("Skiff build"), cache.RefOf(loadout).Key);
|
||||
Assert.Same(loadout, Loadouts.BossOf(cache, cache.GetByName<Faction>("Maker"), out var refusal));
|
||||
Assert.Equal(BossRefusal.None, refusal);
|
||||
}
|
||||
}
|
||||
|
||||
// The loadout and the boss reference are one batch: when the faction changed on disk after the commit's cache read it,
|
||||
// the commit returns false and neither lands, though the loadout alone would have; the other writer's change survives.
|
||||
[Fact]
|
||||
public void ABossCommitOverAFactionChangedOnDiskWritesNeitherTheLoadoutNorTheReference()
|
||||
{
|
||||
Loadout loadout;
|
||||
CultRecordRef<Faction> maker;
|
||||
using (var read = Open())
|
||||
{
|
||||
loadout = HandBuilt(read);
|
||||
maker = read.RefOf(read.GetByName<Faction>("Maker"));
|
||||
}
|
||||
|
||||
using (var stale = AetheriaStores.Open(Catalog, catalogWritable: true))
|
||||
{
|
||||
Assert.NotNull(stale.Get(maker));
|
||||
using (var other = AetheriaStores.Open(Catalog, catalogWritable: true))
|
||||
{
|
||||
var fresh = other.Get(maker);
|
||||
Assert.True(other.Commit(batch =>
|
||||
{
|
||||
batch.Expect(maker.Key, fresh);
|
||||
fresh.ShortName = "NEW";
|
||||
batch.Upsert(typeof(Faction), fresh, maker.Key);
|
||||
}));
|
||||
}
|
||||
|
||||
Assert.False(Loadouts.Commit(stale, loadout, replace: false, maker));
|
||||
}
|
||||
|
||||
using var after = Open();
|
||||
Assert.Empty(after.GetAll<Loadout>());
|
||||
var faction = after.Get(maker);
|
||||
Assert.Equal("NEW", faction.ShortName);
|
||||
Assert.False(faction.BossLoadout.IsSet());
|
||||
}
|
||||
|
||||
// The commit is conditional on the record at KeyOf(Name) too: a loadout written under that name after this cache read the
|
||||
// catalog is not overwritten, even with replace set, and the second commit reports false.
|
||||
[Fact]
|
||||
public void ACommitOverALoadoutWrittenAfterTheReadWritesNothing()
|
||||
{
|
||||
Loadout loadout;
|
||||
using (var read = Open())
|
||||
loadout = HandBuilt(read);
|
||||
|
||||
using (var stale = AetheriaStores.Open(Catalog, catalogWritable: true))
|
||||
{
|
||||
Assert.Empty(stale.GetAll<Loadout>());
|
||||
Assert.True(Loadouts.Commit(Catalog, loadout, replace: false));
|
||||
Assert.False(Loadouts.Commit(stale, loadout, replace: true, default));
|
||||
}
|
||||
|
||||
using var after = Open();
|
||||
Assert.Single(after.GetAll<Loadout>());
|
||||
}
|
||||
|
||||
// A boss faction the catalog does not hold is refused before anything is written.
|
||||
[Fact]
|
||||
public void ABossCommitNamingNoFactionIsRefusedAndNothingIsWritten()
|
||||
{
|
||||
var before = Hash(Catalog);
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(Catalog, loadout, replace: false));
|
||||
|
||||
Assert.Contains("names mod designs", error.Message);
|
||||
Assert.Contains(key, error.Message);
|
||||
using (var cache = Open())
|
||||
{
|
||||
var error = Assert.Throws<InvalidOperationException>(() =>
|
||||
Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, new CultRecordRef<Faction>(new CultRecordKey("faction:absent"))));
|
||||
Assert.Contains("boss faction is not in the catalog", error.Message);
|
||||
}
|
||||
Assert.Equal(before, Hash(Catalog));
|
||||
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
|
||||
}
|
||||
|
||||
// Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates.
|
||||
|
|
|
|||
|
|
@ -687,6 +687,47 @@ public sealed partial class RunStartTests
|
|||
foreach (var faction in AllFactionNames()) AssertUnbuildableBossIsAsIfAbsent(faction, loadout => MoveOut(loadout, 3));
|
||||
}
|
||||
|
||||
// Ruling catalog-is-a-package-stack: a boss Loadout committed on a package's hull, and the package then removed (the one
|
||||
// uninstall). The catalog still resolves (the shipped file, nothing thrown), so the game boots; the Loadout and the
|
||||
// faction's reference to it survive in the shipped file, and the boss zone fails closed with the reason ZoneGenerator logs.
|
||||
[Fact]
|
||||
public void A_boss_loadout_on_a_removed_package_hull_still_boots_and_leaves_the_zone_without_a_boss()
|
||||
{
|
||||
const string id = "mod.removedboss";
|
||||
var work = Path.Combine(_root, "removed-package");
|
||||
var shipped = Path.Combine(work, "GameData", "Aetheria.cc");
|
||||
var mods = Path.Combine(work, "GameData", "Mods");
|
||||
Directory.CreateDirectory(mods);
|
||||
File.Copy(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), shipped);
|
||||
ShipFixture.WritePackage(mods, id, "Removed Boss Hull");
|
||||
var installed = TestCatalog.Resolve(shipped, mods, Directory.CreateDirectory(Path.Combine(work, "installed")).FullName);
|
||||
Assert.NotEqual(shipped, installed);
|
||||
|
||||
var galaxy = DemoGalaxy(1);
|
||||
var antagonist = Zhestokost(galaxy);
|
||||
var boss = new Loadout { Name = "Removed Package Boss", Hull = new CultRecordRef<HullData>(ShipModCatalog.HullKey(id)), WeaponGroups = new int[0][] };
|
||||
Assert.True(Loadouts.Commit(shipped, boss, replace: false, _cache.RefOf(antagonist)));
|
||||
ShipModCatalog.RemovePackage(mods, id);
|
||||
|
||||
var booted = TestCatalog.Resolve(shipped, mods, Directory.CreateDirectory(Path.Combine(work, "booted")).FullName);
|
||||
Assert.Equal(shipped, booted);
|
||||
Loadout survived;
|
||||
using (var catalog = AetheriaStores.Open(booted))
|
||||
{
|
||||
survived = Loadouts.BossOf(catalog, catalog.GetAll<Faction>().Single(f => f.Name == antagonist.Name), out var refusal);
|
||||
Assert.Equal(BossRefusal.None, refusal);
|
||||
Assert.Null(catalog.Get(survived.Hull));
|
||||
}
|
||||
|
||||
StoreLoadout(survived);
|
||||
SetBoss(antagonist, survived);
|
||||
var log = new List<string>();
|
||||
var pack = BossZonePack(galaxy, LoggingItems(log), galaxy.Exit);
|
||||
Assert.False(pack.BossSpawned);
|
||||
Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
|
||||
Assert.Contains(log, line => line.Contains("boss Loadout Removed Package Boss cannot be built") && line.Contains($"design {ShipModCatalog.HullKey(id)} is not in the catalog"));
|
||||
}
|
||||
|
||||
// A design-only slot asks the chooser, which draws its faction's stream; the fit is decided before it is asked.
|
||||
[Fact]
|
||||
public void An_unbuildable_boss_loadout_with_design_only_slots_leaves_the_zone_as_if_it_had_no_boss()
|
||||
|
|
|
|||
153
tools/AetherDb/LoadoutCommitCommand.cs
Normal file
153
tools/AetherDb/LoadoutCommitCommand.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/* 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 CultMath;
|
||||
using GameCult.Caching;
|
||||
|
||||
// loadout-commit <name> <hull product> [<x>,<y>,<rotation>,<design>[@<product>]]... [--group <i>,<j>...]... [--replace] [--boss-of <faction>]
|
||||
// (aetheria-release, cut loadout-commit; rulings boss-loadouts-agent-authored, catalog-is-a-package-stack): writes one Loadout
|
||||
// record into GameData/Aetheria.cc through Loadouts.Commit, the only writer, and with --boss-of makes it that faction's boss in
|
||||
// the same commit. Names resolve exactly against the catalog the game boots: ShipModCatalog.ResolveCatalog over the shipped
|
||||
// file and GameData/Mods, composed into a scratch file that is never committed. So a loadout may name a package's hull,
|
||||
// design or product (one hull product, one design per slot, one product where a slot names one, a rotation by its
|
||||
// ItemRotation name, a faction by Name or ShortName). The loadout is then built once on a throwaway ItemManager with
|
||||
// Loadouts.Materialize, so a slot that does not fit or a weapon group that names a non-weapon is refused here. Any refusal
|
||||
// happens before Commit: the catalog stays byte-identical. A refusal names the field and the slot, never the value it was given.
|
||||
public static class LoadoutCommitCommand
|
||||
{
|
||||
private const string Usage = "usage: loadout-commit <name> <hull product> [<x>,<y>,<rotation>,<design>[@<product>]]... [--group <i>,<j>...]... [--replace] [--boss-of <faction>]";
|
||||
|
||||
public static int Run(string[] args, string root = null, TextWriter output = null)
|
||||
{
|
||||
output ??= Console.Out;
|
||||
try
|
||||
{
|
||||
return Commit(args, root, output);
|
||||
}
|
||||
catch (Exception e) when (e is InvalidOperationException or ArgumentException or FormatException)
|
||||
{
|
||||
output.WriteLine($"refused: {e.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static int Commit(string[] args, string root, TextWriter output)
|
||||
{
|
||||
var replace = false;
|
||||
string bossOfName = null;
|
||||
var groups = new List<int[]>();
|
||||
var positional = new List<string>();
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
if (args[i] == "--replace") replace = true;
|
||||
else if (args[i] == "--boss-of")
|
||||
{
|
||||
if (++i >= args.Length) throw new ArgumentException("--boss-of needs a faction");
|
||||
bossOfName = args[i];
|
||||
}
|
||||
else if (args[i] == "--group")
|
||||
{
|
||||
if (++i >= args.Length) throw new ArgumentException("--group needs a list of slot indices");
|
||||
groups.Add(args[i].Split(',').Select(part => int.TryParse(part, out var index) ? index : throw new FormatException($"group {groups.Count}: an index is not a number")).ToArray());
|
||||
}
|
||||
else positional.Add(args[i]);
|
||||
}
|
||||
|
||||
if (positional.Count < 2)
|
||||
{
|
||||
output.WriteLine(Usage);
|
||||
return 1;
|
||||
}
|
||||
|
||||
root ??= AetherDb.FindRoot();
|
||||
var catalogPath = Path.Combine(root, "GameData", "Aetheria.cc");
|
||||
var scratch = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "aetherdb-loadout-commit-" + Guid.NewGuid().ToString("N"))).FullName;
|
||||
try
|
||||
{
|
||||
var (composed, excluded) = ShipModCatalog.ResolveCatalog(catalogPath, Path.Combine(scratch, "derived.cc"), Path.Combine(root, "GameData", "Mods"));
|
||||
foreach (var exclusion in excluded) output.WriteLine($"package left out, as at boot: {exclusion}");
|
||||
|
||||
Loadout loadout;
|
||||
CultRecordRef<Faction> bossOf = default;
|
||||
string summary;
|
||||
// Disposed before the commit: with no package composed, this is the shipped file itself.
|
||||
using (var cache = AetheriaStores.Open(composed))
|
||||
{
|
||||
loadout = Build(cache, positional[0], positional[1], positional.Skip(2).ToArray(), groups);
|
||||
// Compose writes no faction, so the composed catalog's factions are the shipped ones under the same keys.
|
||||
if (bossOfName != null)
|
||||
bossOf = cache.RefOf(ExactlyOne(cache.GetAll<Faction>().Where(f => f.Name == bossOfName || f.ShortName == bossOfName).ToArray(), "boss faction"));
|
||||
|
||||
var failures = new List<string>();
|
||||
var settings = AuthoredSettings.Load(root).Read<GameplaySettings>("GameplaySettings");
|
||||
// Validation never draws for play, so the item stream gets a fixed seed.
|
||||
var itemManager = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
||||
var entity = Loadouts.Materialize(itemManager, null, loadout, design => ProductsOf(cache, design).FirstOrDefault(), failures);
|
||||
if (entity == null) throw new InvalidOperationException($"the loadout cannot be built: {string.Join("; ", failures)}");
|
||||
|
||||
summary = $"{loadout.Name}: committed, hull {cache.Get(loadout.Hull).Name} ({cache.Get(loadout.HullProduct).Name}), {loadout.Slots.Count} slots" +
|
||||
(bossOf.IsSet() ? $", boss of {cache.Get(bossOf).Name}" : "");
|
||||
}
|
||||
|
||||
if (!Loadouts.Commit(catalogPath, loadout, replace, bossOf))
|
||||
throw new InvalidOperationException("the catalog changed since it was read; nothing was written");
|
||||
output.WriteLine(summary);
|
||||
return 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(scratch, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Validation only: a design-only slot is built at its first product by Name, so the throwaway build proves the fit.
|
||||
private static IEnumerable<FactionProductData> ProductsOf(CultCache cache, CraftedItemData design)
|
||||
{
|
||||
var key = cache.RefOf(design).Key;
|
||||
return cache.GetAll<FactionProductData>().Where(product => product.Design.Key.Equals(key)).OrderBy(product => product.Name, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static Loadout Build(CultCache cache, string name, string hullProductName, string[] slotArgs, List<int[]> groups)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("the loadout name is empty");
|
||||
var hullProduct = ExactlyOne(cache.GetAll<FactionProductData>().Where(product => product.Name == hullProductName).ToArray(), "hull product");
|
||||
var hull = cache.Get(hullProduct.Design) as HullData ?? throw new InvalidOperationException("hull product: its design is not a hull");
|
||||
var slots = new List<LoadoutSlot>();
|
||||
for (var i = 0; i < slotArgs.Length; i++)
|
||||
{
|
||||
var where = $"slot {i}";
|
||||
var parts = slotArgs[i].Split(',', 4);
|
||||
if (parts.Length != 4 || !int.TryParse(parts[0], out var x) || !int.TryParse(parts[1], out var y))
|
||||
throw new FormatException($"{where}: expected <x>,<y>,<rotation>,<design>[@<product>]");
|
||||
if (!Enum.TryParse<ItemRotation>(parts[2], out var rotation) || !Enum.IsDefined(typeof(ItemRotation), rotation) || int.TryParse(parts[2], out _))
|
||||
throw new FormatException($"{where}: rotation is not an ItemRotation name");
|
||||
var at = parts[3].IndexOf('@');
|
||||
var designName = at < 0 ? parts[3] : parts[3][..at];
|
||||
var design = ExactlyOne(cache.GetAll<EquippableItemData>().Where(d => d.Name == designName).ToArray(), $"{where} design");
|
||||
var slot = new LoadoutSlot { Position = new int2(x, y), Rotation = rotation, Design = cache.RefOf(design) };
|
||||
if (at >= 0)
|
||||
{
|
||||
var productName = parts[3][(at + 1)..];
|
||||
slot.Product = cache.RefOf(ExactlyOne(cache.GetAll<FactionProductData>().Where(p => p.Name == productName).ToArray(), $"{where} product"));
|
||||
}
|
||||
slots.Add(slot);
|
||||
}
|
||||
|
||||
return new Loadout
|
||||
{
|
||||
Name = name,
|
||||
Hull = cache.RefOf(hull),
|
||||
HullProduct = cache.RefOf(hullProduct),
|
||||
Slots = slots,
|
||||
WeaponGroups = groups.Count == 0 ? null : groups.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static T ExactlyOne<T>(T[] found, string field) =>
|
||||
found.Length == 1 ? found[0] : throw new InvalidOperationException(found.Length == 0 ? $"{field}: no record has that name" : $"{field}: more than one record has that name");
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ public static class Program
|
|||
case "station-fit": return StationFit();
|
||||
case "hardpoint-fit": return HardpointFit();
|
||||
case "loadout": return Loadout(args.Skip(1).ToArray());
|
||||
case "loadout-commit": return LoadoutCommitCommand.Run(args.Skip(1).ToArray());
|
||||
case "save": return Save();
|
||||
case "factions": return Factions();
|
||||
case "dangling": return Dangling(args.Skip(1).ToArray());
|
||||
|
|
@ -77,7 +78,7 @@ public static class Program
|
|||
}
|
||||
case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out);
|
||||
default:
|
||||
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [hull \"<name>\" <kind>] [apply]");
|
||||
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], loadout-commit <name> <hull product> [<x>,<y>,<rotation>,<design>[@<product>]]... [--group <i>,<j>...]... [--replace] [--boss-of <faction>], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [hull \"<name>\" <kind>] [apply]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue