Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90f977bf19 | |||
| e47bb27ef8 | |||
| d912cf44d2 | |||
| a74de35c82 | |||
| a0a650db7a | |||
| 6463e79317 | |||
| fc992f5abe | |||
| e385e7ce0e | |||
| e71667aab1 | |||
| 5d624f9e6d | |||
| bfcde46c0f | |||
| 095baf2fb5 | |||
| 6b602dd34a | |||
| 1b5e989182 | |||
| 4ab2ba15c7 |
12 changed files with 961 additions and 115 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
210
tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs
Normal file
210
tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs
Normal 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 _));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
125
tools/AetherDb/LoadoutCommitCommand.cs
Normal file
125
tools/AetherDb/LoadoutCommitCommand.cs
Normal 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");
|
||||
}
|
||||
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue