Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
90f977bf19 Boss tests: a null faction has no boss hull; an unbuildable boss Loadout lists every failure
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:33:09 +02:00
e47bb27ef8 Boss_carries_exactly: the open slot is built as the one product its antagonist can get, with an outsider's listed first
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:31:55 +02:00
d912cf44d2 Boss tie-break test; the carried boss's open slot is a design with products the maker cannot get
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:16:47 +02:00
a74de35c82 Boss_carries_exactly: the open slot's brand is an available product of its design (a weighted draw need not reach every one)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 19:08:22 +02:00
a0a650db7a Boss_carries_exactly: the open slot's brand is one the antagonist's outfitter can choose, not any product of the design
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:56:36 +02:00
6463e79317 Boss fixtures: ordinary hulls only as the copied "like" hull; refusal tests copy a design by its runtime type
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:47:21 +02:00
fc992f5abe Boss tests store Loadouts through the cache batch at Loadouts.KeyOf (the cache has no keyed Upsert)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:40:09 +02:00
e385e7ce0e Boss tests: fixed Loadout bosses, refusals, loadout-commit tests; "Lucent" is "Lucent Media" (builds not yet checked)
RunGoalTests rewritten for Loadouts.BossOf (the three h1 failures were the fixture name "Lucent" against the shipped
"Lucent Media"). LoadoutCommitCommandTests covers the command, its refusals and the maker's boss end to end.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:32:37 +02:00
e71667aab1 AetherDb loadout-commit; factions and loadout show the derived boss Loadout
loadout-commit resolves every name exactly, test-materializes, then Loadouts.Commit. factions prints
BossOf's Loadout (none / NO LOADOUT); loadout no longer generates a Boss hull.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:25:46 +02:00
5d624f9e6d Loadouts.BossOf: a faction's boss flies the Loadout on its boss hull's product (builds not yet checked)
BossOf keys on BossHullsOf(items, faction)[0] and Loadout.HullProduct, refuses typed (BossRefusal),
reads no Random, Prefab or Visual. ZoneGenerator builds the boss through Loadouts.Materialize with
ChooseProduct as the only generator member; Galaxy picks boss factions through BossOf. Loadout.cs
vocabulary: loadout, not preset or variant.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:25:42 +02:00
bfcde46c0f Delete GenerateBossLoadout, ShipLoadout and the boss fallback tests (does not build: ZoneGenerator and Program still call them until the next commit)
Boss gear is never drawn (ruling boss-gear-is-a-loadout). GenerateShipLoadout gets its single body back.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:22:15 +02:00
095baf2fb5 RunGoalTests: product Design refs are CraftedItemData refs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:57:23 +02:00
6b602dd34a ship-authoring boss on|off sets HullData.Boss on a package hull
Judged before the file is touched, touches no product; inspect prints
", boss" when set. Tests: the command, and the package path through compose.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:54:52 +02:00
1b5e989182 Derive a faction's boss hull from HullData.Boss and its products
HullData.Boss (key 33) is the flag; LoadoutGenerator.BossHullsOf is the one
owner of which Ship hulls are a faction's boss (the Boss hulls among the
designs of its products). AvailableProducts' candidate filter drops Boss
hulls, so one is never stock, traffic, a generated fit or a fallback boss.
GenerateShipLoadout splits into the draw and ShipLoadout(product).
Galaxy.PlaceFactionsMain and AetherDb factions/loadout read the derivation.
Derivation tests replace the two deleted BossHull tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:54:50 +02:00
4ab2ba15c7 Delete Faction.BossHull and its readers
Key 10 is retired (shipped values left in place). GenerateBossLoadout is
its fallback alone; the factions command no longer reads a named boss; the
two BossHull tests go (the named-but-unsold case cannot exist under a
product-first derivation). Galaxy.PlaceFactionsMain, the Exit_open test and
the derivation arrive in the next commit; this commit does not build.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:49:46 +02:00
12 changed files with 961 additions and 115 deletions

View file

@ -39,8 +39,7 @@ public class Faction
// Key 9 was the faction's name-file link, retired by ruling faction-curated-place-names; never reuse it.
[Inspectable, JsonProperty("bossHull"), Key(10)]
public CultRecordRef<HullData> BossHull;
// Key 10 was the faction's named boss hull, retired by boss-hull-derived: a faction's boss hulls are derived from the Boss hulls it makes; never reuse it.
[Inspectable, JsonProperty("influence"), Key(11)]
public int InfluenceDistance = 4;

View file

@ -288,9 +288,9 @@ public class Galaxy
progressCallback?.Invoke("Placing Factions");
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
// Choose some megas to have bosses placed based on whether a boss hull is assigned
// Choose some megas to have bosses placed: the factions with a boss to fly, a Boss hull and its Loadout
var bossMegas = Factions
.Where(m => m.BossHull.IsSet())
.Where(m => Loadouts.BossOf(_cache, m, out _) != null)
.Take(bossCount)
.ToArray();

View file

@ -555,6 +555,12 @@ public class HullData : EquippableItemData
[Inspectable, JsonProperty("visual"), Key(32)]
public CultRecordRef<ShipAuthoring> Visual;
// Only bosses fly this hull. Which factions it is a boss for comes from its products' makers
// (LoadoutGenerator.BossHullsOf), so the flag names no faction and reads no model. A boss's gear is its faction's
// fixed Loadout on this hull's product (Loadouts.BossOf), never generated.
[Inspectable, JsonProperty("boss"), Key(33)]
public bool Boss;
// The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world
// units: a true bound on how far the hull reaches from its position (the half-diagonal of the bounding box is not,
// as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5.

View file

@ -6,11 +6,11 @@ using GameCult.Caching;
using MessagePack;
using CultMath;
// A ship preset (a variant): the hull's design, and per hull cell a design and its rotation. A slot names a design and
// may name a product of it; a design-only slot is open on purpose, and whoever materializes the preset chooses its
// product from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which keeps a preset
// portable across galaxies. A preset that needs a brand names it. Authored catalog data: written in CultCache Studio or by the editor capture
// command, read at runtime.
// A Loadout: a hull together with item designs or products equipped to specific cells (ruling boss-gear-is-a-loadout).
// A slot names a design, and names a product only where the loadout demands one; a design-only slot's product is chosen
// by whoever materializes it from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which
// keeps a loadout portable across galaxies. Authored catalog data: written in CultCache Studio or by the AetherDb and
// editor capture commands, read at runtime.
[CultDocument("aetheria.loadout", "1"), MessagePackObject]
public class Loadout
{
@ -30,6 +30,8 @@ public class LoadoutSlot
[Key(3)] public CultRecordRef<FactionProductData> Product; // unset: design-only, chosen at materialization
}
public enum BossRefusal { None, NoBossHull, NoLoadout }
// The owner of loadouts: capture from a live ship, the only writer, and the only builder of an entity from a loadout.
public static class Loadouts
{
@ -66,30 +68,60 @@ public static class Loadouts
private static CultRecordRef<FactionProductData> ProductRef(ItemManager itemManager, CraftedItemInstance unit) =>
itemManager.Brand(unit).Product is { } product ? itemManager.ItemData.RefOf(product) : default;
// A preset's key derives from its name, so capturing the same name again addresses the same record.
// A loadout's key derives from its name, so capturing the same name again addresses the same record.
public static CultRecordKey KeyOf(string name) => new CultRecordKey("loadout:" + name);
// The only writer of presets. It opens its own short-lived cache over the catalog file, writable, and disposes it
// The one owner of what a faction's boss flies: the Loadout whose HullProduct is the product of the faction's first boss
// hull (LoadoutGenerator.BossHullsOf, dearest first), first by Name ordinal. Keyed on the boss hull's product through
// Loadout.HullProduct, never generated; reads no Prefab, Visual or Random. Null with the refusal when there is no boss
// hull or no Loadout for it.
public static Loadout BossOf(CultCache items, Faction faction, out BossRefusal refusal)
{
var hulls = LoadoutGenerator.BossHullsOf(items, faction);
if (hulls.Length == 0)
{
refusal = BossRefusal.NoBossHull;
return null;
}
var key = items.RefOf(hulls[0].product).Key;
var loadout = items.GetAll<Loadout>()
.Where(candidate => candidate.HullProduct.IsSet() && candidate.HullProduct.Key.Equals(key))
.OrderBy(candidate => candidate.Name, StringComparer.Ordinal)
.FirstOrDefault();
refusal = loadout == null ? BossRefusal.NoLoadout : BossRefusal.None;
return loadout;
}
// Names the field that is missing; echoes no input value and no record key.
public static string Describe(BossRefusal refusal) => refusal switch
{
BossRefusal.NoBossHull => "makes no ship hull with HullData.Boss set",
BossRefusal.NoLoadout => "no Loadout's HullProduct names its boss hull's product",
_ => ""
};
// 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 preset 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 preset of this name
// 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 preset changed on disk since this open is not clobbered: returns false then.
// 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)
{
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($"Preset '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a preset.");
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 preset capture never creates one.");
throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one.");
using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);
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)
throw new InvalidOperationException($"Preset '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first.");
throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first.");
var existing = cache.Get<Loadout>(key);
if (existing != null && !replace)
throw new InvalidOperationException($"Preset '{loadout.Name}' already exists; replace it explicitly.");
throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly.");
return cache.Commit(batch =>
{
batch.Expect(key, existing);
@ -104,7 +136,8 @@ public static class Loadouts
// WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on any failure
// nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from
// itemManager.Random. How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game
// spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does. The hull's type
// spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does, and so does the
// boss in ZoneGenerator, which is built here too from its faction's Loadout (Loadouts.BossOf). The hull's type
// picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is
// put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is
// padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots,
@ -119,7 +152,7 @@ public static class Loadouts
var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality);
var hullData = cache.Get(loadout.Hull);
if (hullData is { HullType: HullType.Station })
failures.Add($"hull: {hullData.Name} is a station hull; a preset builds a ship or a turret");
failures.Add($"hull: {hullData.Name} is a station hull; a loadout builds a ship or a turret");
var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray();
var groups = loadout.WeaponGroups ?? new int[0][];
var groupCount = itemManager.GameplaySettings.WeaponGroupCount;

View file

@ -184,7 +184,9 @@ public class LoadoutGenerator
}
// The products this faction can be offered for a design kind (only gear needs a home), and whether manufacturer preference applies (it does
// not once a required item has fallen back to any manufacturer). The one availability rule for every selection.
// not once a required item has fallen back to any manufacturer). The one availability rule for every selection: a Boss
// hull is never on offer, so it is never station stock, ordinary traffic or a generated scenario fit; a Boss hull
// reaches a ship only through its faction's boss Loadout (Loadouts.BossOf).
private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts<T>(Predicate<T> filter, bool required) where T : CraftedItemData
{
var hulls = SoldHulls(ItemManager.ItemData);
@ -193,6 +195,7 @@ public class LoadoutGenerator
.Where(entry =>
entry.design != null &&
entry.design.Price > 0 &&
!(entry.design is HullData { Boss: true }) &&
entry.product.Manufacturer.IsSet() &&
(!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) &&
(filter?.Invoke(entry.design) ?? true))
@ -209,25 +212,19 @@ public class LoadoutGenerator
return (available, preferManufacturers);
}
// A faction's boss: its BossHull when the faction sets one and a product sells it, else the most expensive ship hull
// the faction can be offered, with the gap logged. Null only when no ship hull is on offer at all.
public EntityPack GenerateBossLoadout()
{
var bossHull = Faction != null && Faction.BossHull.IsSet() ? ItemManager.ItemData.Get(Faction.BossHull) : null;
if (bossHull != null)
{
var boss = GenerateShipLoadout(hull => hull == bossHull);
if (boss != null) return boss;
ItemManager.Log($"{Faction.Name}'s BossHull {bossHull.Name} is not sold by any product; its boss flies the most expensive ship hull instead.");
}
else
ItemManager.Log($"{Faction?.Name ?? "This faction"} has no BossHull; its boss flies the most expensive ship hull instead.");
var (available, _) = AvailableProducts<HullData>(hull => hull.HullType == HullType.Ship, required: true);
if (available.Length == 0) return null;
var priciest = available.OrderByDescending(entry => entry.design.Price).ThenBy(entry => entry.design.Name, StringComparer.Ordinal).First().design;
return GenerateShipLoadout(hull => hull == priciest);
}
// The one owner of which hull is a faction's boss: the Ship hulls flagged Boss among the designs of the products the
// faction manufactures, dearest first then by name. The flag lives on the hull and the maker on the product, so a Boss
// hull several makers sell is a boss of each; nothing here reads a model, a prefab or a visual. Its readers are
// Loadouts.BossOf and AetherDb factions.
public static (FactionProductData product, HullData hull)[] BossHullsOf(CultCache items, Faction faction) =>
faction == null
? Array.Empty<(FactionProductData, HullData)>()
: items.GetAll<FactionProductData>()
.Where(product => items.Get(product.Manufacturer) == faction)
.Select(product => (product, hull: items.Get(product.Design) as HullData))
.Where(entry => entry.hull is { Boss: true, HullType: HullType.Ship })
.OrderByDescending(entry => entry.hull.Price).ThenBy(entry => entry.hull.Name, StringComparer.Ordinal).ThenBy(entry => entry.product.Name, StringComparer.Ordinal)
.ToArray();
// Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a
// bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station

View file

@ -397,17 +397,28 @@ public static class ZoneGenerator
}
// A boss zone gets its faction's boss whether or not the zone is ambient: the boss is the run's goal, not
// wandering traffic. RunGoal reads the mark; nothing else decides whether a boss lives.
// wandering traffic. A boss flies its faction's fixed Loadout, never generated gear (ruling boss-gear-is-a-loadout);
// ChooseProduct only picks the brand of a design-only slot. RunGoal reads the mark; nothing else decides whether a
// boss lives.
foreach (var bossFaction in galaxy.BossZones.Where(entry => entry.Value == galaxyZone).Select(entry => entry.Key))
{
var boss = GetLoadoutGenerator(bossFaction).GenerateBossLoadout();
if (boss == null)
var loadout = Loadouts.BossOf(itemManager.ItemData, bossFaction, out var refusal);
if (loadout == null)
{
itemManager.Log($"No boss could be generated for {bossFaction.Name}; its boss zone has no boss to kill!");
itemManager.Log($"{bossFaction.Name} has no boss: it {Loadouts.Describe(refusal)}.");
continue;
}
boss.Boss = true;
pack.Entities.Add(boss);
var failures = new List<string>();
var boss = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures);
if (boss == null)
{
itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}");
continue;
}
boss.Faction = bossFaction;
var bossPack = EntitySerializer.Pack(boss);
bossPack.Boss = true;
pack.Entities.Add(bossPack);
pack.BossSpawned = true;
}

View file

@ -0,0 +1,210 @@
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 boss-hull-derived; ruling boss-loadouts-agent-authored), run on a scratch
// copy of the shipped catalog. Commit writes one Loadout at Loadouts.KeyOf(name) with the records its names resolve to; every
// refusal leaves the catalog byte-identical; a Loadout it commits on a Boss hull's product is that maker's boss (Loadouts.BossOf).
public sealed class LoadoutCommitCommandTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "loadout-commit-" + Guid.NewGuid().ToString("N"));
private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc");
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(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");
}
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 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);
}
[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("mod-design")]
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;
case "mod-design":
var mod = (EquippableItemData) Copy(cache.GetAll<EquippableItemData>().Single(d => d.Name == fit.designName));
mod.Name = "Mod gadget";
Assert.True(cache.Commit(batch => batch.Upsert(mod.GetType(), mod, new CultRecordKey("mod-ship:mod.gadget"))));
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" },
"mod-design" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},{cell[2]},Mod gadget" },
_ => 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);
if (refusal == "mod-design") Assert.Contains("names mod designs", output);
Assert.Equal(before, Hash(CatalogPath));
using var after = Open();
Assert.Empty(after.GetAll<Loadout>());
}
// Ruling boss-loadouts-agent-authored end to end: a Boss hull and its maker's product in the catalog, a Loadout written by the
// command on that product, and the maker's boss is that Loadout.
[Fact]
public void A_loadout_committed_on_a_boss_hulls_product_is_its_makers_boss()
{
var fit = Fit();
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
T Copy<T>(T document) => MessagePack.MessagePackSerializer.Deserialize<T>(MessagePack.MessagePackSerializer.Serialize(document, options), options);
using (var cache = Open())
{
var likeProduct = cache.GetAll<FactionProductData>().Single(p => p.Name == fit.hullProduct);
var maker = cache.GetAll<Faction>().OrderBy(f => f.Name, StringComparer.Ordinal).Last();
var boss = Copy((HullData) cache.Get(likeProduct.Design));
boss.Name = "Command Boss";
boss.Boss = true;
var bossRef = cache.Upsert(boss);
var product = Copy(likeProduct);
product.Name = "Command Boss Mk I";
product.Design = new CultRecordRef<CraftedItemData>(bossRef.Key);
product.Manufacturer = cache.RefOf(maker);
cache.Upsert(product);
cache.FlushAsync().Wait();
}
Assert.Equal(0, Run(out _, "Command Boss loadout", "Command Boss Mk I", fit.slot));
using var reopened = Open();
var makerAfter = reopened.GetAll<Faction>().OrderBy(f => f.Name, StringComparer.Ordinal).Last();
var found = Loadouts.BossOf(reopened, makerAfter, out var refusal);
Assert.Equal(BossRefusal.None, refusal);
Assert.Equal("Command Boss loadout", found.Name);
Assert.Equal("Command Boss", reopened.Get(found.Hull).Name);
foreach (var other in reopened.GetAll<Faction>().Where(f => f != makerAfter))
Assert.Null(Loadouts.BossOf(reopened, other, out _));
}
}

View file

@ -8,6 +8,8 @@ using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using CultMath;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using UniRx;
using Xunit;
@ -16,9 +18,9 @@ using Xunit;
// and the run save.
public sealed partial class RunStartTests
{
private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true)
private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true, uint seed = 7)
{
items.Random = new CultMath.Random(7);
items.Random = new CultMath.Random(seed);
return ZoneGenerator.GenerateZone(items, _zoneSettings, _planetSettings, galaxy, zone, galaxy.IsPrelude, ambient);
}
@ -35,12 +37,435 @@ public sealed partial class RunStartTests
private HullData[] SoldShipHulls() => _cache.GetAll<FactionProductData>()
.Where(product => product.Manufacturer.IsSet())
.Select(product => _cache.Get(product.Design) as HullData)
.Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0)
.Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0 && !hull.Boss)
.Distinct().ToArray();
// A Boss hull a faction makes, by the production path: a copy of a sold ship hull (so it builds into a real ship) flagged
// Boss under its own name and price, and one copy of that hull's product per maker. A copy rather than the shipped
// hull itself, because flagging a shipped hull would make it a Boss hull of every maker the catalog gives it.
private HullData MakeBoss(string name, int price, params Faction[] makers) => MakeBoss(HullType.Ship, name, price, makers);
private HullData MakeBoss(HullType type, string name, int price, params Faction[] makers)
{
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
T Copy<T>(T document) => MessagePack.MessagePackSerializer.Deserialize<T>(MessagePack.MessagePackSerializer.Serialize(document, options), options);
var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First();
var likeProduct = _cache.GetAll<FactionProductData>().OrderBy(product => product.Name, StringComparer.Ordinal)
.First(product => _cache.Get(product.Design) == like);
var boss = Copy(like);
boss.Name = name;
boss.Price = price;
boss.HullType = type;
boss.Boss = true;
boss.Prefab = null;
boss.Visual = default;
var bossRef = _cache.Upsert(boss);
foreach (var maker in makers)
{
var product = Copy(likeProduct);
product.Name = $"{name} by {maker.Name}";
product.Design = new CultRecordRef<CraftedItemData>(bossRef.Key);
product.Manufacturer = _cache.RefOf(maker);
_cache.Upsert(product);
}
return _cache.Get(bossRef);
}
// A boss's fixed Loadout (ruling boss-gear-is-a-loadout), by the production capture: a generated fit of the hull MakeBoss
// copied, captured as a Loadout (Loadouts.Capture), cut down to its weapons so it is smaller than any generated fit (the
// tractor pickup and the rest are not in it), moved onto the boss hull and the maker's product of it, and stored at
// Loadouts.KeyOf(name), the key Loadouts.Commit writes. openRank leaves the slot whose design ranks highest (above 0) design-only.
// The shipped catalog has no Boss hull, so this and MakeBoss are fixtures.
private Loadout MakeBossLoadout(HullData boss, Faction maker, string name, Func<EquippableItemData, int> openRank = null)
{
var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First();
var random = new CultMath.Random(11);
var generator = new LoadoutGenerator(ref random, _items, null, null, null, .5f);
var ship = (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull == like));
var fit = Loadouts.Capture(_items, ship, name);
var armed = fit.Slots.Select((slot, index) => (slot, index))
.Where(entry => _cache.Get(entry.slot.Design).Behaviors.Any(behavior => behavior is WeaponData)).ToArray();
Assert.NotEmpty(armed);
Assert.True(armed.Length < fit.Slots.Count, "the generated fit needs gear besides weapons for this fixture to tell a Loadout from a fit");
var renumbered = armed.Select((entry, number) => (entry.index, number)).ToDictionary(entry => entry.index, entry => entry.number);
fit.WeaponGroups = fit.WeaponGroups.Select(group => group.Where(renumbered.ContainsKey).Select(index => renumbered[index]).ToArray()).ToArray();
fit.Slots = armed.Select(entry => entry.slot).ToList();
if (openRank != null)
{
// Only a design the Loadout fits once can be left open, so its products can be rearranged without touching a pinned slot.
var open = fit.Slots.Where(slot => fit.Slots.Count(other => other.Design.Key.Equals(slot.Design.Key)) == 1).OrderByDescending(slot => openRank(_cache.Get(slot.Design))).First();
Assert.True(openRank(_cache.Get(open.Design)) > 0, "no weapon of the fit can be left design-only for this maker");
open.Product = default;
}
fit.Hull = _cache.RefOf(boss);
fit.HullProduct = _cache.RefOf(_cache.GetAll<FactionProductData>().Single(product => _cache.Get(product.Design) == boss && _cache.Get(product.Manufacturer) == maker));
StoreLoadout(fit);
return fit;
}
// A Loadout record at Loadouts.KeyOf(Name), the key Loadouts.Commit writes it under.
private void StoreLoadout(Loadout loadout) =>
Assert.True(_cache.Commit(batch => batch.Upsert(typeof(Loadout), loadout, Loadouts.KeyOf(loadout.Name))));
// The demo's antagonist gets a Boss hull and its Loadout: the shipped catalog has neither.
private HullData GiveAntagonistABoss(Galaxy galaxy)
{
var antagonist = Zhestokost(galaxy);
var boss = MakeBoss("Antagonist Boss", 100, antagonist);
MakeBossLoadout(boss, antagonist, "Antagonist Boss loadout");
return boss;
}
// What a ship carries, cell by cell, as a Loadout would name it: position, rotation, design, product.
private static string[] Cells(ItemManager items, EntityPack pack)
{
var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, pack), "probe");
return captured.Slots.OrderBy(slot => slot.Position.x).ThenBy(slot => slot.Position.y)
.Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{items.ItemData.Get(slot.Design).Name},{(slot.Product.IsSet() ? items.ItemData.Get(slot.Product).Name : "-")}").ToArray();
}
private static Faction FactionNamed(Galaxy galaxy, string name) => galaxy.Factions.Single(faction => faction.Name == name);
private HullData PriciestSold() =>
SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First();
private LoadoutGenerator GeneratorFor(Galaxy galaxy, ItemManager items, Faction faction, uint seed)
{
var random = new CultMath.Random(seed);
return new LoadoutGenerator(ref random, items, galaxy, galaxy.Exit, faction, .5f);
}
// BossOf owns the boss. Zhestokost makes Boss A, B and C and a decoy ordinary hull. Loadouts sit on the decoy's product,
// on Boss B's and C's, and two on Boss A's: only Boss A's, first by name, is the boss's, whatever order the names fall in.
[Fact]
public void Boss_flies_its_factions_boss_loadout()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var cheapest = SoldShipHulls().Min(hull => hull.Price);
var priciest = PriciestSold();
Assert.True(cheapest < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart");
// B and A tie on price, C is cheaper: the boss is the dearest Boss hull, ties by name.
var decoy = new FactionProductData { Name = "Decoy", Design = new CultRecordRef<CraftedItemData>(_cache.RefOf(priciest).Key), Manufacturer = _cache.RefOf(antagonist) };
var decoyRef = _cache.Upsert(decoy);
var b = MakeBoss("Boss B", cheapest + 10, antagonist);
var c = MakeBoss("Boss C", cheapest, antagonist);
var a = MakeBoss("Boss A", cheapest + 10, antagonist);
MakeBossLoadout(b, antagonist, "Aaa loadout of Boss B");
MakeBossLoadout(c, antagonist, "Aab loadout of Boss C");
MakeBossLoadout(a, antagonist, "Zz second loadout of Boss A");
MakeBossLoadout(a, antagonist, "Zz first loadout of Boss A");
StoreLoadout(new Loadout
{
Name = "Aac loadout of the decoy", Hull = _cache.RefOf(priciest),
HullProduct = new CultRecordRef<FactionProductData>(decoyRef.Key)
});
Assert.Equal(new[] { "Boss A", "Boss B", "Boss C" }, LoadoutGenerator.BossHullsOf(_cache, antagonist).Select(entry => entry.hull.Name));
var loadout = Loadouts.BossOf(_cache, antagonist, out var refusal);
Assert.Equal(BossRefusal.None, refusal);
Assert.Equal("Zz first loadout of Boss A", loadout.Name);
var log = new List<string>();
var items = LoggingItems(log);
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal("Boss A", HullName(items, boss));
Assert.DoesNotContain(log, line => line.Contains("makes no boss hull"));
}
// Equal-priced Boss hulls go by hull name, and a hull sold under two products by product name, whatever order the products
// were authored or are named in: the first Boss hull is the same hull for every reader.
[Fact]
public void Equal_priced_boss_hulls_go_by_hull_name_then_product_name()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var y = MakeBoss("Tie Y", 100, antagonist);
var z = MakeBoss("Tie Z", 100, antagonist);
void Rename(HullData hull, string name)
{
var product = _cache.GetAll<FactionProductData>().Single(p => _cache.Get(p.Design) == hull);
product.Name = name;
Assert.True(_cache.Commit(batch => batch.Upsert(typeof(FactionProductData), product, _cache.RefOf(product).Key)));
}
Rename(y, "zz product of Y");
Rename(z, "aa product of Z");
var yProduct = _cache.GetAll<FactionProductData>().Single(p => p.Name == "zz product of Y");
_cache.Upsert(new FactionProductData { Name = "a second product of Y", Design = yProduct.Design, Manufacturer = yProduct.Manufacturer });
var made = LoadoutGenerator.BossHullsOf(_cache, antagonist);
Assert.Equal(new[] { "Tie Y", "Tie Y", "Tie Z" }, made.Select(entry => entry.hull.Name));
Assert.Equal(new[] { "a second product of Y", "zz product of Y", "aa product of Z" }, made.Select(entry => entry.product.Name));
}
// Ruling boss-gear-is-a-loadout: the boss carries its Loadout's cells and nothing else. The generated fit of the same hull
// carries more (a tractor pickup, consumables); none of it is on the boss. No generator draw decides it: another seed gives
// the same cells. A design-only slot takes its brand from the context's ChooseProduct (ruling
// loadout-product-optional-context-chooses); a pinned one keeps the product it names.
[Fact]
public void Boss_carries_exactly_its_loadouts_cells()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var hull = MakeBoss("Boss A", 100, antagonist);
var loadout = MakeBossLoadout(hull, antagonist, "Boss A loadout",
openRank: design => GeneratorFor(galaxy, _items, antagonist, 1) is var offered
&& _cache.GetAll<FactionProductData>().Any(product => product.Design.Key.Equals(_cache.RefOf(design).Key) && offered.IsAvailable(product)) ? 1 : 0);
Assert.True(loadout.Slots.Count > 1);
var open = Assert.Single(loadout.Slots, slot => !slot.Product.IsSet());
string Cell(LoadoutSlot slot, string brand) => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{_cache.Get(slot.Design).Name},{brand}";
var pinned = loadout.Slots.Where(slot => slot != open).Select(slot => Cell(slot, _cache.Get(slot.Product).Name)).OrderBy(x => x, StringComparer.Ordinal).ToArray();
// The context's choice, made unmistakable: the design has two products, one the antagonist's outfitter cannot get (listed
// first) and one it can. The open slot is built as the one it can, whatever the key order or the draw.
var template = _cache.GetAll<FactionProductData>().First(product => product.Design.Key.Equals(open.Design.Key));
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
FactionProductData Brand(string name, Faction maker)
{
var brand = MessagePack.MessagePackSerializer.Deserialize<FactionProductData>(MessagePack.MessagePackSerializer.Serialize(template, options), options);
brand.Name = name;
brand.Manufacturer = _cache.RefOf(maker);
return brand;
}
var outfitter = GeneratorFor(galaxy, _items, antagonist, 1);
var outsider = _cache.GetAll<Faction>().First(faction => !outfitter.IsAvailable(Brand("probe", faction)));
var made = Brand("A brand of an outsider", outsider);
var chosen = Brand("B brand of the antagonist", antagonist);
Assert.False(outfitter.IsAvailable(made));
Assert.True(outfitter.IsAvailable(chosen));
foreach (var product in _cache.GetAll<FactionProductData>().Where(product => product.Design.Key.Equals(open.Design.Key)).ToArray())
Assert.True(_cache.Remove(_cache.RefOf(product).Key));
_cache.Upsert(made);
_cache.Upsert(chosen);
var items = LoggingItems(new List<string>());
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
var cells = Cells(items, boss);
Assert.Equal(loadout.Slots.Count, cells.Length);
Assert.Equal(pinned, cells.Where(cell => !cell.StartsWith($"{open.Position.x},{open.Position.y},")).OrderBy(x => x, StringComparer.Ordinal));
var openCell = Assert.Single(cells, cell => cell.StartsWith($"{open.Position.x},{open.Position.y},"));
Assert.Equal(Cell(open, "B brand of the antagonist"), openCell);
var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, boss), "probe");
Assert.Equal(_cache.Get(loadout.HullProduct).Name, _cache.Get(captured.HullProduct).Name);
Assert.Equal(hull.Name, _cache.Get(captured.Hull).Name);
string[][] Groups(Loadout l) => l.WeaponGroups.Select(group => group.Select(i => $"{l.Slots[i].Position.x},{l.Slots[i].Position.y}").OrderBy(x => x, StringComparer.Ordinal).ToArray()).ToArray();
Assert.Equal(Groups(loadout).Where(group => group.Length > 0), Groups(captured).Where(group => group.Length > 0));
// No generator draw decides the gear: another seed builds the same pinned cells. Only the open slot's brand is the context's pick.
var otherItems = LoggingItems(new List<string>());
var other = Assert.Single(BossZonePack(galaxy, otherItems, galaxy.Exit, seed: 8).Entities, entity => entity.Boss);
var otherCells = Cells(otherItems, other);
Assert.Equal(pinned, otherCells.Where(cell => !cell.StartsWith($"{open.Position.x},{open.Position.y},")).OrderBy(x => x, StringComparer.Ordinal));
}
// No generated fallback: the shipped catalog has no Boss hull, so Zhestokost has no boss, however many ordinary hulls it can be offered.
[Fact]
public void Boss_without_a_boss_hull_is_refused()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
Assert.Equal(BossRefusal.NoBossHull, refusal);
var log = new List<string>();
var items = LoggingItems(log);
var pack = BossZonePack(galaxy, items, galaxy.Exit);
Assert.False(pack.BossSpawned);
Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("HullData.Boss"));
Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss"));
Assert.DoesNotContain(log, line => line.Contains("makes no boss hull"));
Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, null));
}
// No faction has no boss hulls, even when a Boss hull has a product with no maker (which a null faction would equal).
[Fact]
public void No_faction_has_no_boss_hull()
{
var galaxy = DemoGalaxy(1);
var orphan = MakeBoss("Orphan Boss", 100);
var key = _cache.RefOf(orphan).Key;
_cache.Upsert(new FactionProductData { Name = "Orphan product", Design = new CultRecordRef<CraftedItemData>(key) });
Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, null));
Assert.Null(Loadouts.BossOf(_cache, null, out var refusal));
Assert.Equal(BossRefusal.NoBossHull, refusal);
Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, Zhestokost(galaxy)));
}
// A Boss hull whose product has no Loadout is refused typed at the derivation. The refusal names the missing field and
// echoes no input: the canary hull name, its product's name and the record keys reach no log line.
[Fact]
public void A_boss_hull_without_a_loadout_is_refused()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var hull = MakeBoss("Canary7f3a hull", 100, antagonist);
var product = _cache.GetAll<FactionProductData>().Single(p => _cache.Get(p.Design) == hull);
// A Loadout on another maker's boss product is not this boss's.
var lucent = FactionNamed(galaxy, "Lucent Media");
MakeBossLoadout(MakeBoss("Other boss", 50, lucent), lucent, "Other boss loadout");
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
Assert.Equal(BossRefusal.NoLoadout, refusal);
var described = Loadouts.Describe(refusal);
Assert.Contains("HullProduct", described);
var log = new List<string>();
var items = LoggingItems(log);
var pack = BossZonePack(galaxy, items, galaxy.Exit);
Assert.False(pack.BossSpawned);
Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("HullProduct"));
foreach (var canary in new[] { "Canary7f3a", product.Name, _cache.RefOf(product).Key.Value, _cache.RefOf(hull).Key.Value })
{
Assert.DoesNotContain(log, line => line.Contains(canary));
Assert.DoesNotContain(canary, described);
}
}
// The key is the product, not the hull: a Boss hull both makers sell, with a Loadout on Lucent Media's product only.
[Fact]
public void A_loadout_on_another_makers_product_is_not_this_bosses()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var lucent = FactionNamed(galaxy, "Lucent Media");
var shared = MakeBoss("Shared Boss", 100, antagonist, lucent);
var loadout = MakeBossLoadout(shared, lucent, "Aaa Lucent only");
Assert.Equal(loadout.Name, Loadouts.BossOf(_cache, lucent, out var lucentRefusal).Name);
Assert.Equal(BossRefusal.None, lucentRefusal);
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
Assert.Equal(BossRefusal.NoLoadout, refusal);
Assert.False(BossZonePack(galaxy, LoggingItems(new List<string>()), galaxy.Exit).BossSpawned);
}
[Fact]
public void Another_factions_boss_hull_is_never_used()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var lucent = FactionNamed(galaxy, "Lucent Media");
// Dearer than every ordinary hull, and Lucent's alone, with its own Loadout: Zhestokost neither derives it nor falls back to it.
var luxury = MakeBoss("Lucent Flagship", PriciestSold().Price + 1000, lucent);
MakeBossLoadout(luxury, lucent, "Lucent Flagship loadout");
var log = new List<string>();
var items = LoggingItems(log);
var pack = BossZonePack(galaxy, items, galaxy.Exit);
Assert.False(pack.BossSpawned);
Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("HullData.Boss"));
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
Assert.Equal(BossRefusal.NoBossHull, refusal);
Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, antagonist));
Assert.Same(luxury, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull);
Assert.Equal("Lucent Flagship loadout", Loadouts.BossOf(_cache, lucent, out _).Name);
}
[Fact]
public void A_boss_hull_two_makers_sell_is_a_boss_of_each()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var lucent = FactionNamed(galaxy, "Lucent Media");
var shared = MakeBoss("Shared Boss", PriciestSold().Price - 1, antagonist, lucent);
// A Boss station hull the antagonist also makes is no boss: a boss is a ship.
MakeBoss(HullType.Station, "Boss Station", PriciestSold().Price + 1, antagonist);
MakeBossLoadout(shared, lucent, "Aaa loadout of Lucent");
MakeBossLoadout(shared, antagonist, "Zzz loadout of Zhestokost");
Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, antagonist)).hull);
Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull);
Assert.Equal("Zzz loadout of Zhestokost", Loadouts.BossOf(_cache, antagonist, out _).Name);
Assert.Equal("Aaa loadout of Lucent", Loadouts.BossOf(_cache, lucent, out _).Name);
var items = LoggingItems(new List<string>());
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal("Shared Boss", HullName(items, boss));
}
// Ruling hull-programmer-art-reads-as-stand-in: a boss hull may be an agent's stopgap with no authored model.
[Fact]
public void A_boss_hull_needs_no_authored_model()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var bare = MakeBoss("Bare Boss", 1, antagonist);
MakeBossLoadout(bare, antagonist, "Bare Boss loadout");
Assert.Null(bare.Prefab);
Assert.False(bare.Visual.IsSet());
Assert.Same(bare, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, antagonist)).hull);
var items = LoggingItems(new List<string>());
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal("Bare Boss", HullName(items, boss));
}
// A Boss hull is flown only by bosses: with the cheapest price (the heaviest draw weight) and every relevant maker, it still
// never comes out of a generation draw, including the any-manufacturer fallback and a faction no maker is allied to.
[Fact]
public void Boss_hulls_are_never_stock_or_traffic()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var lucent = FactionNamed(galaxy, "Lucent Media");
var bossShip = MakeBoss("Traffic Boss", 1, antagonist, lucent);
var bossStation = MakeBoss(HullType.Station, "Stock Boss", 1, antagonist, lucent);
var stranger = new Faction { Name = "Stranger" };
_cache.Upsert(stranger);
stranger = _cache.GetAll<Faction>().Single(faction => faction.Name == "Stranger");
MakeBoss("Strangers Foe Boss", 1, lucent);
var log = new List<string>();
var items = LoggingItems(log);
var drawn = 0;
for (uint seed = 1; seed <= 20; seed++)
foreach (var faction in new[] { antagonist, lucent, stranger, null })
{
var generator = GeneratorFor(galaxy, items, faction, seed);
for (var i = 0; i < 3; i++)
{
var pack = generator.GenerateShipLoadout();
Assert.NotNull(pack);
drawn++;
Assert.DoesNotContain(HullName(items, pack), new[] { "Traffic Boss", "Strangers Foe Boss" });
Assert.NotEqual("Stock Boss", generator.RandomHull(HullType.Station).design?.Name);
}
// Asked for by name, with the boss the only candidate: nothing is on offer, however the fallback is reached.
Assert.Null(generator.GenerateShipLoadout(hull => hull == bossShip));
Assert.Null(generator.GenerateShipLoadout(hull => hull.Name == "Strangers Foe Boss"));
Assert.Null(generator.RandomHull(HullType.Station, hull => hull == bossStation).design);
}
Assert.Equal(20 * 4 * 3, drawn);
}
// PlaceFactionsMain picks through BossOf: a faction with a Boss hull and no Loadout places nothing, and the faction that
// also has the Loadout is the one entry, though another faction sorts first.
[Fact]
public void Main_galaxy_bosses_are_factions_with_a_boss_loadout()
{
Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones);
var makers = _cache.GetAll<Faction>().OrderBy(faction => faction.Name, StringComparer.Ordinal).Take(2).ToArray();
MakeBoss("Unarmed Boss", 100, makers[0]);
var armed = MakeBoss("Main Boss", 100, makers[1]);
Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones);
MakeBossLoadout(armed, makers[1], "Main Boss loadout");
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
var bossFaction = Assert.Single(galaxy.BossZones.Keys);
Assert.Equal(makers[1].Name, bossFaction.Name);
Assert.NotNull(Loadouts.BossOf(_cache, bossFaction, out _));
}
[Fact]
public void Boss_spawns_once_in_boss_zone()
{
var antagonistsBoss = GiveAntagonistABoss(DemoGalaxy(DemoSeeds.First()));
foreach (var seed in DemoSeeds.Take(3))
{
var galaxy = DemoGalaxy(seed);
@ -53,6 +478,7 @@ public sealed partial class RunStartTests
var boss = Assert.Single(bosses);
Assert.IsType<ShipPack>(boss);
Assert.Same(antagonist, _cache.Get(boss.Faction));
Assert.Equal(antagonistsBoss.Name, HullName(_items, boss));
// Generation is repeatable per zone: the same zone yields the same boss hull.
var again = Assert.Single(BossZonePack(galaxy, _items, zone).Entities, entity => entity.Boss);
Assert.Equal(HullName(_items, boss), HullName(_items, again));
@ -65,59 +491,17 @@ public sealed partial class RunStartTests
public void Boss_spawns_in_a_zone_that_is_not_ambient()
{
var galaxy = DemoGalaxy(1);
GiveAntagonistABoss(galaxy);
var pack = BossZonePack(galaxy, _items, galaxy.Exit, ambient: false);
Assert.Single(pack.Entities, entity => entity.Boss);
Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack);
}
[Fact]
public void Boss_hull_from_BossHull_else_fallback()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var hulls = SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).ToArray();
var priciest = hulls.First();
var cheaper = hulls.Last();
Assert.True(cheaper.Price < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart");
// Set and sold: the boss flies it, and nothing is logged about a gap.
antagonist.BossHull = _cache.RefOf(cheaper);
var log = new List<string>();
var items = LoggingItems(log);
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal(cheaper.Name, HullName(items, boss));
Assert.DoesNotContain(log, line => line.Contains("BossHull"));
// Unset: the most expensive ship hull on offer, and the log names the missing BossHull.
antagonist.BossHull = default;
log.Clear();
boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal(priciest.Name, HullName(items, boss));
Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("no BossHull"));
}
// Set but sold by no product: the boss falls back the same way, and the log says which hull has no seller.
[Fact]
public void Boss_hull_nobody_sells_falls_back()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var sold = SoldShipHulls();
// A ship hull no product sells, dearer than any that is sold: the fallback must not pick it.
var unsold = new HullData { Name = "Unsold Test Hull", HullType = HullType.Ship, Price = sold.Max(hull => hull.Price) + 1 };
_cache.Upsert(unsold);
antagonist.BossHull = _cache.RefOf(unsold);
var log = new List<string>();
var items = LoggingItems(log);
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal(sold.OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First().Name, HullName(items, boss));
Assert.Contains(log, line => line.Contains(unsold.Name) && line.Contains("BossHull"));
}
[Fact]
public void Exit_sealed_until_boss_dies()
{
var galaxy = DemoGalaxy(1);
GiveAntagonistABoss(galaxy);
var exit = ZoneOf(galaxy, galaxy.Exit);
var boss = Assert.Single(exit.Entities, entity => entity.IsBoss);
Assert.True(RunGoal.BossAlive(exit));
@ -138,6 +522,7 @@ public sealed partial class RunStartTests
[Fact]
public void Boss_mark_survives_continue()
{
GiveAntagonistABoss(DemoGalaxy(1));
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1));
Assert.True(failures.Count == 0, string.Join("; ", failures));
galaxy.Entrance.Contents = arena;
@ -196,10 +581,10 @@ public sealed partial class RunStartTests
public void Exit_open_only_in_the_exit_zone()
{
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
// The shipped catalog sets no BossHull outside the demo's antagonist, so the main galaxy places no boss: give a
// faction one and a boss zone that is not the exit, as the generator would for a faction with a boss hull.
// The shipped catalog has no Boss hull, so the main galaxy places no boss: give a faction one and a boss zone that is
// not the exit, as the generator would for a faction that makes a Boss hull.
var faction = galaxy.Factions.First();
faction.BossHull = _cache.RefOf(SoldShipHulls().First());
MakeBossLoadout(MakeBoss("Gate Boss", 100, faction), faction, "Gate Boss loadout");
var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit);
galaxy.BossZones[faction] = bossZone;
var zone = ZoneOf(galaxy, bossZone);
@ -211,19 +596,27 @@ public sealed partial class RunStartTests
Assert.False(RunGoal.ExitOpen(zone));
}
// A boss that cannot be generated (no ship hull on offer at all) never marks the zone as having spawned one.
// A boss Loadout whose slot does not fit gives no boss: the log names the Loadout and the fit failure, and the gate stays sealed.
[Fact]
public void Exit_sealed_when_no_ship_hull_is_on_offer()
public void An_unbuildable_boss_loadout_spawns_no_boss()
{
var galaxy = DemoGalaxy(1);
foreach (var product in _cache.GetAll<FactionProductData>().Where(product => _cache.Get(product.Design) is HullData { HullType: HullType.Ship }).ToArray())
Assert.True(_cache.Remove(_cache.RefOf(product).Key));
var antagonist = Zhestokost(galaxy);
var hull = MakeBoss("Boss A", 100, antagonist);
var loadout = MakeBossLoadout(hull, antagonist, "Unfittable loadout");
loadout.Slots[0].Position = new int2(999, 999);
loadout.Slots[1].Position = new int2(998, 998);
StoreLoadout(loadout);
Assert.Equal("Unfittable loadout", Loadouts.BossOf(_cache, antagonist, out _).Name);
var log = new List<string>();
var items = LoggingItems(log);
var pack = BossZonePack(galaxy, items, galaxy.Exit, ambient: false);
Assert.False(pack.BossSpawned);
Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
Assert.Contains(log, line => line.Contains("No boss could be generated for Zhestokost"));
var refusal = Assert.Single(log, line => line.Contains("Unfittable loadout") && line.Contains("does not fit"));
Assert.Equal(2, Regex.Matches(refusal, "does not fit").Count);
Assert.Contains("; ", refusal);
Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy)));
}
@ -232,6 +625,7 @@ public sealed partial class RunStartTests
public void Exit_open_after_save_of_a_slain_boss()
{
var galaxy = DemoGalaxy(1);
GiveAntagonistABoss(galaxy);
var exit = ZoneOf(galaxy, galaxy.Exit);
Assert.Single(exit.Entities, entity => entity.IsBoss).HeatstrokeDeath.OnNext(Unit.Default);
var repacked = exit.PackZone();

View file

@ -99,6 +99,25 @@ public sealed class ShipProductTests : IDisposable
}
}
// The package path of the derivation: the flag survives compose on the hull, and the maker is the package's product's.
[Fact]
public void ABossPackageIsItsMakersBossAfterCompose()
{
Environment.CurrentDirectory = _root.Path;
var path = Package("mod.boss", "Bosshull", 100, Product("Acme Boss", _acme, "mod.boss"));
Assert.Equal(0, Run("boss", path, "on").Code);
var composition = ShipModCatalog.Compose(Shipped, Derived, Mods);
Assert.Equal(new[] { "mod.boss" }, composition.Included);
using var cache = AetheriaStores.Open(Derived);
var hull = Assert.IsType<HullData>(cache.Get(new CultRecordRef<CraftedItemData>(ShipModCatalog.HullKey("mod.boss"))));
Assert.True(hull.Boss);
var acme = cache.Get<Faction>(_acme);
var rival = cache.Get<Faction>(_rival);
Assert.Same(hull, Assert.Single(LoadoutGenerator.BossHullsOf(cache, acme)).hull);
Assert.Empty(LoadoutGenerator.BossHullsOf(cache, rival));
}
[Fact]
public void APackageWithAnUnknownMakerIsExcluded()
{
@ -299,6 +318,37 @@ public sealed class ShipProductTests : IDisposable
RefusedAndUntouched("product", path, "ACM", "--remove");
}
[Fact]
public void BossCommand()
{
Environment.CurrentDirectory = _root.Path;
var path = Package("mod.skiff", "Skiffhull", 100, Product("Acme Skiff", _acme, "mod.skiff"));
void RefusedAndUntouched(params string[] args)
{
var before = File.ReadAllBytes(path);
Assert.Equal(1, Run(args).Code);
Assert.Equal(before, File.ReadAllBytes(path));
}
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(FactionProductData).Assembly);
byte[] ProductBytes() => MessagePack.MessagePackSerializer.Serialize(ShipAuthoringStore.Load(path).Products.Single(), options);
var productBefore = ProductBytes();
Assert.False(ShipAuthoringStore.Read(path).Hull.Boss);
RefusedAndUntouched("boss", path, "yes");
RefusedAndUntouched("boss", path, "ON");
RefusedAndUntouched("boss", path);
Assert.Equal(0, Run("boss", path, "on").Code);
Assert.True(ShipAuthoringStore.Read(path).Hull.Boss);
Assert.Equal(productBefore, ProductBytes());
Assert.Contains(", boss", Run("inspect", path).Output);
Assert.Equal(0, Run("boss", path, "off").Code);
Assert.False(ShipAuthoringStore.Read(path).Hull.Boss);
Assert.Equal(productBefore, ProductBytes());
Assert.DoesNotContain(", boss", Run("inspect", path).Output);
}
[Fact]
public void ValidateJudgesProductsOnADraftAndInspectListsThem()
{

View file

@ -0,0 +1,125 @@
/* 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]
// (aetheria-release, cut boss-hull-derived; ruling boss-loadouts-agent-authored): writes one Loadout record into
// GameData/Aetheria.cc through Loadouts.Commit, the only writer. Every name is resolved exactly (one hull product, one design per
// slot, one product where a slot names one, a rotation by its ItemRotation name), then the loadout is 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]";
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;
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] == "--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;
}
var db = AetherDb.Open(root: root);
var catalogPath = Path.Combine(db.Root, "GameData", "Aetheria.cc");
var loadout = Build(db.Cache, positional[0], positional[1], positional.Skip(2).ToArray(), groups);
var failures = new List<string>();
var settings = AuthoredSettings.Load(db.Root).Read<GameplaySettings>("GameplaySettings");
var itemManager = new ItemManager(db.Cache, new ProvenanceLedger(), settings, _ => { });
var entity = Loadouts.Materialize(itemManager, null, loadout, design => ProductsOf(db.Cache, design).FirstOrDefault(), failures);
if (entity == null) throw new InvalidOperationException($"the loadout cannot be built: {string.Join("; ", failures)}");
var hull = db.Cache.Get(loadout.Hull);
var product = db.Cache.Get(loadout.HullProduct);
var slots = loadout.Slots.Count;
(db.Cache as IDisposable)?.Dispose();
if (!Loadouts.Commit(catalogPath, loadout, replace))
throw new InvalidOperationException("the catalog changed since it was read; nothing was written");
output.WriteLine($"{loadout.Name}: committed, hull {hull.Name} ({product.Name}), {slots} slots");
return 0;
}
// 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");
}

View file

@ -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());
@ -63,7 +64,7 @@ public static class Program
return PlaceNamesCommand.Run(args.Skip(1).ToArray());
case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out);
default:
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <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 [apply], doctrine-catalog [apply], assembly-from-maker [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], 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 [apply], doctrine-catalog [apply], assembly-from-maker [apply]");
return 1;
}
}
@ -583,8 +584,7 @@ public static class Program
// Every CultRecordRef in the catalog that resolves to nothing, as "<record> <DeclaringType.Member> -> <key>".
// Each "clear <DeclaringType.Member>" argument unsets that member's dangling refs, or removes that dictionary's
// dangling keys (Faction.BossHull clears boss hulls that would claim a chokepoint no boss can spawn in). Dry
// run unless passed "apply", which alone opens the catalog writable and lands every changed record in one commit.
// dangling keys. Dry run unless passed "apply", which alone opens the catalog writable and lands every changed record in one commit.
private static int Dangling(string[] args)
{
var apply = args.Contains("apply");
@ -696,25 +696,22 @@ public static class Program
}
// Each faction's generation-critical links and the size of its curated place list. An empty list is not broken:
// Galaxy.NamePlaces falls back to catalogue designations for a faction without one.
// Galaxy.NamePlaces falls back to catalogue designations for a faction without one. The boss column is derived
// through Loadouts.BossOf, never a field on the faction, and is display only.
private static int Factions()
{
var db = AetherDb.Open();
var products = db.Cache.GetAll<FactionProductData>().ToArray();
Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss hull",-14} {"influence",-9} {"doctrine",-10} products");
var broken = 0;
Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss",-14} {"influence",-9} {"doctrine",-10} products");
foreach (var faction in db.Cache.GetAll<Faction>().OrderBy(f => f.Name))
{
var key = db.Cache.RefOf(faction).Key;
var boss = !faction.BossHull.IsSet()
? "none"
: db.Cache.Get(faction.BossHull)?.Name ?? "DANGLING";
if (boss == "DANGLING") broken++;
var bossLoadout = Loadouts.BossOf(db.Cache, faction, out var refusal);
var boss = bossLoadout?.Name ?? (refusal == BossRefusal.NoLoadout ? "NO LOADOUT" : "none");
Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {faction.Places.Length,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " +
products.Count(p => p.Manufacturer.Key.Equals(key)));
}
Console.WriteLine($"\n{broken} factions carry a link that would break generation");
return broken;
return 0;
}
// What the saved run actually holds per zone. A zone carrying orbits but no orbital entities was packed by a
@ -808,6 +805,15 @@ public static class Program
var generator = new LoadoutGenerator(ref random, itemManager, null, null, null, .5f);
log.Clear();
string outcome;
if (hull.Boss && hull.HullType == HullType.Ship)
{
// A boss hull is never generated: it flies the Loadout authored on one of its products.
var productKeys = db.Cache.GetAll<FactionProductData>().Where(p => db.Cache.Get(p.Design) == hull).Select(p => db.Cache.RefOf(p).Key).ToArray();
var authored = db.Cache.GetAll<Loadout>().Where(l => l.HullProduct.IsSet() && productKeys.Contains(l.HullProduct.Key)).Select(l => l.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray();
if (authored.Length == 0) failures++;
Console.WriteLine($"{hull.Name,-12} ({hull.HullType,-7}) {(authored.Length == 0 ? "NO LOADOUT (boss hull: author one with loadout-commit)" : "boss loadouts: " + string.Join(", ", authored))}");
continue;
}
try
{
var pack = hull.HullType switch
@ -816,7 +822,7 @@ public static class Program
HullType.Turret => generator.GenerateTurretLoadout(),
_ => generator.GenerateStationLoadout()
};
outcome = pack == null ? "NO LOADOUT (nothing suitable found)" : "ok";
outcome = pack != null ? "ok" : "NO LOADOUT (nothing suitable found)";
if (pack == null) failures++;
if (pack != null && log.Any(l => l.Contains("targeting system available", StringComparison.OrdinalIgnoreCase))) targetingGaps++;
}

View file

@ -8,7 +8,7 @@ using System.Linq;
// Uses the existing database executable; mod authoring does not earn another binary target.
public static class ShipAuthoringCommands
{
private const string Usage = "ship-authoring create <ship.cc> <stable-id> <name> [--like <shipped hull name>] | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] | --remove) | price <ship.cc> <price> | compose <shipped.cc> <derived.cc> <mods-dir>";
private const string Usage = "ship-authoring create <ship.cc> <stable-id> <name> [--like <shipped hull name>] | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] | --remove) | price <ship.cc> <price> | boss <ship.cc> on|off | compose <shipped.cc> <derived.cc> <mods-dir>";
public static int Run(string[] args)
{
@ -29,7 +29,7 @@ public static class ShipAuthoringCommands
if (args[0] == "inspect" && args.Length == 2)
{
var (hull, ship, products) = ShipAuthoringStore.Load(args[1]);
Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points");
Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points{(hull.Boss ? ", boss" : "")}");
foreach (var product in products) Console.WriteLine($"product {product.Manufacturer.Key.Value}: {product.Name}");
return 0;
}
@ -91,6 +91,21 @@ public static class ShipAuthoringCommands
Console.WriteLine($"{ship.Id}: price {price}.");
return 0;
}
if (args[0] == "boss" && args.Length == 3)
{
// Judged before the file is touched: a refusal leaves it byte-identical. Sets the hull's flag and no product.
if (args[2] != "on" && args[2] != "off")
throw new InvalidOperationException("boss must be on or off.");
var (hull, ship, _) = ShipAuthoringStore.Load(args[1]);
hull.Boss = args[2] == "on";
using (var cache = ShipAuthoringStore.Open(args[1], writable: true))
{
ShipAuthoringStore.Write(cache, hull, ship);
cache.FlushAsync().Wait();
}
Console.WriteLine($"{ship.Id}: boss {args[2]}.");
return 0;
}
if (args[0] == "product" && args.Length >= 4)
{
var remove = args.Length == 4 && args[3] == "--remove";