Merge remote-tracking branch 'origin/master' into eureka/aetheria-release-package-agent-hull-guards
This commit is contained in:
commit
2f4b617e50
11 changed files with 913 additions and 147 deletions
|
|
@ -642,9 +642,9 @@ public class ActionGameManager : MonoBehaviour
|
|||
{
|
||||
var written = Loadouts.Commit(CatalogPath, Loadouts.Capture(ItemManager, ship, name), replace);
|
||||
console.AppendLogLine(written
|
||||
? $"Preset '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " +
|
||||
"Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured presets."
|
||||
: $"Preset '{name}' changed on disk since load; nothing written");
|
||||
? $"Loadout '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " +
|
||||
"Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured loadouts."
|
||||
: $"Loadout '{name}' changed on disk since load; nothing written");
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 boss hull reference, retired by ruling faction-boss-is-a-loadout-reference; never reuse it.
|
||||
|
||||
[Inspectable, JsonProperty("influence"), Key(11)]
|
||||
public int InfluenceDistance = 4;
|
||||
|
|
@ -66,7 +65,7 @@ public class Faction
|
|||
// The maker's final assembly: the distribution every lot of theirs draws its own quality from, and the hard ceiling on each
|
||||
// of its roles (quality-lean-assembly-hard-ceiling). Null is not authored: a lot of theirs assembles at AssemblyDistribution's
|
||||
// defaults. assembly-from-maker derives it once at the part mean plus a tolerance margin (quality-min-tolerance-binned-assembly);
|
||||
// tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery.
|
||||
// tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery. Key 20 was assemblyDeviation (retired); never reuse it.
|
||||
[Inspectable, JsonProperty("assembly"), Key(19)]
|
||||
public AssemblyDistribution Assembly;
|
||||
|
||||
|
|
@ -74,6 +73,11 @@ public class Faction
|
|||
// Authored by AetherDb place-names, which restores the table on every apply.
|
||||
[Inspectable, JsonProperty("places"), Key(21)]
|
||||
public FactionPlace[] Places = Array.Empty<FactionPlace>();
|
||||
|
||||
// The Loadout this faction's boss flies; unset means the faction has no boss (ruling faction-boss-is-a-loadout-reference).
|
||||
// The one owner of which Loadout is its boss, read through Loadouts.BossOf.
|
||||
[Inspectable, JsonProperty("bossLoadout"), Key(22)]
|
||||
public CultRecordRef<Loadout> BossLoadout;
|
||||
}
|
||||
|
||||
// Historical, narrative and strategic intent join as keys 2+ when economics-driven placement lands (ruling faction-curated-place-names).
|
||||
|
|
|
|||
|
|
@ -289,9 +289,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 whose boss Loadout resolves
|
||||
var bossMegas = Factions
|
||||
.Where(m => m.BossHull.IsSet())
|
||||
.Where(m => Loadouts.BossOf(_cache, m, out _) != null)
|
||||
.Take(bossCount)
|
||||
.ToArray();
|
||||
|
||||
|
|
|
|||
|
|
@ -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. A faction's boss flies one (Faction.BossLoadout). 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,9 @@ public class LoadoutSlot
|
|||
[Key(3)] public CultRecordRef<FactionProductData> Product; // unset: design-only, chosen at materialization
|
||||
}
|
||||
|
||||
// Why a faction has no boss Loadout to build; see Loadouts.BossOf.
|
||||
public enum BossRefusal { None, Unset, Missing }
|
||||
|
||||
// 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 +69,54 @@ 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 resolver of a faction's boss reference (ruling faction-boss-is-a-loadout-reference): the Loadout
|
||||
// Faction.BossLoadout names. The boss is never generated, so this reads nothing but the reference and the catalog. Null with
|
||||
// Unset when the faction sets none, and null with Missing when it sets one the catalog does not hold.
|
||||
public static Loadout BossOf(CultCache items, Faction faction, out BossRefusal refusal)
|
||||
{
|
||||
if (!faction.BossLoadout.IsSet())
|
||||
{
|
||||
refusal = BossRefusal.Unset;
|
||||
return null;
|
||||
}
|
||||
|
||||
var loadout = items.Get(faction.BossLoadout);
|
||||
refusal = loadout == null ? BossRefusal.Missing : BossRefusal.None;
|
||||
return loadout;
|
||||
}
|
||||
|
||||
// Names the faction's field, and echoes no key or name of the input.
|
||||
public static string Describe(BossRefusal refusal) => refusal switch
|
||||
{
|
||||
BossRefusal.Unset => "sets no Faction.BossLoadout",
|
||||
BossRefusal.Missing => "names a Faction.BossLoadout that is not in the catalog",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
|
@ -101,14 +128,19 @@ public static class Loadouts
|
|||
public const float UnbrandedQuality = .95f;
|
||||
|
||||
// All-or-nothing: returns an entity only when every design resolved and fitted, the loadout has at most
|
||||
// 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
|
||||
// WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure of a stage is listed; on any
|
||||
// failure nothing is returned and the zone and cache are unchanged. The stages run in the order that puts refusal
|
||||
// first: what the catalog alone decides (designs, pinned products, hull kind, weapon groups), then whether the cells
|
||||
// fit, tried on a throwaway item manager with its own ledger and stream, and only then the choice of a design-only
|
||||
// slot's product (choose may draw) and the minting of lots. So a loadout that cannot be built mints no lot in the live
|
||||
// ledger and draws nothing from itemManager.Random or from choose. The one exception is a design-only slot with no
|
||||
// available product, which only choose can tell: it is found after an earlier slot's choice, and it too mints nothing.
|
||||
// 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, 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,
|
||||
// so the next commit drops them. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states
|
||||
// padded with empty groups. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states
|
||||
// one for a fit its checks rest on).
|
||||
public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
|
||||
Func<CraftedItemData, FactionProductData> choose, List<string> failures, float? quality = null)
|
||||
|
|
@ -116,11 +148,11 @@ public static class Loadouts
|
|||
var cache = itemManager.ItemData;
|
||||
var reported = failures.Count;
|
||||
|
||||
var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality);
|
||||
var hullSettled = Settle(itemManager, loadout.Hull, loadout.HullProduct, "hull", failures);
|
||||
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");
|
||||
var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray();
|
||||
failures.Add($"hull: {hullData.Name} is a station hull; a loadout builds a ship or a turret");
|
||||
var slotsSettled = loadout.Slots.Select(slot => Settle(itemManager, slot.Design, slot.Product, Cell(slot), failures)).ToArray();
|
||||
var groups = loadout.WeaponGroups ?? new int[0][];
|
||||
var groupCount = itemManager.GameplaySettings.WeaponGroupCount;
|
||||
if (groups.Length > groupCount) failures.Add($"weapon groups: {groups.Length} groups, at most {groupCount}");
|
||||
|
|
@ -132,12 +164,43 @@ public static class Loadouts
|
|||
}
|
||||
if (failures.Count > reported) return null;
|
||||
|
||||
// The cells fit or they do not whatever brand a design-only slot takes: the shape is the design's. Tried on its own
|
||||
// item manager, so nothing is minted into the live ledger and nothing is drawn.
|
||||
var scratch = new ItemManager(cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { });
|
||||
Assemble(scratch, null, hullData, loadout, Unbranded(scratch, hullSettled), slotsSettled.Select(settled => Unbranded(scratch, settled)).ToArray(), failures, out _);
|
||||
if (failures.Count > reported) return null;
|
||||
|
||||
var hullBuild = Build(itemManager, hullSettled, choose, quality, failures);
|
||||
var slotBuilds = slotsSettled.Select(settled => Build(itemManager, settled, choose, quality, failures)).ToArray();
|
||||
if (failures.Count > reported) return null;
|
||||
|
||||
var entity = Assemble(itemManager, liveZone, hullData, loadout, hullBuild, slotBuilds, failures, out var equipped);
|
||||
if (entity == null) return null;
|
||||
|
||||
var built = new (List<Weapon> weapons, List<EquippedItem> items)[groupCount];
|
||||
for (var g = 0; g < groupCount; g++)
|
||||
{
|
||||
var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List<EquippedItem>();
|
||||
built[g] = (items.Select(item => item.GetBehavior<Weapon>()).ToList(), items);
|
||||
}
|
||||
entity.WeaponGroups = built;
|
||||
return entity;
|
||||
}
|
||||
|
||||
// The hull, then each slot's gear, made by the given builders and equipped to the loadout's cells: the one placement of
|
||||
// a loadout, run once on the throwaway manager to decide the fit and once on the live one to build. Null with every
|
||||
// misfit listed when any cell does not take its gear.
|
||||
private static Entity Assemble(ItemManager itemManager, Zone liveZone, HullData hullData, Loadout loadout,
|
||||
Func<CraftedItemInstance> hullBuild, Func<CraftedItemInstance>[] slotBuilds, List<string> failures, out EquippedItem[] equipped)
|
||||
{
|
||||
var reported = failures.Count;
|
||||
var cache = itemManager.ItemData;
|
||||
var hull = (EquippableItem) hullBuild();
|
||||
var settings = itemManager.GameplaySettings.DefaultEntitySettings;
|
||||
Entity entity = hullData.HullType == HullType.Turret
|
||||
? new OrbitalEntity(itemManager, liveZone, hull, default, settings)
|
||||
: new Ship(itemManager, liveZone, hull, settings);
|
||||
var equipped = new EquippedItem[loadout.Slots.Count];
|
||||
equipped = new EquippedItem[loadout.Slots.Count];
|
||||
for (var i = 0; i < loadout.Slots.Count; i++)
|
||||
{
|
||||
var slot = loadout.Slots[i];
|
||||
|
|
@ -154,26 +217,22 @@ public static class Loadouts
|
|||
.Concat(entity.DockingBays)
|
||||
.First(item => item.EquippableItem == unit);
|
||||
}
|
||||
if (failures.Count > reported) return null;
|
||||
|
||||
var built = new (List<Weapon> weapons, List<EquippedItem> items)[groupCount];
|
||||
for (var g = 0; g < groupCount; g++)
|
||||
{
|
||||
var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List<EquippedItem>();
|
||||
built[g] = (items.Select(item => item.GetBehavior<Weapon>()).ToList(), items);
|
||||
}
|
||||
entity.WeaponGroups = built;
|
||||
return entity;
|
||||
return failures.Count > reported ? null : entity;
|
||||
}
|
||||
|
||||
// How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A
|
||||
// pinned product is built as named, whatever choose says; it must be a product of the design. A design with no
|
||||
// product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through
|
||||
// products) and is built unbranded, with no maker, at UnbrandedQuality (a stated quality replaces it). Otherwise choose names the product, and
|
||||
// null (nothing available) or a product of another design is a failure.
|
||||
public static Func<CraftedItemInstance> Resolve<T>(ItemManager itemManager, CultRecordRef<T> design,
|
||||
CultRecordRef<FactionProductData> product, Func<CraftedItemData, FactionProductData> choose, string where,
|
||||
List<string> failures, float? quality = null) where T : CraftedItemData
|
||||
// What the catalog alone says about one design of a loadout: the design, and whether it pins a product, has no product
|
||||
// at all, or needs a chooser to name one. Nothing is minted and nothing drawn.
|
||||
private sealed class Settled
|
||||
{
|
||||
public CraftedItemData Data;
|
||||
public FactionProductData Pinned; // the product the loadout names, if it does
|
||||
public bool Unbranded; // a design no product makes
|
||||
public string Where;
|
||||
}
|
||||
|
||||
// Null with the failure listed.
|
||||
private static Settled Settle<T>(ItemManager itemManager, CultRecordRef<T> design, CultRecordRef<FactionProductData> product,
|
||||
string where, List<string> failures) where T : CraftedItemData
|
||||
{
|
||||
var cache = itemManager.ItemData;
|
||||
var data = cache.Get(design);
|
||||
|
|
@ -183,35 +242,69 @@ public static class Loadouts
|
|||
return null;
|
||||
}
|
||||
|
||||
FactionProductData made;
|
||||
if (product.IsSet())
|
||||
{
|
||||
made = cache.Get(product);
|
||||
var made = cache.Get(product);
|
||||
if (made == null)
|
||||
{
|
||||
failures.Add($"{where}: product {product.Key} is not in the catalog");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(design.Key)))
|
||||
return () => itemManager.CreateInstance(itemManager.CreateLot(data, default, quality ?? UnbrandedQuality));
|
||||
made = choose(data);
|
||||
if (made == null)
|
||||
if (!made.Design.Key.Equals(design.Key))
|
||||
{
|
||||
failures.Add($"{where}: no available product of {data.Name}");
|
||||
failures.Add($"{where}: product {made.Name} is not a product of {data.Name}");
|
||||
return null;
|
||||
}
|
||||
return new Settled { Data = data, Pinned = made, Where = where };
|
||||
}
|
||||
|
||||
if (!made.Design.Key.Equals(design.Key))
|
||||
return new Settled
|
||||
{
|
||||
failures.Add($"{where}: product {made.Name} is not a product of {data.Name}");
|
||||
return null;
|
||||
Data = data, Where = where,
|
||||
Unbranded = !cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(design.Key))
|
||||
};
|
||||
}
|
||||
|
||||
// A design made with no product, at UnbrandedQuality: no maker, no draw. It is also the stand-in the fit trial builds
|
||||
// every design as, since a brand never changes a shape. A settled design that failed is never built.
|
||||
private static Func<CraftedItemInstance> Unbranded(ItemManager itemManager, Settled settled) =>
|
||||
() => itemManager.CreateInstance(itemManager.CreateLot(settled.Data, default, UnbrandedQuality));
|
||||
|
||||
// How a settled design is built, or null with the failure listed; nothing is minted until the returned builder runs.
|
||||
// A design-only slot is where choose is asked, and may draw.
|
||||
private static Func<CraftedItemInstance> Build(ItemManager itemManager, Settled settled,
|
||||
Func<CraftedItemData, FactionProductData> choose, float? quality, List<string> failures)
|
||||
{
|
||||
if (settled == null) return null;
|
||||
if (settled.Unbranded)
|
||||
return () => itemManager.CreateInstance(itemManager.CreateLot(settled.Data, default, quality ?? UnbrandedQuality));
|
||||
var made = settled.Pinned;
|
||||
if (made == null)
|
||||
{
|
||||
made = choose(settled.Data);
|
||||
if (made == null)
|
||||
{
|
||||
failures.Add($"{settled.Where}: no available product of {settled.Data.Name}");
|
||||
return null;
|
||||
}
|
||||
if (!made.Design.Key.Equals(itemManager.ItemData.RefOf(settled.Data).Key))
|
||||
{
|
||||
failures.Add($"{settled.Where}: product {made.Name} is not a product of {settled.Data.Name}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return () => itemManager.CreateInstance(quality is { } q ? itemManager.CreateLot(made, q) : itemManager.CreateLot(made));
|
||||
}
|
||||
|
||||
// How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A
|
||||
// pinned product is built as named, whatever choose says; it must be a product of the design. A design with no
|
||||
// product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through
|
||||
// products) and is built unbranded, with no maker, at UnbrandedQuality (a stated quality replaces it). Otherwise choose names the product, and
|
||||
// null (nothing available) or a product of another design is a failure.
|
||||
public static Func<CraftedItemInstance> Resolve<T>(ItemManager itemManager, CultRecordRef<T> design,
|
||||
CultRecordRef<FactionProductData> product, Func<CraftedItemData, FactionProductData> choose, string where,
|
||||
List<string> failures, float? quality = null) where T : CraftedItemData =>
|
||||
Build(itemManager, Settle(itemManager, design, product, where, failures), choose, quality, failures);
|
||||
|
||||
private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,6 +184,8 @@ public class LoadoutGenerator
|
|||
count);
|
||||
}
|
||||
|
||||
// An item is on offer only through a product, so a design no product makes is never drawn, stocked or chosen; it reaches a ship
|
||||
// only through a Loadout (ruling off-market-is-general).
|
||||
// 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.
|
||||
private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts<T>(Predicate<T> filter, bool required) where T : CraftedItemData
|
||||
|
|
@ -210,26 +212,6 @@ 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);
|
||||
}
|
||||
|
||||
// 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
|
||||
// stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior.
|
||||
|
|
|
|||
|
|
@ -421,22 +421,33 @@ 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. The boss is its faction's boss Loadout, built whole by Loadouts.Materialize and never drawn
|
||||
// (ruling boss-gear-is-a-loadout); ChooseProduct only brands 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)}. Its boss zone has no boss to kill.");
|
||||
continue;
|
||||
}
|
||||
var failures = new List<string>();
|
||||
var entity = Loadouts.Materialize(itemManager, null, loadout, design => GetLoadoutGenerator(bossFaction).ChooseProduct(design), failures); // the generator exists only once the Loadout is known to fit
|
||||
if (entity == null)
|
||||
{
|
||||
itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}");
|
||||
continue;
|
||||
}
|
||||
entity.Faction = bossFaction;
|
||||
var boss = EntitySerializer.Pack(entity);
|
||||
boss.Boss = true;
|
||||
pack.Entities.Add(boss);
|
||||
pack.BossSpawned = true;
|
||||
}
|
||||
|
||||
if (galaxyZone == galaxy.Exit && !pack.BossSpawned)
|
||||
itemManager.Log("Galaxy.Exit has no boss: Galaxy.BossZones does not map a boss faction to the exit zone, or its boss could not be generated (ZoneGenerator exit-without-boss). The exit gate stays sealed.");
|
||||
itemManager.Log("Galaxy.Exit has no boss: Galaxy.BossZones does not map a boss faction to the exit zone, or its boss Loadout is unset, missing or cannot be built (ZoneGenerator exit-without-boss). The exit gate stays sealed.");
|
||||
|
||||
if (!ambient) return pack;
|
||||
|
||||
|
|
|
|||
|
|
@ -11334,10 +11334,10 @@ PIDs are in `S\out\pids.log`, the first 3404 and the last 20348. It then writes
|
|||
|---|---|---|---|
|
||||
| Faction language, `factions/<faction>.scad` | The faction slug. One file per faction. A Pirate hull includes its donor's file as well. | Drafted by an agent from the lore and the operator's art, marked draft, then approved by an operator ruling. A revision rebuilds every hull of that faction at its next Build. | The operator decides the language (`zhestokost-language-source` is the first). Agents propose; nothing else edits it. |
|
||||
| Hull source, `GameData/Mods/<id>/hull.scad` | The package id, equal to `ShipAuthoring.Id`. One per package. | Created by an agent pass for a hull with no operator art and revised by agents. Deleted by Package in the same act that packages operator art into that package. | Agents (Imagination designs, Hands lands). The operator decides when it dies, by packaging her art. |
|
||||
| "Agent-built" status | Presence of `hull.scad`. No field. | Ends when the file is deleted. | Derived. "Which hulls are agent-built" is a directory listing. |
|
||||
| "Agent-built" status | Derived by `_hull_state` from hull.scad, Build's maker record, its untouched zones and her meshes (section HS). No field. | Ends when the state leaves agent and unbuilt: Package retiring hull.scad for her art. | Derived. The state table is in section HS. |
|
||||
| Zone meshes and anchor objects in Blender | Marked `aetheria.hull_source`, with ids `aetheria.id`. | Replaced on every Build. Never hand-edited. | Build From Hull Source. Build refuses a collection holding meshes it did not make. |
|
||||
| `ship.glb` and ShipAuthoring anchors | `ShipAuthoring.ModelAsset`, with anchor ids equal to mount ids. | Rewritten by every Package, whether from an agent hull or operator art. The ids are stable across the swap, so loadouts and saves survive it. | Package Ship, the only writer. |
|
||||
| HullData layout (schematic) | `mod-hull:<id>`. | Authored through Save Layout. Linked to the model by mount name only. | The schematic author: agents for agent hulls, the operator for hers. Never Build. AH-B3 is the open conflict. |
|
||||
| HullData layout (schematic) | `mod-hull:<id>`. | Authored through Save Layout. Linked to the model by mount name only. | The schematic author: agents for agent hulls, the operator for hers. Never Build. AH-B3 is closed by cut `package-agent-hull-guards` (section PG). |
|
||||
| Hero asset | A package with no `hull.scad` that is named as its faction's hero. | Set by an operator ruling (`agent-hull-hero-assets`). | The operator. |
|
||||
|
||||
### Questions admitted
|
||||
|
|
@ -11369,3 +11369,205 @@ Neither blocks the first cut.
|
|||
- The model read was one agent judging its own sheets.
|
||||
- Lucent and AU languages are not drafted.
|
||||
- The hero comparison is weak until the heroes exist.
|
||||
|
||||
## Package on agent hulls (PG)
|
||||
|
||||
Imagination pass `imag-ag7-hull-guards`, session `self-2026-10-19-ag7`, 2026-10-19, pinned to Aetheria `origin/master`
|
||||
5acbaa1b (worktree `C:\aeth-wt\ag7-hull-guards-imag`). It maps cut `package-agent-hull-guards` (spec
|
||||
`aetheria-release:cut_spec:cut-package-agent-hull-guards.r1`) for follow-ups `package-agent-hull-guards` and
|
||||
`hull-copy-refusals` and finding `cut-agent-hull-source.s2.hull-names-its-own-faction`. It rests on rulings
|
||||
`remove-package-schematic-derivation`, `hull-faction-is-product-maker`, `schematic-and-model-are-separate-spaces` and
|
||||
`package-carries-product`.
|
||||
|
||||
### Body facts (source reads and one probe at 5acbaa1b)
|
||||
|
||||
- **PG-B1. The derivation has three writers.** `_derived_position` (`__init__.py:253`) maps a model point to a cell
|
||||
through `hull_grid.cell_at` and `mounted_position`. Add Weapon (`_add_mount`, :302) and Mirror Mount (:359) call it.
|
||||
Package calls `_plan_mounts` (:409), which plans every weapon row's Position, and then `_apply_mounts` (:432), which
|
||||
writes those Positions, sets each mount object's yaw from its row's Rotation, and saves the layout. So Package writes
|
||||
each space from the other: the schematic from the model, and the model's yaw from the schematic. Package would turn
|
||||
a hull source's turned mount (smoke step 1.10, yaw 90) back to its row's facing.
|
||||
- **PG-B2. After the delete, `cell_at`, `mounted_position`, `mounted_cell` and `_centre_offset` have no caller** outside
|
||||
`tests/test_hull_grid.py` (grep of `tools/blender`). `cell_centre`, `mirror_position`, `mirror_footprint`,
|
||||
`mirror_rotation` and `facing_yaw` keep callers (Rasterise, the grid drawing, `_frame_ship`, Mirror Mount, Add
|
||||
Weapon).
|
||||
- **PG-B3. A product names its maker by an opaque Faction record key.** `FactionProductData.Manufacturer` is
|
||||
`CultRecordRef<Faction>` at `[Key(4)]` (`Assets/Scripts/ServerShared/FactionProduct.cs:26`), and
|
||||
`CultRecordRefFormatter` writes it as the key string. Probe `probe_factions.py` (cultcache-py from
|
||||
`F:\Projects\CultLib\packages\cultcache-py\src`, read of `GameData/Aetheria.cc`) listed 16 `aetheria.faction`
|
||||
records. Among them are Zhestokost `16bad4f9-7fb5-41c5-8da0-dc76709eb7d3`, Pirates
|
||||
`1d8a168def994a76a1ce7cb18f59c038` and Rossum & Douglas `adfb9b08-aabc-45c6-9586-915698a9c4b0`. Product makers are
|
||||
these keys: Juggernaut's, for example, is Zhestokost's. The language files are named by slug (`zhestokost`, `pirate`,
|
||||
`rnd`), and nothing maps a slug to a Faction key today.
|
||||
- **PG-B4. New Ship makes no product.** `AetherDb ship-authoring create` writes only a HullData and a ShipAuthoring
|
||||
(`tools/AetherDb/ShipAuthoringCommands.cs:56`). `ship-authoring product <ship.cc> <maker> --name <name>` (:94)
|
||||
writes a product, resolving the maker by Faction `Name` or `ShortName` (:136). The agent-hull smoke packages ships
|
||||
that have no product (Soul s2 noted it).
|
||||
- **PG-B5. The duplicate-id refusal names nothing.** `ship_cc.replace_visual` raises "An anchor ID is used twice"
|
||||
(`ship_cc.py:171`), and it does so after Package has framed the ship and written `ship.glb`. Build never looks at
|
||||
ids, because `_own_meshes` skips every object that carries `aetheria.role`. The add-on already has a way to point at
|
||||
offenders without echoing input: `_refuse_selecting` (`__init__.py:1139`), which `_refuse_loose_meshes` uses.
|
||||
- **PG-B6. The tests split by host.** `tools/blender/tests/test_*.py` import `ship_cc`, `hull_grid` and `hull_source`
|
||||
by path with no bpy (`CULTLIB_PACKAGES=<CultLib>/packages python -m unittest discover -s tools/blender/tests`), so
|
||||
they run on Yggdrasil in a python image with msgpack. The `smoke_*.py` files need Blender 5.2's bpy and run
|
||||
headless (`blender --background --factory-startup --python-use-system-env --python ...`). They run on Starfire, one
|
||||
process at a time, as Soul ran them for agent-hull-source. `ShipSchemaPinTests` is C# and pins the slot constants of
|
||||
`ship_cc.py` by regex.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **The language file names its maker.** The ruling needs a comparison between the brief's faction (a language slug)
|
||||
and the product's maker (a Faction key). Matching names (`Pirates` against `pirate`, `Rossum & Douglas` against `rnd`)
|
||||
would be a fuzzy rule. A table in `hull_source.py` would be a second owner of a fact the language file already
|
||||
stands for. So each `factions/<faction>.scad` gains its Faction record key as an eighth element of its `lang` echo.
|
||||
The file that owns the language then also says whose language it is, and the existing "includes its language
|
||||
unchanged" check covers that element too.
|
||||
- **Build records the maker it judged by, and Package compares that record.** Re-reading `hull.scad` at Package would
|
||||
compare the brief as it is now, which may differ from the brief Build judged: an author could edit the brief after
|
||||
Build and then Package. Build already records what it made (`aetheria.hull_built`), so it also records
|
||||
`aetheria.hull_maker`, the maker key of the language it judged against. Package then refuses unless the package
|
||||
holds at least one product and every product's Manufacturer is that key. The check runs whenever the package still
|
||||
ships Build's zones. When her art takes over (the same predicate `_retire_hull_source` uses), the brief no longer
|
||||
governs and no check runs. An operator could edit the record by hand, since it is a custom property; that is the same
|
||||
trust as the fingerprint record, and a hull source cannot reach it.
|
||||
- **This cut fixes the finding.** After it, the faction a shipped hull was judged by is its product's maker. Drafting
|
||||
under another faction stays possible, as the ruling allows. A separate cut would only repeat the Package edit.
|
||||
- **Mirror Mount mirrors each space within itself.** The twin row's Position is `mirror_position` of the row's own
|
||||
Position and footprint on the layout's width. The twin object stands at the reflection of the mount's model point
|
||||
across Ship Root's X = 0. Mirror Mount refuses when either twin would coincide with its source: the same Position,
|
||||
or a model point within 1 mm of X = 0. `CENTRELINE` (half a schematic cell, measured in model metres) was a
|
||||
cross-space constant, so it goes.
|
||||
- **Add Weapon's yaw stays a creation default.** One Rotation choice sets both the new row and the new empty's facing,
|
||||
once. Nothing re-derives either afterwards. The ruling took only the Position derivation out of Add Weapon, so the
|
||||
yaw stays.
|
||||
- **Copies are refused by id, in Build and Package alike.** One helper refuses while two role-bearing objects share an
|
||||
`aetheria.id`. It selects them and names a count, never an object. That covers the collider, emitter, radiator and
|
||||
mount copies Soul built (s2 probes A2 and A5 to A7). The duplicate check in `ship_cc` stays, as the file writer's
|
||||
invariant.
|
||||
|
||||
### Model page rows
|
||||
|
||||
| Kind | Identity | Lifecycle | Authority |
|
||||
|---|---|---|---|
|
||||
| A language's maker: the eighth element of `lang` in `factions/<faction>.scad` | The Faction record key in `GameData/Aetheria.cc`. | Written when the language is drafted. Changes only if the language is reassigned to another faction. | The language file, operator-approved like the rest of it. |
|
||||
| `aetheria.hull_maker` on a ship collection | The maker key of the language Build judged against. | Set by every Build. Deleted with `aetheria.hull_built` when Package retires `hull.scad`. | Build From Hull Source writes it; Package reads it. |
|
||||
| HullData layout Position | As before. | Authored in the layout panel only. Add Weapon creates a row at the default Position. | The schematic author. Package and the mount helpers no longer write it (this cut closes AH-B3). |
|
||||
|
||||
### Admitted
|
||||
|
||||
- `aetheria-release:cut_spec:cut-package-agent-hull-guards.r1`.
|
||||
|
||||
## Hull collection states (HS)
|
||||
|
||||
Imagination pass `imag-ag7-hull-states`, session `self-2026-10-19-ag7`, 2026-10-19, pinned to Aetheria f121b464 (head
|
||||
of `eureka/aetheria-release-package-agent-hull-guards`, worktree `C:\aeth-wt\ag7-hull-states-f121`). Three Soul passes
|
||||
(verdicts `cut-package-agent-hull-guards.s1` to `.s3`) each found the one decision "is this hull Build's, hers, or
|
||||
neither" answered by two readers that disagreed. This section is the model that gives that decision one owner. It
|
||||
revises spec `cut-package-agent-hull-guards` to r2 for findings `.s3.never-built-art-deadlock` and
|
||||
`.s3.art-requirement-unpinned`, and admits question `art-over-unbuilt-hull-source`.
|
||||
|
||||
### Body facts (source reads and one probe at f121b464)
|
||||
|
||||
- **HS-B1. Four inputs name the state, and each has one writer.** `hull.scad` in the package (`_hull_source_file`,
|
||||
`__init__.py:1499`): written by agents, deleted by `_retire_hull_source` (:1645) or by hand. `aetheria.hull_maker`
|
||||
and `aetheria.hull_built` on the collection (:1494-1495): written together by Build (:1622-1623), deleted together by
|
||||
`_retire_hull_source` (:1648). Build's untouched zone meshes: derived by `_hull_built` (:1518) from the record and each
|
||||
object's fingerprint. Her meshes: `_own_meshes` (:789) minus Build's untouched objects, so a Build zone she edited
|
||||
counts as hers.
|
||||
- **HS-B2. The regime has two readers today.** `_art_takes_over` (:1627) reads the maker record, Ship Root, the
|
||||
untouched zones and her meshes. `_refuse_wrong_maker` (:1654) calls it, then reads `hull.scad` again on its own
|
||||
(:1663) when the record is absent. `_retire_hull_source` (:1639) reads only `_art_takes_over`. The panel reads
|
||||
`hull.scad` to show the Build button (:1789). Build (:1556) reads `hull.scad` as its input file and refuses her
|
||||
meshes and edited objects of its own (:1562-1568), which is its write-safety rule, not the regime.
|
||||
- **HS-B3. Fourteen states are reachable, and the two readers disagree in exactly one.** Probe
|
||||
`probe_states.py` (scratchpad `imag-ag7-hull-states`) transcribes the f121b464 conditions and enumerates every
|
||||
combination of hull.scad (S), the maker record (M), an untouched Build zone (Z), her meshes (A) and Ship Root (R),
|
||||
with Z implying M and R, A implying R, and M without R (a hand-deleted Ship Root) left out. Of 14 states, one is a
|
||||
deadlock: S, no M, no Z, A, R (an agent's hull.scad Build never ran, with her art under Ship Root). Package refuses
|
||||
"Build From Hull Source first" and Build refuses her meshes. That is Soul's s3 case A. The proposed classifier below
|
||||
gives the same Package and retirement outcome as f121b464 in the other 13 states.
|
||||
- **HS-B4. One state is pinned by nothing.** M, no Z, no A (Build's zones deleted, nothing put in their place): the
|
||||
smoke has no step for it, so the art test inside `_art_takes_over` can be replaced with True and the smoke stays
|
||||
green (Soul s3 mutation `mut.art-needs-no-art`).
|
||||
- **HS-B5. Products matter only in the agent state.** `product_makers` (`ship_cc.py`) is read only by
|
||||
`_refuse_wrong_maker`, after the regime says the hull is Build's. Her art and a plain hull ship with any product or
|
||||
none (smoke step 1.9 removes the product before her art is packaged).
|
||||
|
||||
### The state table
|
||||
|
||||
S is hull.scad in the package, M is Build's maker record, Z is an untouched zone mesh of Build's, and A is her meshes
|
||||
under Ship Root. "Check" means that Package refuses unless the package holds a product and every product's
|
||||
Manufacturer is the recorded maker: no product gives "no product", and a wrong or mixed maker gives "not by the
|
||||
maker". Every Package outcome holds only when the validator accepts. Retirement deletes hull.scad (if present), M and
|
||||
`aetheria.hull_built`, after the validator has accepted.
|
||||
|
||||
| # | S | M | Z | A | State | Build | Package | Retired | How it is reached |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| H1 | - | - | - | - | plain | refuses: no hull.scad | no check | nothing | New Ship, no agent hull |
|
||||
| H2 | - | - | - | yes | plain | refuses: no hull.scad | no check | nothing | Her hero or hand-made hull. It is also H6 or H11 after the Package that retires |
|
||||
| H3 | yes | - | - | - | unbuilt | builds | refuses: "Build From Hull Source first, or put your own meshes under Ship Root" | nothing | An agent's hull.scad dropped in. Smoke PASS 3 |
|
||||
| H4 | yes | - | - | yes | **art** (question `art-over-unbuilt-hull-source`) | refuses: not its meshes | no check | hull.scad | Her art over a hull.scad Build never ran. **The f121b464 deadlock** |
|
||||
| H5 | yes | yes | yes | - | agent | builds (replaces its own) | check | nothing | After Build. Smoke 1.1 to 1.5 |
|
||||
| H6 | yes | yes | - | yes | art | refuses: not its meshes | no check | hull.scad, M | Her art replaced every Build zone, or she edited them all. Smoke 1.9, odd hull |
|
||||
| H7 | yes | yes | yes | yes | agent | refuses: not its meshes | check | nothing | Her mesh beside Build's zones, or one zone edited. Smoke 1.8 |
|
||||
| H8 | yes | yes | - | - | agent | builds | check | nothing | Build's zones deleted, nothing in their place. **Unpinned (s3 art-requirement)** |
|
||||
| H9 | - | yes | yes | - | agent | refuses: no hull.scad | check | nothing | hull.scad deleted by hand after Build. Smoke 1.5's hull.scad-gone row |
|
||||
| H10 | - | yes | yes | yes | agent | refuses: no hull.scad | check | nothing | H7 with hull.scad deleted by hand |
|
||||
| H11 | - | yes | - | yes | art | refuses: no hull.scad | no check | M | H6 with hull.scad deleted by hand. Smoke 1.9, even hull |
|
||||
| H12 | - | yes | - | - | agent | refuses: no hull.scad | check | nothing | H8 with hull.scad deleted by hand |
|
||||
|
||||
Ship Root absent (states H1 and H3 before New Ship's root, or after a hand deletion) changes nothing in the table:
|
||||
`_own_meshes` needs a root, so with no root A is false and the row is H1 or H3. A maker record with no root (hand
|
||||
deletion) is agent.
|
||||
|
||||
### Authority
|
||||
|
||||
- **Owner of the state: `_hull_state(collection)`**, one classifier in `__init__.py`, evaluated in this order. Build's
|
||||
untouched zone present gives agent. Otherwise her meshes present together with Build's record or hull.scad gives art.
|
||||
Otherwise Build's record present gives agent. Otherwise hull.scad present gives unbuilt. Otherwise plain. It reads
|
||||
`_hull_built`, `_own_meshes`, `HULL_MAKER in collection` and `_hull_source_file`, and nothing else does so to decide
|
||||
the regime.
|
||||
- **Owner of the maker of a shipped agent hull: its products** (ruling `hull-faction-is-product-maker`).
|
||||
`aetheria.hull_maker` is a record of what Build judged against, read only as the value to compare in the agent state.
|
||||
- **Owner of when hull.scad dies: Package**, through `_retire_hull_source` in the art state after the validator
|
||||
accepts, or the operator by hand. Ruling `hull-art-communicates-form` keeps agent hulls until her art replaces them.
|
||||
The model row of section "Agent-built hulls" names Package's packaging of her art as that act.
|
||||
- **Owner of whether Build may write: Build** (`_build_hull_source`), on its own rule that it never replaces a mesh it
|
||||
did not make or an object of its own that was changed. This is a separate decision from the regime, and it reads
|
||||
hull.scad as its input.
|
||||
- **Display-only:** the panel's read of hull.scad (:1789), which shows the Build button. In H4 the button shows and
|
||||
Build refuses. It decides nothing.
|
||||
- **Forbidden readers of the regime:** `_refuse_wrong_maker` reading `hull.scad` or deciding by the presence of the
|
||||
maker record; `_retire_hull_source` testing anything but `_hull_state`; any reader of `aetheria.hull_built` other
|
||||
than `_hull_built`; the smoke asserting the classifier's return value as its only proof.
|
||||
- **Deletion line:** `_art_takes_over` (:1627-1636) and the hull.scad branch of `_refuse_wrong_maker` (:1661-1665) go
|
||||
before `_hull_state` is added.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **The body decides, not a file or a record alone.** The guard exists so that a hull shipping Build's judged body
|
||||
ships under Build's judged maker. Whether Build's untouched zones are in the collection is the fact that says so.
|
||||
hull.scad (keyed by r1, s1 `guard-keyed-on-hull-scad`) and the maker record (keyed by h3, s3 `never-built-art-deadlock`)
|
||||
each stand in for that fact in only some states, which is why each fix moved the hole somewhere else.
|
||||
- **Order is the model.** Z before A keeps H7 and H10 under the guard: one of Build's zones still ships. A with M or S
|
||||
before M alone makes her art win once no Build zone is left (H4, H6, H11). A must be required (H8 and H12 stay agent),
|
||||
and M or S must be required (H2 stays plain and retires nothing).
|
||||
- **H4 is the operator's call.** Two readings are coherent. Under the first, her art over a hull.scad is hers and
|
||||
Package retires the file, which is what 70c366b5 did and what the "Agent-built hulls" model row says. Under the
|
||||
second, Package never deletes an agent's hull.scad that Build never ran, and refuses with text naming the exit
|
||||
(delete hull.scad). The recommendation is the first, in question `art-over-unbuilt-hull-source`. Either answer
|
||||
changes one condition in `_hull_state` and one message, and both remove the deadlock.
|
||||
- **The "Agent-built" status row above is superseded.** It said the status is the presence of hull.scad. That holds
|
||||
only for H3 to H8. The status is `_hull_state` being agent or unbuilt.
|
||||
|
||||
### Model page rows
|
||||
|
||||
| Kind | Identity | Lifecycle | Authority |
|
||||
|---|---|---|---|
|
||||
| Hull state (plain, unbuilt, agent, art) of a ship collection | Derived per call from hull.scad, `aetheria.hull_maker`, Build's untouched zones and her meshes. No field. | Recomputed at every Package and retirement. It changes when Build runs, when she adds or removes meshes, at retirement, and when hull.scad or a record is deleted by hand. | `_hull_state`, alone. |
|
||||
|
||||
### Admitted
|
||||
|
||||
- `aetheria-release:question:art-over-unbuilt-hull-source` (recommended `art-takes-over`, raised in the spec, which it
|
||||
blocks).
|
||||
- `aetheria-release:cut_spec:cut-package-agent-hull-guards.r2`, superseding r1.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
# Prior art for agent-authored stand-in hulls (aetheria-release, 2026-10-19)
|
||||
|
||||
Serves: the stand-in hull prompt bench for the demo cast and freelancer factions
|
||||
(operator 2026-10-19: "Let's see if we can figure out a prompt that gets Opus to
|
||||
spit out some decent stand-in hulls for the demo cast (including ships from
|
||||
non-present factions to be piloted by other freelancers)"). Gathered by Eyes
|
||||
pass `eyes-ag7-llm-cad-prior-art`, session `self-2026-10-19-ag7`. Companion to
|
||||
`2026-10-18-agent-built-hull-art-prior-art.md` and
|
||||
`2026-10-18-faction-form-language-prior-art.md`.
|
||||
|
||||
Evidence levels: **read** (HTML or abstract page read), **snippet** (search
|
||||
excerpt only).
|
||||
|
||||
## Loops and their measured gains
|
||||
|
||||
- **SceneCraft** (ICML 2024, arXiv 2403.01248, read). Removing the render-review
|
||||
inner loop dropped constraint score 88.9 to 26.1, the largest loss in its
|
||||
ablation. Removing the learned code-skill library dropped it 88.9 to 64.5 and
|
||||
CLIP 69.8 to 48.3. Human eval put its aesthetics win rate at 74.5% against
|
||||
BlenderGPT's 14.5%. It measures scene layout from given assets, not part form.
|
||||
- **CADCodeVerify** (ICLR 2025, arXiv 2410.05340, read). Two refinement rounds;
|
||||
no gain observed beyond round 2. Point-cloud distance 0.156 to 0.126. Dropping
|
||||
the reference views or the few-shot questions made it worse. The VLM's own
|
||||
verification answers were correct only 64.6% and 68.2% of the time; human
|
||||
gold feedback (0.120) beat VLM feedback (0.137) on a 50-sample subset.
|
||||
- **BlenderGym** (CVPR 2025, arXiv 2504.01786, read). VLM verifier agreement
|
||||
with human pairwise judgments 0.66 (Claude 3.5 Sonnet) against 0.79
|
||||
human-to-human; Claude was the only verifier that consistently gave full
|
||||
reasoning. Scarce compute favours generation, abundant compute favours
|
||||
verification: a scaled small verifier over 32 Claude candidates beat unscaled
|
||||
GPT-4o and Claude. Generators hallucinate differences, and edits mismatch the
|
||||
diagnosis in scripts over about 80 lines. Metrics miss physical
|
||||
implausibility such as floating objects.
|
||||
- **LL3M** (arXiv 2508.08228, read). Planner, retrieval, coder, critic and
|
||||
verifier agents; the critic sees 5 renders, the verifier checks each fix was
|
||||
applied, refinement loops until the verifier approves. Blender-API retrieval
|
||||
raised complex-operation use 1.20 to 5.86 and cut execution errors 26%.
|
||||
Without retrieval the coder sometimes deleted offending lines to silence
|
||||
errors. Without shared prior code the coder regenerates a substantially
|
||||
different asset. Disconnected handles survive auto-refinement; VLMs struggle
|
||||
with spatial artifacts. Agent ablations are qualitative only; no user study.
|
||||
- **Procedura** (arXiv 2608.26238, read). Named parts with typed mates, per-part
|
||||
compile, mate and connectivity gates, and a vision critic that fixes one
|
||||
diagnosed issue at a time. Ablation on MechBench-36 (judge score): full 0.828,
|
||||
no refine 0.800, no planning 0.791, no 3D feedback 0.819; dropping planning
|
||||
caused the biggest aesthetics drop, 0.827 to 0.775. A floater at 70% span was
|
||||
1.9% of volume, so a volume check misses it. Case study: 24 to 3 to 2 HIGH
|
||||
issues over three critic cycles. CSG suits hard-surface better than organic.
|
||||
- **P3D-Bench** (arXiv 2606.11152, abstract). Multi-part code generation is much
|
||||
harder than single-part: models get identity right, dimensions, part count
|
||||
and part geometry wrong.
|
||||
- **ModelRift Pantheon benchmark** (vendor blog, one run per cell, read). Opus
|
||||
4.7 scored 3.0/5 with "uniform colour and weight"; Sonnet 4.6 3.4/5 with the
|
||||
cleanest massing. Explicit measured dimensions helped; human annotations drawn
|
||||
on the render beat text corrections. A preview PNG looked better than the
|
||||
exported STL, which had non-manifold surfaces.
|
||||
|
||||
## Self-judgement
|
||||
|
||||
- Huang et al., ICLR 2024 (arXiv 2310.01798, snippet): intrinsic
|
||||
self-correction without external feedback often fails and can hurt; reported
|
||||
gains partly came from oracle labels or weak first prompts.
|
||||
- Panickssery et al., NeurIPS 2024 (arXiv 2404.13076, snippet): LLM evaluators
|
||||
recognise and favour their own generations; text domain.
|
||||
- Multimodal self-judging leans on text priors over the image (arXiv 2406.11280,
|
||||
2508.20655, snippets).
|
||||
|
||||
## Failure modes in CAD code
|
||||
|
||||
- BenchCAD and ASSEMCAD (snippets): successful renders still show floating
|
||||
components; union without face glue gives floating subpieces; polar patterns
|
||||
are an error cluster. ASSEMCAD mitigates with safe-union and overlap
|
||||
validators.
|
||||
- Shape-grammar ships (CESCG 2016; 2022 Hacettepe thesis; snippets): primitive
|
||||
sets plus rules give similar outlines with varied detail; unconstrained
|
||||
derivation tends toward unstructured results.
|
||||
|
||||
## Game-art practice (criteria a critic can check)
|
||||
|
||||
- Stellaris Dev Diary 230 (search excerpt): silhouette and proportions read
|
||||
first at distance; big shapes settled before small; shared motifs unify a
|
||||
family.
|
||||
- Fractured Space art direction, Creative Bloq (search excerpt): an arrowhead
|
||||
silhouette reads as a threat across the map; three manufacturers each get a
|
||||
distinct vocabulary (stacked cylinders, brutalist cubes, naval modular
|
||||
shells); human figures dropped in as scale checks.
|
||||
- "Moth" analysis, John Liberto, ArtStation (search excerpt): 3 large, 4 mid
|
||||
and many small volumes; the large volumes dictate the silhouette.
|
||||
- Primary/secondary/tertiary shapes (steveyang.readthedocs.io, read, after Neil
|
||||
Blevins): primary defines silhouette, secondary individuality, tertiary story.
|
||||
|
||||
## Gaps
|
||||
|
||||
No controlled study of design-language or style-spec conditioning for
|
||||
LLM-authored CAD; no measurement of a parts library against floating-part or
|
||||
symmetry errors (nearest: SceneCraft's skill library, LL3M's retrieval); no
|
||||
sweep of render-critique round counts for aesthetic quality; no direct
|
||||
measurement of a model praising its own render (inferred from text-domain
|
||||
work); no source on boxy "lego" look or scale confusion in LLM 3D code; no
|
||||
authoritative game-art source for 60/30/10 ship proportions. 3D-PreMise
|
||||
(2401.06437) and MeshCoder (2508.14879) seen as abstracts only.
|
||||
|
|
@ -910,7 +910,7 @@ public sealed class FlightTests : IDisposable
|
|||
Assert.Equal(turns, turn != 0f);
|
||||
}
|
||||
|
||||
// Faction.Doctrine rides at wire slot 17; slot 16 is reserved for Relations; Places rides at 21, which sets the array's length.
|
||||
// Faction.Doctrine rides at wire slot 17; slot 16 is reserved for Relations; BossLoadout rides at 22, which sets the array's length.
|
||||
// Kills: the key moved.
|
||||
[Fact]
|
||||
public void TheFactionDoctrineRidesAtWireSlot17()
|
||||
|
|
@ -920,7 +920,7 @@ public sealed class FlightTests : IDisposable
|
|||
var slots = MessagePack.MessagePackSerializer.Deserialize<object[]>(MessagePack.MessagePackSerializer.Serialize(faction,
|
||||
CultDocumentMessagePackSerialization.OptionsFor(typeof(Faction).Assembly)));
|
||||
|
||||
Assert.Equal(22, slots.Length); // 19 is Assembly, 21 is Places
|
||||
Assert.Equal(23, slots.Length); // 19 is Assembly, 21 is Places, 22 is BossLoadout
|
||||
Assert.Null(slots[16]);
|
||||
var doctrine = Assert.IsType<object[]>(slots[17]);
|
||||
Assert.Equal(6f, Convert.ToSingle(doctrine[1]));
|
||||
|
|
|
|||
|
|
@ -8,17 +8,19 @@ using System.IO;
|
|||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using UniRx;
|
||||
using Xunit;
|
||||
|
||||
// The demo's goal (aetheria-release, cut boss-gate): the boss zone spawns its faction's boss, and the exit gate opens
|
||||
// only when that boss is dead. Every input is the production one: the demo galaxy, GenerateZone, the Zone death path,
|
||||
// only when that boss is dead. The boss flies its faction's boss Loadout (cut boss-loadout). Every input is the production one: the demo galaxy, GenerateZone, the Zone death path,
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
|
@ -38,12 +40,87 @@ public sealed partial class RunStartTests
|
|||
.Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0)
|
||||
.Distinct().ToArray();
|
||||
|
||||
// A hull no product makes, so the market never offers it: a MessagePack copy of the first sold ship hull by name, renamed. It has
|
||||
// no authored model, which a boss does not need. Stored once; asking again returns the same record.
|
||||
private HullData BossTestHull()
|
||||
{
|
||||
var existing = _cache.GetAll<HullData>().FirstOrDefault(hull => hull.Name == "Boss Test Hull");
|
||||
if (existing != null) return existing;
|
||||
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
|
||||
var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First();
|
||||
var boss = MessagePack.MessagePackSerializer.Deserialize<HullData>(MessagePack.MessagePackSerializer.Serialize(like, options), options);
|
||||
boss.Name = "Boss Test Hull";
|
||||
boss.Prefab = null;
|
||||
boss.Visual = default;
|
||||
return _cache.Get(_cache.Upsert(boss));
|
||||
}
|
||||
|
||||
// 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))));
|
||||
|
||||
// A boss's fixed Loadout (ruling boss-gear-is-a-loadout), by the production capture: a seed-11 generated fit of the first sold
|
||||
// ship hull by name, captured (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 given hull with no hull product, and stored under its name.
|
||||
private Loadout MakeBossLoadout(HullData hull, string name)
|
||||
{
|
||||
var like = SoldShipHulls().OrderBy(h => h.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(h => h == 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();
|
||||
fit.Hull = _cache.RefOf(hull);
|
||||
fit.HullProduct = default;
|
||||
StoreLoadout(fit);
|
||||
return fit;
|
||||
}
|
||||
|
||||
private static void SetBoss(Faction faction, Loadout loadout) => faction.BossLoadout = new CultRecordRef<Loadout>(Loadouts.KeyOf(loadout.Name));
|
||||
|
||||
// The demo's antagonist gets a boss Loadout on a hull nothing sells: the shipped catalog authors none.
|
||||
private Loadout GiveAntagonistABoss(Galaxy galaxy)
|
||||
{
|
||||
var boss = MakeBossLoadout(BossTestHull(), "Antagonist Boss");
|
||||
SetBoss(Zhestokost(galaxy), boss);
|
||||
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 string[] LoadoutCells(Loadout loadout) =>
|
||||
loadout.Slots.OrderBy(slot => slot.Position.x).ThenBy(slot => slot.Position.y)
|
||||
.Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{_cache.Get(slot.Design).Name},{(slot.Product.IsSet() ? _cache.Get(slot.Product).Name : "-")}").ToArray();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// What the pack holds, in order, by hull and faction: two zone packs that drew the same stream hold the same list.
|
||||
private static string[] Roster(ItemManager items, ZonePack pack) =>
|
||||
pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}").ToArray();
|
||||
|
||||
[Fact]
|
||||
public void Boss_spawns_once_in_boss_zone()
|
||||
{
|
||||
foreach (var seed in DemoSeeds.Take(3))
|
||||
{
|
||||
var galaxy = DemoGalaxy(seed);
|
||||
GiveAntagonistABoss(galaxy);
|
||||
var antagonist = Zhestokost(galaxy);
|
||||
foreach (var zone in galaxy.Zones)
|
||||
{
|
||||
|
|
@ -53,6 +130,7 @@ public sealed partial class RunStartTests
|
|||
var boss = Assert.Single(bosses);
|
||||
Assert.IsType<ShipPack>(boss);
|
||||
Assert.Same(antagonist, _cache.Get(boss.Faction));
|
||||
Assert.Equal("Boss Test Hull", 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 +143,215 @@ 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);
|
||||
}
|
||||
|
||||
// Faction.BossLoadout owns the boss. A decoy Loadout sorts before it by name and by key, so a resolver that takes the first
|
||||
// Loadout in the cache, or any Loadout but the referenced one, flies the wrong hull.
|
||||
[Fact]
|
||||
public void Boss_hull_from_BossHull_else_fallback()
|
||||
public void Boss_flies_its_factions_boss_loadout()
|
||||
{
|
||||
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");
|
||||
var sold = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First();
|
||||
StoreLoadout(new Loadout { Name = "Aaa Decoy", Hull = _cache.RefOf(sold) });
|
||||
var loadout = GiveAntagonistABoss(galaxy);
|
||||
|
||||
// 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"));
|
||||
Assert.Same(_cache.Get(antagonist.BossLoadout), Loadouts.BossOf(_cache, antagonist, out var refusal));
|
||||
Assert.Equal(BossRefusal.None, refusal);
|
||||
Assert.Equal("Antagonist Boss", loadout.Name);
|
||||
var boss = Assert.Single(BossZonePack(galaxy, _items, galaxy.Exit).Entities, entity => entity.Boss);
|
||||
Assert.IsType<ShipPack>(boss);
|
||||
Assert.Same(antagonist, _cache.Get(boss.Faction));
|
||||
Assert.Equal("Boss Test Hull", HullName(_items, boss));
|
||||
var again = Assert.Single(BossZonePack(galaxy, _items, galaxy.Exit).Entities, entity => entity.Boss);
|
||||
Assert.Equal(Cells(_items, boss), Cells(_items, again));
|
||||
}
|
||||
|
||||
// Set but sold by no product: the boss falls back the same way, and the log says which hull has no seller.
|
||||
// Ruling boss-gear-is-a-loadout: the boss carries exactly its Loadout, nothing drawn. The Loadout is weapons only, so any
|
||||
// pickup, extra gear, cargo or consumable on the boss is a generator pass that should not run.
|
||||
[Fact]
|
||||
public void Boss_hull_nobody_sells_falls_back()
|
||||
public void Boss_carries_exactly_its_loadouts_cells()
|
||||
{
|
||||
var galaxy = DemoGalaxy(1);
|
||||
var loadout = GiveAntagonistABoss(galaxy);
|
||||
Assert.True(loadout.Slots.Count > 1);
|
||||
Assert.All(loadout.Slots, slot => Assert.True(slot.Product.IsSet(), "a captured fit pins every product"));
|
||||
|
||||
foreach (var seed in new uint[] { 7, 8 })
|
||||
{
|
||||
var items = LoggingItems(new List<string>());
|
||||
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit, seed: seed).Entities, entity => entity.Boss);
|
||||
Assert.Equal(LoadoutCells(loadout), Cells(items, boss));
|
||||
Assert.All(boss.Equipment, entry => Assert.DoesNotContain(items.GetData(entry.item).Behaviors, behavior => behavior is PickupData));
|
||||
Assert.Empty(boss.CargoContents.SelectMany(bay => bay));
|
||||
Assert.Empty(boss.DockingBayContents.SelectMany(bay => bay));
|
||||
|
||||
var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, boss), "probe");
|
||||
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()).Where(group => group.Length > 0).ToArray();
|
||||
Assert.Equal(Groups(loadout), Groups(captured));
|
||||
}
|
||||
}
|
||||
|
||||
// Ruling loadout-product-optional-context-chooses: a design-only slot takes the brand the boss's faction can get. The design has
|
||||
// an outsider's product that sorts first by name, so a chooser that takes the first product by name builds the wrong brand.
|
||||
[Fact]
|
||||
public void A_design_only_boss_slot_takes_a_brand_its_faction_can_get()
|
||||
{
|
||||
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 loadout = GiveAntagonistABoss(galaxy);
|
||||
var outfitter = GeneratorFor(galaxy, _items, antagonist, 1);
|
||||
var open = loadout.Slots.First(slot => _cache.GetAll<FactionProductData>().Any(product => product.Design.Key.Equals(slot.Design.Key) && outfitter.IsAvailable(product)));
|
||||
var template = _cache.GetAll<FactionProductData>().First(product => product.Design.Key.Equals(open.Design.Key));
|
||||
open.Product = default;
|
||||
StoreLoadout(loadout);
|
||||
|
||||
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
|
||||
var outsider = MessagePack.MessagePackSerializer.Deserialize<FactionProductData>(MessagePack.MessagePackSerializer.Serialize(template, options), options);
|
||||
outsider.Name = "Aaa Outsider";
|
||||
foreach (var faction in _cache.GetAll<Faction>().OrderBy(faction => faction.Name, StringComparer.Ordinal))
|
||||
{
|
||||
outsider.Manufacturer = _cache.RefOf(faction);
|
||||
if (!outfitter.IsAvailable(outsider)) break;
|
||||
}
|
||||
Assert.False(outfitter.IsAvailable(outsider), "the outsider's maker is neither Zhestokost nor in its allegiance");
|
||||
_cache.Upsert(outsider);
|
||||
|
||||
var gettable = _cache.GetAll<FactionProductData>().Where(product => product.Design.Key.Equals(open.Design.Key) && outfitter.IsAvailable(product)).Select(product => product.Name).ToArray();
|
||||
Assert.NotEmpty(gettable);
|
||||
foreach (var seed in new uint[] { 1, 2, 3, 4, 5 })
|
||||
{
|
||||
var items = LoggingItems(new List<string>());
|
||||
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit, seed: seed).Entities, entity => entity.Boss);
|
||||
var cell = Assert.Single(Cells(items, boss), c => c.StartsWith($"{open.Position.x},{open.Position.y},"));
|
||||
var brand = cell.Split(',').Last();
|
||||
Assert.NotEqual("Aaa Outsider", brand);
|
||||
Assert.Contains(brand, gettable);
|
||||
}
|
||||
}
|
||||
|
||||
// No generated fallback (rulings boss-gear-is-a-loadout, demo-boss-hull-invented): the shipped catalog authors no boss Loadout,
|
||||
// so Zhestokost has no boss. The refusal happens before anything is drawn.
|
||||
[Fact]
|
||||
public void Boss_without_a_boss_loadout_is_refused()
|
||||
{
|
||||
var galaxy = DemoGalaxy(1);
|
||||
var antagonist = Zhestokost(galaxy);
|
||||
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
|
||||
Assert.Equal(BossRefusal.Unset, 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 has no boss") && line.Contains("sets no Faction.BossLoadout"));
|
||||
Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss"));
|
||||
Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy)));
|
||||
|
||||
// Nothing was drawn for the refused boss: the zone is the zone with no boss mapped at all.
|
||||
galaxy.BossZones.Clear();
|
||||
var bare = BossZonePack(galaxy, LoggingItems(new List<string>()), galaxy.Exit);
|
||||
Assert.NotEmpty(bare.Entities);
|
||||
Assert.Equal(Roster(items, bare), Roster(items, pack));
|
||||
}
|
||||
|
||||
// A reference to a Loadout the catalog does not hold is refused like no reference, and the log names the field, not the value.
|
||||
[Fact]
|
||||
public void A_missing_boss_loadout_is_refused()
|
||||
{
|
||||
var galaxy = DemoGalaxy(1);
|
||||
var antagonist = Zhestokost(galaxy);
|
||||
antagonist.BossLoadout = new CultRecordRef<Loadout>(Loadouts.KeyOf("No Such Loadout"));
|
||||
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
|
||||
Assert.Equal(BossRefusal.Missing, 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 has no boss") && line.Contains("names a Faction.BossLoadout that is not in the catalog"));
|
||||
Assert.DoesNotContain(log, line => line.Contains("No Such Loadout") || line.Contains("sets no"));
|
||||
Assert.DoesNotContain(log, line => line.Contains("Zhestokost has no boss") && line.Contains("No Such"));
|
||||
}
|
||||
|
||||
// A boss Loadout whose slots do not fit gives no boss: the log names the Loadout and every fit failure, and the gate stays sealed.
|
||||
[Fact]
|
||||
public void An_unbuildable_boss_loadout_spawns_no_boss()
|
||||
{
|
||||
var galaxy = DemoGalaxy(1);
|
||||
var loadout = GiveAntagonistABoss(galaxy);
|
||||
loadout.Slots[0].Position = new int2(999, 999);
|
||||
loadout.Slots[1].Position = new int2(998, 998);
|
||||
StoreLoadout(loadout);
|
||||
|
||||
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);
|
||||
var refusal = Assert.Single(log, line => line.Contains("Antagonist Boss") && 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)));
|
||||
}
|
||||
|
||||
// The existing market rule (ruling off-market-is-general): an item is on offer iff a product of it is. A hull no product makes
|
||||
// is never drawn, stocked or chosen, yet the boss whose Loadout names it flies it.
|
||||
[Fact]
|
||||
public void A_hull_no_product_makes_is_never_on_offer()
|
||||
{
|
||||
var galaxy = DemoGalaxy(1);
|
||||
var hull = BossTestHull();
|
||||
GiveAntagonistABoss(galaxy);
|
||||
Assert.DoesNotContain(hull, LoadoutGenerator.SoldHulls(_cache));
|
||||
var items = LoggingItems(new List<string>());
|
||||
|
||||
for (uint seed = 1; seed <= 20; seed++)
|
||||
{
|
||||
var generator = GeneratorFor(galaxy, items, Zhestokost(galaxy), seed);
|
||||
var ship = generator.GenerateShipLoadout();
|
||||
Assert.NotNull(ship);
|
||||
Assert.NotEqual(hull.Name, HullName(items, ship));
|
||||
var station = generator.GenerateStationLoadout();
|
||||
Assert.DoesNotContain(EntitySerializer.Items(station), item => items.GetData(item).Name == hull.Name);
|
||||
Assert.Null(generator.ChooseProduct(hull));
|
||||
Assert.Null(generator.GenerateShipLoadout(candidate => candidate == hull));
|
||||
}
|
||||
|
||||
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"));
|
||||
Assert.Equal(hull.Name, HullName(items, boss));
|
||||
}
|
||||
|
||||
// PlaceFactionsMain reads BossOf: with no reference set there is no boss zone, a reference to nothing places none, and the one
|
||||
// faction whose boss Loadout resolves 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 factions = _cache.GetAll<Faction>().OrderBy(faction => faction.Name, StringComparer.Ordinal).ToArray();
|
||||
factions[0].BossLoadout = new CultRecordRef<Loadout>(Loadouts.KeyOf("No Such Loadout"));
|
||||
Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones);
|
||||
|
||||
var armed = MakeBossLoadout(BossTestHull(), "Main Boss");
|
||||
SetBoss(factions[1], armed);
|
||||
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
|
||||
var bossFaction = Assert.Single(galaxy.BossZones.Keys);
|
||||
Assert.Equal(factions[1].Name, bossFaction.Name);
|
||||
Assert.NotNull(Loadouts.BossOf(_cache, bossFaction, out _));
|
||||
}
|
||||
|
||||
[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));
|
||||
|
|
@ -140,6 +374,7 @@ public sealed partial class RunStartTests
|
|||
{
|
||||
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1));
|
||||
Assert.True(failures.Count == 0, string.Join("; ", failures));
|
||||
GiveAntagonistABoss(galaxy);
|
||||
galaxy.Entrance.Contents = arena;
|
||||
galaxy.Exit.Contents = ZoneOf(galaxy, galaxy.Exit);
|
||||
var before = Assert.Single(galaxy.Exit.Contents.Entities, entity => entity.IsBoss);
|
||||
|
|
@ -175,7 +410,7 @@ public sealed partial class RunStartTests
|
|||
}
|
||||
}
|
||||
|
||||
// The same for a demo exit whose boss could not be generated: the zone logs the gap by name and the gate stays sealed.
|
||||
// The same for a demo exit whose boss could not be built: the zone logs the gap by name and the gate stays sealed.
|
||||
[Fact]
|
||||
public void Exit_sealed_and_logged_when_the_boss_cannot_be_generated()
|
||||
{
|
||||
|
|
@ -196,14 +431,21 @@ 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 sets no boss Loadout, 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 Loadout. This Loadout pins its hull's brand, so any faction
|
||||
// builds it whatever it can get.
|
||||
var faction = galaxy.Factions.First();
|
||||
faction.BossHull = _cache.RefOf(SoldShipHulls().First());
|
||||
var hull = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First();
|
||||
var gate = MakeBossLoadout(hull, "Gate Boss");
|
||||
gate.HullProduct = _cache.RefOf(_cache.GetAll<FactionProductData>().OrderBy(product => product.Name, StringComparer.Ordinal).First(product => _cache.Get(product.Design) == hull));
|
||||
StoreLoadout(gate);
|
||||
SetBoss(faction, gate);
|
||||
var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit);
|
||||
galaxy.BossZones[faction] = bossZone;
|
||||
var zone = ZoneOf(galaxy, bossZone);
|
||||
Assert.True(zone.Pack.BossSpawned);
|
||||
var log = new List<string>();
|
||||
var items = LoggingItems(log);
|
||||
var zone = new Zone(items, _planetSettings, BossZonePack(galaxy, items, bossZone), bossZone, galaxy);
|
||||
Assert.True(zone.Pack.BossSpawned, string.Join("; ", log));
|
||||
Assert.NotEmpty(zone.Entities.Where(entity => entity.IsBoss));
|
||||
foreach (var boss in zone.Entities.Where(entity => entity.IsBoss).ToArray())
|
||||
boss.HeatstrokeDeath.OnNext(Unit.Default);
|
||||
|
|
@ -211,27 +453,12 @@ 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.
|
||||
[Fact]
|
||||
public void Exit_sealed_when_no_ship_hull_is_on_offer()
|
||||
{
|
||||
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 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"));
|
||||
Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy)));
|
||||
}
|
||||
|
||||
// A boss killed before the save stays dead after it, and the gate stays open: the spawn fact travels with the zone.
|
||||
[Fact]
|
||||
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();
|
||||
|
|
@ -326,4 +553,151 @@ public sealed partial class RunStartTests
|
|||
Assert.Equal(0, gate.x, 3);
|
||||
Assert.Equal(zone.Pack.Radius * ratio, gate.y, 2);
|
||||
}
|
||||
|
||||
// Soul's probes of cut boss-loadout pass s1, kept as behaviour: the rules the first eight tests let a mutant past.
|
||||
|
||||
[Fact]
|
||||
public void The_boss_is_the_loadout_its_faction_names_even_when_another_loadout_shares_its_hull()
|
||||
{
|
||||
var galaxy = DemoGalaxy(1);
|
||||
var decoy = MakeBossLoadout(BossTestHull(), "Aaa Same Hull");
|
||||
decoy.Slots = decoy.Slots.Take(1).ToList();
|
||||
decoy.WeaponGroups = new int[0][];
|
||||
StoreLoadout(decoy);
|
||||
var boss = GiveAntagonistABoss(galaxy);
|
||||
Assert.True(boss.Slots.Count > decoy.Slots.Count);
|
||||
var pack = Assert.Single(BossZonePack(galaxy, _items, galaxy.Exit).Entities, e => e.Boss);
|
||||
Assert.Equal(LoadoutCells(boss), Cells(_items, pack));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void A_refused_boss_flies_no_catalog_loadout_and_draws_nothing(bool dangling)
|
||||
{
|
||||
var galaxy = DemoGalaxy(1);
|
||||
var hull = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First();
|
||||
StoreLoadout(new Loadout { Name = "Aaa Decoy", Hull = _cache.RefOf(hull) });
|
||||
if (dangling) Zhestokost(galaxy).BossLoadout = new CultRecordRef<Loadout>(Loadouts.KeyOf("CANARY-7f3a"));
|
||||
var log = new List<string>();
|
||||
var items = LoggingItems(log);
|
||||
var pack = BossZonePack(galaxy, items, galaxy.Exit);
|
||||
var next = items.Random.NextFloat();
|
||||
Assert.False(pack.BossSpawned);
|
||||
Assert.DoesNotContain(pack.Entities, e => e.Boss);
|
||||
Assert.DoesNotContain(log, l => l.Contains("CANARY"));
|
||||
galaxy.BossZones.Clear();
|
||||
var bareItems = LoggingItems(new List<string>());
|
||||
var bare = BossZonePack(galaxy, bareItems, galaxy.Exit);
|
||||
Assert.Equal(Roster(bareItems, bare), Roster(items, pack));
|
||||
Assert.Equal(bareItems.Random.NextFloat(), next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_design_only_boss_slot_whose_design_only_outsiders_make_refuses_the_boss()
|
||||
{
|
||||
var galaxy = DemoGalaxy(1);
|
||||
var faction = Zhestokost(galaxy);
|
||||
var loadout = GiveAntagonistABoss(galaxy);
|
||||
var outfitter = GeneratorFor(galaxy, _items, faction, 1);
|
||||
var open = loadout.Slots.First(slot => _cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(slot.Design.Key) && outfitter.IsAvailable(p)));
|
||||
var outsider = _cache.GetAll<Faction>().OrderBy(f => f.Name, StringComparer.Ordinal).First(f => f.Name != faction.Name && !faction.Allegiance.ContainsKey(_cache.RefOf(f)));
|
||||
foreach (var p in _cache.GetAll<FactionProductData>().Where(p => p.Design.Key.Equals(open.Design.Key))) p.Manufacturer = _cache.RefOf(outsider);
|
||||
Assert.Null(outfitter.ChooseProduct(_cache.Get(open.Design)));
|
||||
open.Product = default;
|
||||
StoreLoadout(loadout);
|
||||
var log = new List<string>();
|
||||
var pack = BossZonePack(galaxy, LoggingItems(log), galaxy.Exit, ambient: false);
|
||||
Assert.False(pack.BossSpawned);
|
||||
Assert.DoesNotContain(pack.Entities, e => e.Boss);
|
||||
Assert.Contains(log, l => l.Contains("Antagonist Boss") && l.Contains("no available product"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_boss_path_draws_only_what_building_its_loadout_draws()
|
||||
{
|
||||
var galaxy = DemoGalaxy(1);
|
||||
var loadout = GiveAntagonistABoss(galaxy);
|
||||
var withBoss = LoggingItems(new List<string>());
|
||||
Assert.True(BossZonePack(galaxy, withBoss, galaxy.Exit, ambient: false).BossSpawned);
|
||||
var next = withBoss.Random.NextFloat();
|
||||
galaxy.BossZones.Clear();
|
||||
var expected = LoggingItems(new List<string>());
|
||||
BossZonePack(galaxy, expected, galaxy.Exit, ambient: false);
|
||||
var failures = new List<string>();
|
||||
Assert.NotNull(Loadouts.Materialize(expected, null, loadout, GeneratorFor(galaxy, expected, Zhestokost(galaxy), 7).ChooseProduct, failures));
|
||||
Assert.Empty(failures);
|
||||
Assert.Equal(expected.Random.NextFloat(), next);
|
||||
}
|
||||
|
||||
// Everything a generated zone exposes afterwards: the serialized ledger, the roster and the gear of its ships, and
|
||||
// the next draw of the item stream. A faction's generator stream shows in the ships it outfitted, so it is in the pack.
|
||||
private string ZoneFacts(ItemManager items, ZonePack pack)
|
||||
{
|
||||
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
|
||||
var bytes = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(MessagePack.MessagePackSerializer.Serialize(items.Lots, options)));
|
||||
var gear = pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}|{(EntitySerializer.Unpack(items, null, entity) is Ship ? string.Join(";", Cells(items, entity)) : "-")}");
|
||||
return $"ledger {bytes} NextLot={items.Lots.NextLot} lots={items.Lots.Lots.Count}\n{string.Join("\n", gear)}\nnext item draw {items.Random.NextFloat():R}";
|
||||
}
|
||||
|
||||
// The boss of the named faction, whose Loadout is made unbuildable by unbuild, refuses before anything is minted or drawn: the
|
||||
// zone is the zone with no boss in every state it exposes. The faction need not be the zone's nearest.
|
||||
private void AssertUnbuildableBossIsAsIfAbsent(string bossFaction, Action<Loadout> unbuild)
|
||||
{
|
||||
var galaxy = DemoGalaxy(1);
|
||||
var loadout = GiveAntagonistABoss(galaxy);
|
||||
unbuild(loadout);
|
||||
StoreLoadout(loadout);
|
||||
var boss = galaxy.Factions.Single(faction => faction.Name == bossFaction);
|
||||
Zhestokost(galaxy).BossLoadout = default;
|
||||
SetBoss(boss, loadout);
|
||||
galaxy.BossZones.Clear();
|
||||
galaxy.BossZones[boss] = galaxy.Exit;
|
||||
var log = new List<string>();
|
||||
var items = LoggingItems(log);
|
||||
var pack = BossZonePack(galaxy, items, galaxy.Exit);
|
||||
Assert.False(pack.BossSpawned, bossFaction);
|
||||
Assert.Contains(log, line => line.Contains("Antagonist Boss") && line.Contains("cannot be built"));
|
||||
|
||||
var bare = DemoGalaxy(1);
|
||||
bare.BossZones.Clear();
|
||||
var bareItems = LoggingItems(new List<string>());
|
||||
var barePack = BossZonePack(bare, bareItems, bare.Exit);
|
||||
var expected = ZoneFacts(bareItems, barePack);
|
||||
var actual = ZoneFacts(items, pack);
|
||||
Assert.True(expected == actual, $"{bossFaction}: the zone with an unbuildable boss differs from the zone with none.\nno boss:\n{expected}\nunbuildable boss:\n{actual}\nlog: {string.Join(" / ", log)}");
|
||||
}
|
||||
|
||||
private IEnumerable<string> AllFactionNames() => DemoGalaxy(1).Factions.Select(faction => faction.Name).OrderBy(name => name, StringComparer.Ordinal);
|
||||
|
||||
private static void MoveOut(Loadout loadout, int slots)
|
||||
{
|
||||
for (var i = 0; i < slots; i++) loadout.Slots[i].Position = new int2(999 - i, 999 - i);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unbuildable_boss_loadout_with_one_failure_leaves_the_zone_as_if_it_had_no_boss()
|
||||
{
|
||||
foreach (var faction in AllFactionNames()) AssertUnbuildableBossIsAsIfAbsent(faction, loadout => MoveOut(loadout, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unbuildable_boss_loadout_with_several_failures_leaves_the_zone_as_if_it_had_no_boss()
|
||||
{
|
||||
foreach (var faction in AllFactionNames()) AssertUnbuildableBossIsAsIfAbsent(faction, loadout => MoveOut(loadout, 3));
|
||||
}
|
||||
|
||||
// A design-only slot asks the chooser, which draws its faction's stream; the fit is decided before it is asked.
|
||||
[Fact]
|
||||
public void An_unbuildable_boss_loadout_with_design_only_slots_leaves_the_zone_as_if_it_had_no_boss()
|
||||
{
|
||||
foreach (var faction in AllFactionNames())
|
||||
AssertUnbuildableBossIsAsIfAbsent(faction, loadout =>
|
||||
{
|
||||
foreach (var slot in loadout.Slots) slot.Product = default;
|
||||
Assert.Contains(loadout.Slots, slot => _cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(slot.Design.Key)));
|
||||
MoveOut(loadout, 1);
|
||||
loadout.Slots[loadout.Slots.Count - 1].Position = new int2(900, 900);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -593,7 +593,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
|
||||
// dangling keys (a faction's BossLoadout member clears boss references that name no Loadout). 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)
|
||||
{
|
||||
|
|
@ -711,15 +711,14 @@ public static class Program
|
|||
{
|
||||
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");
|
||||
Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss loadout",-14} {"influence",-9} {"doctrine",-10} products");
|
||||
var broken = 0;
|
||||
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 loadout = Loadouts.BossOf(db.Cache, faction, out var refusal);
|
||||
var boss = refusal == BossRefusal.Unset ? "none" : loadout?.Name ?? "MISSING";
|
||||
if (refusal == BossRefusal.Missing) broken++;
|
||||
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)));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue