diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 4afb00b1d..525894ac9 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -41,6 +41,7 @@ public static class AetheriaStores foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); VerseGrammarValidation.ValidateAll(cache.GetAll()); + ValidateBins(cache); return cache; } catch @@ -49,6 +50,24 @@ public static class AetheriaStores throw; } } + + // The mint and the weighting trust a bin: they never check its Floor or its Line. So the catalog read refuses one whose Floor is + // not a finite number (it would weigh NaN and poison every draw of its design), whose Line names no product the catalog holds, or + // whose Line is itself a bin (a bin is of a line, never of a bin). The refusals name the field and echo no value. + private static void ValidateBins(CultCache cache) + { + foreach (var product in cache.GetAll()) + { + if (product.Bin == null) continue; + if (!float.IsFinite(product.Bin.Floor)) + throw new InvalidOperationException("A product bin's Floor is not a finite number."); + var line = cache.Get(product.Bin.Line); + if (line == null) + throw new InvalidOperationException("A product bin's Line names no product this catalog holds."); + if (line.Bin != null) + throw new InvalidOperationException("A product bin's Line names another bin; a bin is of a line."); + } + } } public static class CultRecordRefs diff --git a/Assets/Scripts/ServerShared/Extensions.cs b/Assets/Scripts/ServerShared/Extensions.cs index 656e85ba2..97433a299 100644 --- a/Assets/Scripts/ServerShared/Extensions.cs +++ b/Assets/Scripts/ServerShared/Extensions.cs @@ -180,6 +180,21 @@ public static class Extensions return mean + deviation * sqrt(-2 * log(u1)) * cos(2 * PI * u2); } + // NextGaussian truncated below mean + floorDeviations * deviation: draws until one clears that cutoff, at most 64 times, + // then returns the cutoff itself. Exact for any reachable floor and bounded and deterministic per seed for an unreachable + // one. A binned product's fills and assembly (ProductBin). Callers clamp, as with NextGaussian. + public static float NextGaussianAbove(this ref Random random, float mean, float deviation, float floorDeviations) + { + var cutoff = mean + floorDeviations * deviation; + for (var tries = 0; tries < 64; tries++) + { + var draw = random.NextGaussian(mean, deviation); + if (draw >= cutoff) return draw; + } + + return cutoff; + } + public static float NextUnbounded(this ref Random random) => 1 / (1 - random.NextFloat()) - 1; public static float NextUnbounded(this ref Random random, float bias, float power, float ceiling) => 1 / (1 - pow(min(random.NextFloat(), ceiling), 1 - pow(clamp(bias,0,.99f), 1 / power))) - 1; diff --git a/Assets/Scripts/ServerShared/FactionProduct.cs b/Assets/Scripts/ServerShared/FactionProduct.cs index cdaed4f28..b9a3f0009 100644 --- a/Assets/Scripts/ServerShared/FactionProduct.cs +++ b/Assets/Scripts/ServerShared/FactionProduct.cs @@ -8,9 +8,9 @@ using GameCult.Caching; using MessagePack; using Newtonsoft.Json; -// A manufacturer's branded variant of a generic design: its name, its flavor text, and how it builds each of -// the design's roles. Which products exist is what a faction makes, so market segmentation and regional -// progression are authored here rather than as duplicate item entries. +// What a manufacturer sells of a generic design: its name, its flavor text, and either a line (how the maker builds each of the +// design's roles) or a bin of one of its own lines (maker-design-tiers-allowed). Which products exist is what a faction makes, so +// market segmentation and regional progression are authored here rather than as duplicate item entries. [CultDocument("aetheria.factionproductdata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] public class FactionProductData { @@ -29,16 +29,21 @@ public class FactionProductData [Inspectable, JsonProperty("roles"), Key(5)] public List Roles = new List(); - // This line's own final test (maker-design-tiers-allowed): a binned or premium line is a better assembly as well as better - // parts. Null assembles as its maker does. + // This line's own final test (maker-design-tiers-allowed): a premium line is a better assembly as well as better parts. + // Null assembles as its maker does. A binned product leaves it null and reads its line's. [Inspectable, JsonProperty("assembly"), Key(6)] public AssemblyDistribution Assembly; + + // Set on a binned product: the line it bins and the floor above which it takes that line's output. A bin authors no Roles and + // no Assembly; the mint derives both from the line. + [Inspectable, JsonProperty("bin"), Key(7)] + public ProductBin Bin; } // How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is // the technology the manufacturer puts in and whose standard deviation is their quality control. A market -// segment is a second product with a role pumped up; what makes that part better is the flavor text's to tell, -// because a part here carries quality and nothing else. +// segment is a bin of the line (ProductBin), not a second product with a role pumped up; what makes a part better is the +// flavor text's to tell, because a part here carries quality and nothing else. [MessagePackObject, JsonObject(MemberSerialization.OptIn)] public class ProductRole { @@ -52,6 +57,19 @@ public class ProductRole public float StandardDeviation = .15f; } +// A bin of one of the maker's lines (ruling maker-design-tiers-allowed): the line it bins (the same design and maker, itself +// unbinned) and the floor, in standard deviations above the mean of each of the line's distributions, below which the bin +// takes nothing. 0 is the top half of the line, .67 the top quarter. +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class ProductBin +{ + [Inspectable, JsonProperty("line"), Key(0)] + public CultRecordRef Line; + + [Inspectable, JsonProperty("floor"), Key(1)] + public float Floor; +} + // How well a maker puts the thing together: the technology and quality control of final assembly. A lot's own quality is drawn // from it, and it is the ceiling on every role the lot fills. Defaults are a middling maker. [MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 5e0a0dcc5..5ea06f455 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -775,7 +775,7 @@ public static class FireControl // Lays a mine for a mine layer: freezes the payload as evaluated now (stats, the item's blast radius, the // layer and its faction) and hands the body to Zone.Lay, the one adder. It launches at the shooter's velocity // plus the mount direction turned by a dice angle within +-Spread/2, times Velocity; the dice are the mine's - // own (zone seed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price: + // own (the zone's DropSeed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price: // InstantWeapon has already paid. A layer whose item has no blast radius lays nothing. public static void Lay(Weapon weapon, EquippedItem item, Entity source) { @@ -789,7 +789,7 @@ public static class FireControl var id = zone.NextBodyId(); var half = weapon.Spread / 2f; - var angle = radians(SimulationDice.For(zone.CombatSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half)); + var angle = radians(SimulationDice.For(zone.DropSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half)); var s = sin(angle); var c = cos(angle); var mount = MountDirection(item).xz; diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index ce7b30630..26b08c22d 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -28,8 +28,8 @@ public class Galaxy private Action Log { get; } public bool IsPrelude { get; } - // The seed this galaxy was generated at, which also seeds its run's item draws (RunStart); zero for a galaxy - // restored from a save. + // The run's seed, minted once by GalaxyStage.SeedFrom and carried by the save (SavedGame.Seed). It seeds + // generation, the run's item draws (RunStart) and the zone's drop dice (Zone.DropSeed). public uint Seed { get; } private GalaxyZone[] _exitPath; @@ -51,6 +51,7 @@ public class Galaxy _cache = cultCache; Log = log; Background = savedGame.Background; + Seed = savedGame.Seed; Factions = savedGame.Factions.Select(faction => cultCache.Get(faction)).ToArray(); for (var i = 0; i < Factions.Length; i++) @@ -96,15 +97,15 @@ public class Galaxy SectorBackgroundSettings background, CultCache cache, Action log, - Action progressCallback = null, - uint seed = 0) + Action progressCallback, + uint seed) { _cache = cache; IsPrelude = false; Background = background; Log = log; var factions = cache.GetAll(); - Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed; + Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero"); var random = new Random(Seed); Factions = factions.OrderBy(x => random.NextFloat()).Take(settings.MegaCount).ToArray(); foreach (var f in Factions) FactionRelationships[f] = FactionRelationship.Neutral; @@ -163,15 +164,15 @@ public class Galaxy PlayerSettings playerSettings, DirectoryInfo narrativeDirectory, Action log, - Action progressCallback = null, - uint seed = 0) + Action progressCallback, + uint seed) { _cache = cache; IsPrelude = true; Background = background; Log = log; - Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed; + Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero"); var random = new Random(Seed); var factions = new List(); diff --git a/Assets/Scripts/ServerShared/GalaxyStage.cs b/Assets/Scripts/ServerShared/GalaxyStage.cs index cd8feefed..a93e25f89 100644 --- a/Assets/Scripts/ServerShared/GalaxyStage.cs +++ b/Assets/Scripts/ServerShared/GalaxyStage.cs @@ -46,7 +46,8 @@ public sealed class GalaxyStage _clock = clock ?? (() => (uint) (DateTime.Now.Ticks % uint.MaxValue)); } - // The seed this generation runs at, never zero: the scenario's, or the clock's when the scenario's is zero. + // The run's one minter: the scenario's seed, or the clock's when the scenario's is zero, never zero. Galaxy carries + // what it mints and SavedGame keeps it, so Continue rolls the same drops. public uint Seed { get; private set; } internal void SeedFrom(Scenario scenario) diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index 12d45d101..b33b17224 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -121,6 +121,9 @@ public class ItemManager // Builds one of a manufacturer's branded products (quality-is-assembly-over-supply-chain): the unit's own quality is its // final assembly, drawn from the product's assembly distribution, else its maker's, else AssemblyDistribution's defaults, // and each of the design's roles is filled with a part whose quality is drawn from the product's distribution for it. + // A binned product (maker-design-tiers-allowed) has no distributions of its own: it is its line's, derived here on every + // mint, with each draw (the assembly and every fill) truncated below the line's mean + Bin.Floor deviations. The lot is + // still the bin's: its Product, its title and so its brand. public int CreateLot(FactionProductData product) => Mint(product, null); // The same lot at a stated quality: the assembly and every role fill are that quality and nothing is drawn, so a staged @@ -130,29 +133,37 @@ public class ItemManager private int Mint(FactionProductData product, float? stated) { var design = ItemData.Get(product.Design); - var assembly = product.Assembly ?? ItemData.Get(product.Manufacturer)?.Assembly ?? new AssemblyDistribution(); + var line = product.Bin != null ? ItemData.Get(product.Bin.Line) : null; + if (product.Bin != null && line == null) + Log("A binned product names a line the catalog does not hold; it mints as a product with no roles of its own."); + var source = line ?? product; + var floor = line != null ? product.Bin.Floor : (float?) null; + var assembly = source.Assembly ?? ItemData.Get(product.Manufacturer)?.Assembly ?? new AssemblyDistribution(); var lot = new Lot { Design = ItemData.RefOf(design), Origin = new Attributed { Faction = product.Manufacturer }, Product = ItemData.RefOf(product), - Quality = stated ?? clamp(Random.NextGaussian(assembly.Mean, assembly.StandardDeviation), .01f, 1), + Quality = stated ?? clamp(Draw(assembly.Mean, assembly.StandardDeviation, floor), .01f, 1), Roles = new List() }; if (design.Roles != null) foreach (var role in design.Roles) { - var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole(); + var build = source.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole(); lot.Roles.Add(new RoleFill { Role = role.Name, - Quality = stated ?? clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1) + Quality = stated ?? clamp(Draw(build.Mean, build.StandardDeviation, floor), .01f, 1) }); } return Lots.Add(lot); } + private float Draw(float mean, float deviation, float? floorDeviations) => + floorDeviations is { } floor ? Random.NextGaussianAbove(mean, deviation, floor) : Random.NextGaussian(mean, deviation); + // No branded product: a lot attributed to maker (which may be unset) with no role fills, e.g. console `give`. public int CreateLot(CraftedItemData design, CultRecordRef maker, float quality) { diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 849f0c352..2e925a201 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -176,8 +176,9 @@ public class LoadoutGenerator public (FactionProductData product, T design)[] RandomProducts(int count, float sizeExponent, Predicate filter = null, bool required = false) where T : CraftedItemData { var (available, preferManufacturers) = AvailableProducts(filter, required); + var shares = BinShares(); return available.WeightedRandomElements(ref Random, entry => - (preferManufacturers ? ProductWeight(entry.product) : 1) * + Weight(entry.product, shares, preferManufacturers) * pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree count); @@ -234,12 +235,34 @@ public class LoadoutGenerator Galaxy == null || Faction == null || ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer); - // How much this outfitter favors a product: its maker's preference. The one weight of generation and of ChooseProduct. - public float ProductWeight(FactionProductData product) => ManufacturerPreference(product.Manufacturer); + // How much this outfitter favors a product: its maker's preference times the product's share of it. The one weight of + // generation and of ChooseProduct. A bin takes the share of its maker's stock that would pass its floor and its line takes + // the rest (ruling bin-share-by-floor), so binning never changes a maker's presence for the design. + public float ProductWeight(FactionProductData product) => Weight(product, BinShares(), true); + + // The one weight, with the share of the stock computed from shares (BinShares, once per weighting pass). A draw that has + // fallen back to any manufacturer weighs by share alone: the maker's preference no longer applies, its bins' shares still do. + private float Weight(FactionProductData product, Dictionary shares, bool preferManufacturers) => + (preferManufacturers ? ManufacturerPreference(product.Manufacturer) : 1) * Share(product, shares); + + // 1 - Phi(floor): the fraction of a normal draw that lies at least floor deviations above its mean. + private static float PassFraction(float floor) => .5f * (1 - erf(floor / sqrt(2f))); + + // Each line's key to the sum of its bins' pass fractions: read from the catalog once per weighting pass, never kept. + private Dictionary BinShares() => ItemManager.ItemData.GetAll() + .Where(product => product.Bin != null) + .GroupBy(product => product.Bin.Line.Key.Value) + .ToDictionary(group => group.Key, group => group.Sum(bin => PassFraction(bin.Bin.Floor))); + + private float Share(FactionProductData product, Dictionary shares) + { + if (product.Bin != null) return PassFraction(product.Bin.Floor); + return max(0f, 1 - shares.GetValueOrDefault(ItemManager.ItemData.RefOf(product).Key.Value)); + } // The product a design named without one is built as (ruling loadout-product-optional-context-chooses): among the // products of the design this outfitter can get, one draw from the run's seeded Random weighted by ProductWeight; - // null when none, and uniform over them when every weight is 0. Candidates are ordered by name so the draw's + // null when none, and by share alone when every weight is 0. Candidates are ordered by name so the draw's // enumeration, and so its result, never depends on record keys. All of the policy is this body. public FactionProductData ChooseProduct(CraftedItemData design) { @@ -249,8 +272,9 @@ public class LoadoutGenerator .OrderBy(product => product.Name, StringComparer.Ordinal) .ToArray(); if (candidates.Length == 0) return null; - var uniform = candidates.All(product => ProductWeight(product) <= 0); - return candidates.WeightedRandomElements(ref Random, product => uniform ? 1 : ProductWeight(product), 1)[0]; + var shares = BinShares(); + var uniform = candidates.All(product => Weight(product, shares, true) <= 0); + return candidates.WeightedRandomElements(ref Random, product => Weight(product, shares, !uniform), 1)[0]; } // Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters. diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index ba819e6ec..1758074fa 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -50,6 +50,11 @@ public class SavedGame [Key(13)] public int Credits; + + // The run's seed (Galaxy.Seed), minted once by GalaxyStage.SeedFrom. Written only by RunSave.Capture and read only + // by Galaxy's save constructor; 0 in a save written before run-seed, which then rolls name-only drops. + [Key(14)] + public uint Seed; } // The run's lifecycle over the run store. Only Commit creates SavedGame, SavedZone and ProvenanceLedger records; @@ -79,7 +84,8 @@ public static class RunSave Exit = Array.IndexOf(galaxy.Zones, galaxy.Exit), IsTutorial = galaxy.IsPrelude, ActionBarBindings = actionBar, - Credits = credits + Credits = credits, + Seed = galaxy.Seed }; // A zone generated earlier but not loaded this session has no live Contents; its packed contents carry over. diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index f68d3a1a9..dc8cd0538 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -126,11 +126,15 @@ public class Zone // of touching a shared stream. public uint CombatSeed { get; } + // The zone's drop dice (loot and mine scatter): CombatSeed mixed with its run's seed (ruling loot-seed-in-save), so a + // new game drops differently and a reloaded save repeats. Mix(0) is 0, so a zone with no galaxy rolls on CombatSeed. + public uint DropSeed { get; } + // The one source of floating item and mine ids: zone-scoped, from 1, never reused. Runtime state, not saved. private uint _lastBodyId; public FloatingBodyId NextBodyId() => new FloatingBodyId(++_lastBodyId); - // The death ordinal of the loot dice: the nth death in this zone rolls on SimulationDice.For(CombatSeed, LootStream, n). + // The death ordinal of the loot dice: the nth death in this zone rolls on SimulationDice.For(DropSeed, LootStream, n). private uint _deaths; // The zone-scoped agent ordinal, from 1, never reused: the key of a pilot's own dice. @@ -152,6 +156,7 @@ public class Zone Settings = settings; _orbitPositionAt = OrbitPositionAt; CombatSeed = galaxyZone?.Name.StableHash() ?? 1337u; + DropSeed = CombatSeed ^ SimulationDice.Mix(galaxy?.Seed ?? 0u); var cache = itemManager.ItemData; // Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7; operator ruling Q3): death removes the ship, @@ -452,7 +457,7 @@ public class Zone private void DropLoot(Entity dead) { var settings = _itemManager.GameplaySettings; - var random = SimulationDice.For(CombatSeed, SimulationDice.LootStream, ++_deaths); + var random = SimulationDice.For(DropSeed, SimulationDice.LootStream, ++_deaths); var at = new float2(dead.Position.x, dead.Position.z); float2 Heading() { var angle = random.NextFloat() * 2f * PI; return float2(cos(angle), sin(angle)); } foreach (var equipped in dead.Equipment.ToArray()) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 9f9ff69e5..a2ddaad5d 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22342ea360c7f453de500da8650e6a23648f8c1250eb22e724765bbcf619eac0 -size 186037 +oid sha256:c327ef1bbabbd34ddb7b9f643f5db8cc54aa5730f232bd42a1fa5f2b03973e89 +size 189753 diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index b5e91ddaa..88267df37 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -888,8 +888,8 @@ name. Per ruling `no-save-compatibility-before-players` nothing reads key 11. |---|---|---|---| | Pirates `Faction` | Catalog record, name `Pirates` | Authored once by `AetherDb pirates-faction apply`; doctrine is added by faction-play r2 | Ruling `pirates-record-content`; the operator sees the dry run | | Package product | `mod-product::` in the package's `ship.cc` | Written by `AetherDb ship-authoring product`; copied verbatim by compose; Blender never edits it | The package author | -| Boss hull | `HullData.Boss` (key 33) | Set by `ship-authoring boss`; a faction's boss hulls are derived per generation from the products it makes | The package author; `BossHullsOf` derives | -| `Faction.BossHull` (key 10) | Retired | Deleted by `boss-hull-derived` | none | +| Boss loadout | `Faction.BossLoadout` (key 22), a reference to one Loadout; the Loadout names its hull by design | Set by an agent-authored catalog write (follow-up `zhestokost-boss-loadout`); resolved by `Loadouts.BossOf`, which refuses Unset or Missing | Agents write it, the operator tweaks in Studio (rulings `faction-boss-is-a-loadout-reference`, `boss-loadouts-agent-authored`) | +| `Faction.BossHull` (key 10) | Retired, never reused | Deleted by `boss-loadout` (the derived-boss design with `HullData.Boss` was withdrawn unmerged) | none | | Pirates products | 8 `FactionProductData`, emoji names, over other makers' designs | Authored with the faction in one commit | Ruling `pirates-record-content` | | Package hull price | `HullData.Price` | Written by `ship-authoring price` (or `create --like`) | The package author | | Emoji font file | `Assets/Fonts/Emoji/.ttf` + `emoji-font.lock` | Rebuilt only by `tools/emoji` at a new pinned version | The operator's `emoji-art-set` ruling | @@ -10784,194 +10784,115 @@ worktree C:\aeth-wt\ag6-quality-readers at 70c7a497. Catalog: GameData/Aetheria. quality-layers-combine, not in the spec, so the spec does not wait on it. context-pack: 14 anchors SAME. -## Boss hull derived +## Boss loadout -r3's probes and source reads were made by imagination-ag6-boss-hull (session self-2026-10-18-ag6), -2026-10-18, in the detached worktree `C:\aeth-wt\ag6-boss-hull` at `origin/master` `eb27ced1` -(BH1-BH11). r4's probes were made by imagination-ag6-boss-hull-r4 in the same worktree at the -branch head `095baf2f`, which is r3's h1 (BH12-BH17). Read by `cut-boss-hull-derived.r4`. This -section supersedes the anchors of "Demo hulls, the Pirates and the boss" H7 (`@2a5fc00b`) and of -Products PL6. Under Rationale below, r4 replaces r3's boss-loadout path. Ruling -`boss-gear-is-a-loadout` revokes that path; it replaced `boss-gear-is-fixed`. +This section replaces "Boss hull derived" (the `cut-boss-hull-derived` r1-r4 design: `HullData.Boss`, +`BossHullsOf`, a Loadout found by hull product). Ruling `faction-boss-is-a-loadout-reference` replaced that +design; r4 is withdrawn. Branch `eureka/aetheria-release-boss-hull-derived` (head `90f977bf`, reports h1, h2) +is unmerged evidence only. Probes and source reads by imagination-ag6-boss-loadout (session +self-2026-10-18-ag6), 2026-10-18, in the detached worktree `C:\aeth-wt\ag6-boss-loadout` at `origin/master` +`a40072d9`, then `d28ce42d` (that commit adds one research doc only; every anchor below is the same at both). +Read by `cut-boss-loadout.r1`. The model-page row `Faction.BossHull (key 10)` (about line 892) and the r4 +table row (about line 10958) now say: retired by `boss-loadout`, never reuse; add the row +`Faction.BossLoadout (key 22) | CultRecordRef | the one owner of which Loadout is a faction's boss`. ### Body facts -- **BH1. What the cut waits on is on master.** boss-gate is merged at `39c7508e` - (cut-boss-gate r2, verdict s3). faction-play-1 is merged at `d7f30ed1` (r3, h6), and its catalog - replay is at `aa12cbac`. minor-power-factions is merged at `c486b06d`. pirates-record is merged - at `2c065937` and package-product at `3535660c`. -- **BH2. Faction keys at `eb27ced1`** (`Corporations.cs`): 1-5, 7, 8, a retired-key comment for 9 - (`:39`), `BossHull` Key(10) at `:42-43`, then 11-15, 17 (Doctrine), 19 (Assembly) and 21 - (Places). h1 deleted `BossHull` and retired key 10. -- **BH3. HullData keys** (`ItemData.cs:531-556` at `eb27ced1`): 23, 24, 25, retired 26, 27, 28, - 29 and 32 (`Visual`). h1 added `Boss` at Key(33) (`ItemData.cs:557-560` at `095baf2f`). -- **BH4. Every BossHull reader at `eb27ced1`** was `Galaxy.cs:293`, - `LoadoutGenerator.cs:212-230`, `tools/AetherDb/Program.cs:586` and `:709-712`, and - `RunGoalTests.cs:74-115` and `:199-202`. h1 removed every one. -- **BH5. Retiring a key with stored values needs no catalog rewrite.** Precedents: `1d689adf` - (Faction key 9) and `be7f2b07` (HullData key 26). -- **BH6. Every generation hull draw goes through one availability rule**, `AvailableProducts` - (`LoadoutGenerator.cs:196-219` at `095baf2f`). Its candidate filter carries h1's - `!(entry.design is HullData { Boss: true })` (`:204`) ahead of the any-manufacturer fallback. - `ChooseProduct` (`:278-289`) has its own `Where` and does not exclude Boss hulls. r4 never asks - it for a hull, because `BossOf` returns only a Loadout whose `HullProduct` is set. -- **BH7. Explicit hull draws.** `ScenarioStage.Generated` (`ScenarioStage.cs:93`) and AetherDb - `loadout` reach `AvailableProducts`. Many tests call `GenerateShipLoadout(h => h == hull)` on - shipped hulls, and none of those hulls is a Boss hull. -- **BH8. The boss path at `095baf2f`.** `ZoneGenerator.cs:401-411` calls - `GetLoadoutGenerator(bossFaction).GenerateBossLoadout()` for every `BossZones` entry in the zone. - It logs "No boss could be generated" on null and otherwise marks the pack `Boss` and the zone - `BossSpawned`. `:414-415` logs exit-without-boss. `GetLoadoutGenerator` (`:282-287`) caches one - generator per faction over the zone's `random`. `GenerateBossLoadout` (`LoadoutGenerator.cs:221-233`) - builds `BossHullsOf[0]` through `ShipLoadout(product)` (`:55-66`: instance, Ship, - `OutfitEntity`, `EquipPickup`, `StockConsumables`, Pack). Otherwise it logs "makes no boss - hull" and generates the priciest ship hull on offer. Its readers are only `ZoneGenerator.cs:403` - and AetherDb `loadout` (`Program.cs:815`). -- **BH9. The package writer.** `ShipAuthoringCommands.cs`: `Usage` at `:11`; h1 added `boss - on|off`. Compose copies a package's HullData verbatim. - `ShipProductTests.ABossPackageIsItsMakersBossAfterCompose` passes at `095baf2f`. -- **BH10. Zhestokost makes no ship hull, and the shipped catalog has no Boss hull.** Products PL6 - still holds. Today's demo boss is the generated fallback: the priciest ordinary ship hull - Zhestokost can be offered. Under r4 there is no fallback, so the demo exit has no boss until a - Zhestokost Boss hull and its Loadout exist (question `demo-boss-before-its-hull`). -- **BH11. Which hulls can be bosses.** Ruling `hull-programmer-art-reads-as-stand-in` (it - supersedes `hull-programmer-art-stopgap`) leaves the stopgap mechanism open. The Eyes facts file - at `ac835a8a` places stopgap art on the package path. Nothing in either derivation reads - `Prefab`, `Visual` or a model. -- **BH12. The three failures in h1 are a fixture defect.** Probe p1 ran ygg-verify at `095baf2f` - with the filter of the three tests and the detailed console logger. All three throw - `InvalidOperationException: Sequence contains no matching element` at - `RunStartTests.FactionNamed` (`RunGoalTests.cs:75`), called with `"Lucent"` at `:130`, `:149` - and `:185`. The shipped faction is `"Lucent Media"`, and `"Lucent"` is its ShortName - (`DemoTerminusTests.cs:23`, `CatalogRoleTests.cs:368`). Probes p2 and p3 used a scratch clone - with only that string changed (`f683baa4`). All three passed: `Passed - A_boss_hull_two_makers_sell_is_a_boss_of_each`, `Passed Boss_hulls_are_never_stock_or_traffic` - and `Passed Another_factions_boss_hull_is_never_used`. The h1 job hid the messages because its - grep kept only `Assert|Expected|Actual` lines. Verdicts: - - `Boss_hulls_are_never_stock_or_traffic` is a real fixture defect. Its rule (ruling - `boss-hulls-exclusive`) holds once the name is fixed, and the test survives unchanged - otherwise. - - `Another_factions_boss_hull_is_never_used` is a real fixture defect. Its fallback assertions - die with the revoked path. Its rule, that another maker's Boss hull is not this faction's, - survives as a `NoBossHull` refusal. - - `A_boss_hull_two_makers_sell_is_a_boss_of_each` is a real fixture defect. Its `BossHullsOf` - rule survives. Its last assertion (the boss flies "Shared Boss") becomes "flies its Loadout". -- **BH13. The Loadout record already carries the key from a boss hull to its gear.** `Loadout` - (`Loadout.cs:14-22`, schema `aetheria.loadout` v1) has `Name` (Key 0), `Hull` (Key 1), - `Slots` (Key 2: `Position`, `Rotation`, `Design`, optional `Product`), `WeaponGroups` (Key 3) - and `HullProduct` (Key 4, optional). `HullProduct` names the product, and therefore the maker, - of the hull. So "the Loadout whose `HullProduct` is the faction's boss hull product" needs no new - field on `Loadout`, `Faction`, `FactionProductData` or `HullData`. It also tells apart two makers - of one shared Boss hull. -- **BH14. `Loadouts.Materialize` is already the builder a fixed boss needs.** It is all or - nothing, pins a set product as named, and builds a design-only slot through the passed chooser. - Its doc says a game spawner passes `LoadoutGenerator.ChooseProduct`. It builds a Ship from a ship - hull with the given zone, null included, as `ShipLoadout` did, and pads to `WeaponGroupCount`. - It adds no pickup, consumables or outfit. Its callers today are `ScenarioStage.cs:179` (fits) - and the capture round trip in tests. -- **BH15. A catalog Loadout cannot name a package hull.** `Loadouts.Commit` (`Loadout.cs:78-98`) - is the only writer of Loadout records, through the in-game capture command - (`ActionGameManager.cs:643`). It refuses any loadout whose hull or slot design is a mod key. - Package hulls and products are keyed `mod-hull:`, `mod-product:` and `mod-ship:` - (`ShipModCatalog.cs:23-30`), and "the shipped catalog ... never reference[s] one". A package - carries only its HullData, its ShipAuthoring and its products (`ShipModCatalog.Package`, - `:13-21`). So a boss Loadout for a package Boss hull has to ride in the package. That is the - next cut line below, not r4. -- **BH16. Boss-dependent tests.** `RunGoalTests.cs` holds 18 tests. Those that read the demo's - fallback boss are `Boss_spawns_once_in_boss_zone`, `Boss_spawns_in_a_zone_that_is_not_ambient`, - `Exit_sealed_until_boss_dies`, `Boss_mark_survives_continue` and - `Exit_open_after_save_of_a_slain_boss`. `Exit_sealed_when_no_ship_hull_is_on_offer` pins the - fallback's null branch. No other test file references `IsBoss`, `BossSpawned` or `ExitOpen`. -- **BH17. CultCache variants are not available to Aetheria yet.** CultLib `src` has variant - records (`CultCache.cs:59-60`, `CultVariantDelta`, "a variant record (slot 4): its base and - overrides"). Aetheria pins CultLib `45c2f4006f5f` (`Directory.Build.props:6`), and - `git grep CultVariantDelta 45c2f40` finds nothing. An interim Boss hull copied from a shipped - hull would be a plain copy until the pin moves. "Variant" is that record kind, never a loadout - (ruling `boss-gear-is-a-loadout`). +- **BL1. What decides the boss today.** `ZoneGenerator.GenerateZone`'s boss block (`ZoneGenerator.cs:423-439`) + calls `LoadoutGenerator.GenerateBossLoadout` (`LoadoutGenerator.cs:212-230`). That method flies + `Faction.BossHull` (`Corporations.cs:42-43`, key 10) through `GenerateShipLoadout`. When that is unset or + unsold it flies the dearest ship hull on offer. So every demo boss today is the generated fallback. The + main galaxy picks boss factions by `BossHull.IsSet()` (`Galaxy.cs:291-295`). AetherDb `factions` prints the + hull (`Program.cs:700-718`), and the `dangling` comment names it (`:586`). +- **BL2. Catalog state (probe: msgpack decode of `GameData/Aetheria.cc`, 186,037 bytes).** There are 15 Faction + rows, each a 22-element array (keys 0-21). Index 10 is `''` (unset) on all 15, so deleting `BossHull` + loses no data. Index 20 is nil on all; it was `assemblyDeviation`, retired by `30597b0e`, with no + never-reuse note in the source. The catalog holds no `aetheria.loadout` records. The new reference + therefore takes key **22**. +- **BL3. A Loadout names its hull by design.** `Loadout.Hull` (key 1) is the design and `HullProduct` (key 4) + the optional brand (`Loadout.cs:14-22`). `Loadouts.Materialize` (`:113-167`) builds hull and slots through + `Resolve` (`:174-214`). A pinned product is built as named whatever the chooser says. A design with no + product at all is built unbranded, with no maker, at `UnbrandedQuality` .95. Otherwise the chooser picks. + So a boss Loadout needs no format change, and the boss is built by the same builder as scenario fits and + capture. Header and comments still say "preset (a variant)", which ruling `boss-gear-is-a-loadout` + corrects. `ActionGameManager.cs:645-647` prints "Preset '...'". +- **BL4. What is on the market today (probe: every enumeration of designs and products).** Every generated + draw goes through `LoadoutGenerator.AvailableProducts` (`:188-210`): hulls, gear, station stock and + consumables. It enumerates `FactionProductData` and filters by `IsAvailable` (`:251-253`), the design's + price > 0 and a gear home. `ChooseProduct` (`:262-272`) is the chooser for design-only slots. It + enumerates products and filters by the same `IsAvailable`. `SoldHulls` (`:237-238`), the hulls that + give gear a home, enumerates products. No game path offers a design except through a product. The design + enumerations found are `ShipModShips.cs:44` (visual prototypes), `ShipAuthoring.cs:181` (package load), + `ShipModCatalog.cs:139`, `ZoneGenerator.cs:274` (station reach), console `give` (`ActionGameManager.cs:539`) + and AetherDb reports. None of them stocks, spawns or sells. So **an item is on the market iff some product + of it is on offer** is already the rule, and its one owner is the product set read through + `AvailableProducts`, `ChooseProduct` and `SoldHulls`. The price-0 filter in `AvailableProducts` is a + second, partial gate. `ChooseProduct` and `SoldHulls` ignore it, and package validation refuses price 0 + for a hull with products (`ShipAuthoring.cs:220-229`). It is value, not market membership, and is not a + candidate mechanism. +- **BL5. "No product" already has a meaning.** Scenarios ruling Q3 (`docs/scenarios-cut.md:20`, `:155`) says + "a design no product sells is a scenario test design and nothing else". `ScenarioTests.cs:576-610` + (`EveryUnsoldDesignIsAScenarioTestDesign`) and `ProductLinesCatalogTests.cs:14-34` (weapon designs: Smoke + only) pin it. A package hull may ship with no products (`ShipAuthoring.cs:222`, `if (products.Length == 0) + return;`). +- **BL6. Brand rulings.** `designs-are-tech-nodes`: flavour names belong to products. + `catalog-grows-generic-designs-branded-products`: branding lives in products. With no product, a boss + hull's only name is its design's, and its quality is .95 rather than drawn from its maker's + `Faction.Assembly` (key 19). +- **BL7. Boss consumers that stay.** `Galaxy.PlaceGate` (`Galaxy.cs:148-151`) maps the demo antagonist's home + to `BossZones` and `Exit` whatever the antagonist flies. `RunGoal` (`RunGoal.cs:10-17`) opens the gate only + when the exit zone spawned a boss and it is dead (it fails closed). `EntityPack.Boss` and + `EntitySerializer` (`:31`, `:85`) are unchanged. `ActionGameManager.cs:365` says "Its guardian holds it + shut" when no boss is present. +- **BL8. Tests on the old path.** `RunGoalTests.cs` (329 lines): `Boss_hull_from_BossHull_else_fallback` + (`:73-97`), `Boss_hull_nobody_sells_falls_back` (`:99-115`) and `Exit_sealed_when_no_ship_hull_is_on_offer` + (`:214-228`) pin the fallback. `Exit_open_only_in_the_exit_zone` (`:193-212`) sets `BossHull`. Five tests + rely on a demo boss existing on the bare catalog (`:41-71`, `:116-160`, `:230-240`). No other test reads + `BossHull` or `GenerateBossLoadout` (grep). Other tests touch `BossZones` only as saved-game fields. +- **BL9. Reusable from `90f977bf`.** The Loadout.cs header rewrite (`git show 90f977bf:Assets/Scripts/ServerShared/Loadout.cs`, + lines 9-13). The ZoneGenerator boss block shape (Materialize with `liveZone: null` and + `GetLoadoutGenerator(bossFaction).ChooseProduct`; green in h2's full run at `d912cf44`, 2431 passed). + The fixture pattern `MakeBossLoadout` (capture a generated fit, keep its weapon slots, renumber groups) and + `StoreLoadout` through `_cache.Commit(batch => batch.Upsert(typeof(Loadout), ...))` (`fc992f5a`). + h2's diagnosis that three r3 failures were the fixture name "Lucent" for "Lucent Media". h2's + `LoadoutCommitCommand` (125 lines, plus 210 lines of tests) takes a hull *product*. It is for the next + cut and must accept a hull design. -### Rationale (r4) +### Rationale (r1) -- **One owner of what a boss flies:** `Loadouts.BossOf(CultCache, Faction, out BossRefusal)`, in - `Loadout.cs` beside the other owners of Loadout records. It takes `BossHullsOf(items, faction)[0]` - and returns the Loadout whose `HullProduct` key is that product's key, first by Name ordinal. - When there is none, it returns null with `NoBossHull` or `NoLoadout`. It reads no `Random`, no - model and no availability rule. `ZoneGenerator`'s boss block, `Galaxy.PlaceFactionsMain` and - AetherDb `factions` all call it. `LoadoutGenerator.BossHullsOf` stays the one owner of which hull - is a boss. -- **Keyed on the product, not the hull** (BH13). A Boss hull two makers sell is a boss of each, - and each maker's boss is the Loadout on its own product. No field on Faction or the product - points at a Loadout, so no second record can disagree with the Loadout. -- **No generator on the boss path.** `GenerateBossLoadout`, its priciest-hull fallback and - `ShipLoadout(product)` are deleted. `GenerateShipLoadout` gets back its single body, because the - boss was `ShipLoadout`'s only other caller. The boss is built by `Loadouts.Materialize`, the one - builder from a Loadout (BH14). It carries exactly its cells: no tractor pickup, no consumables - and no outfit pass. -- **The chooser is not a draw of gear.** Materialize needs a chooser only for a design-only slot. - Ruling `loadout-product-optional-context-chooses` says that slot's product is chosen by context, - and `ChooseProduct` is that rule's owner. It never picks a design, a cell or the hull. The - negative check forbids every generator draw on the boss path (`GenerateShipLoadout`, - `RandomHull`, `RandomProduct(s)`, `OutfitEntity`, `EquipPickup`, `StockConsumables`). An agent - who wants the brand fixed pins the slot's product. -- **A typed refusal, never a fallback.** `BossRefusal { None, NoBossHull, NoLoadout }`. - `Loadouts.Describe` names the missing field (`HullData.Boss`, `Loadout.HullProduct`) and echoes - no product name or record key. The zone logs ` has no boss: it .`, spawns - nothing and leaves `BossSpawned` false, so the exit gate fails closed through boss-gate's - existing rule. A Loadout that does not fit is logged with Materialize's failures and also spawns - nothing. -- **Galaxy picks factions that have a boss to fly.** In the main galaxy, a faction with a Boss - hull but no Loadout gets no boss zone. That keeps a placed boss zone from being empty by - construction. -- **Authoring** (ruling `boss-loadouts-agent-authored`). AetherDb `loadout-commit [,,,[@]]... [--group i,j]... [--replace]` writes one - Loadout through `Loadouts.Commit`, which stays the only writer. The command first resolves every - name exactly and test-materializes the Loadout, so a typo or a misfit refuses before anything is - written. The operator adjusts the record in Studio afterwards. The command is argument-driven, - not a coded table like `product-lines tiers`. A coded table replayed at merges would fight her - Studio edits. The record in the catalog is the authority, and the commit message records the - command line for a merge replay. -- **Vocabulary.** `Loadout.cs` says "Loadout" throughout. "Preset" and "variant" go from its - comments and messages. The wider sweep (the `capturepreset` console command, `ScenarioStage`, - `Provenance`, `LoadoutGenerator` and `ActionGameManager` comments) is follow-up - `loadout-vocabulary-sweep`. -- **Kept from r3 (h1, landed):** Faction.BossHull is deleted with key 10 retired, `HullData.Boss` - Key(33) stays, `BossHullsOf` stays, the exclusive clause in `AvailableProducts` stays (ruling - `boss-hulls-exclusive`), and `ship-authoring boss`, the compose test and the no-authored-model - test stay (ruling `hull-programmer-art-reads-as-stand-in`). -- **Revoked from r3:** `GenerateBossLoadout` through `BossHullsOf` and `ShipLoadout(product)`, the - AetherDb `loadout` boss branch, `Boss_without_a_boss_hull_falls_back`, and the "makes no boss - hull" log. -- **Prior art (from memory, medium confidence; no Eyes pass this round):** Starsector's unique - bosses (for example the Ziggurat and the Omega flagships) fly hand-authored variant files - attached to a scripted fleet, never the faction's autofit. EVE's officer and faction-commander - spawns have fixed fittings per NPC type. Both match a fixed record keyed to the boss, with no - generator. +- **Which Loadout is a faction's boss.** One owner: the field `Faction.BossLoadout` (key 22), authored in + Studio or by an agent command (ruling `boss-loadouts-agent-authored`). `Loadouts.BossOf(items, faction, out + BossRefusal)` is its one resolver (Unset, Missing). ZoneGenerator, Galaxy's main-galaxy pick and AetherDb + `factions` call it. Nothing derives a boss from hulls, products or flags. +- **What a boss flies.** Its Loadout, through `Loadouts.Materialize`, whose chooser + (`ChooseProduct`) only brands a design-only slot (ruling `loadout-product-optional-context-chooses`). No + generator draw sits on the boss path, and nothing is outfitted, picked up or stocked. +- **What is on the market.** BL4: on the market iff a product of the item is on offer, owned by the product + set through `AvailableProducts`, `ChooseProduct` and `SoldHulls`. A boss hull reaches a ship only through a + Loadout, which names it by design (`Loadout.Hull`) or pins its product. This cut adds no off-market code. No + boss hull exists yet (ruling `demo-boss-hull-invented`), so nothing in this cut depends on how an + off-market item is marked. Question `off-market-items-keep-a-maker` asks whether an off-market item keeps a + maker, a brand and a drawn quality. If it does, its product carries one general not-offered field read by + `IsAvailable` and `SoldHulls`. If it does not, it has no product, and the Q3 census accepts a design a + Loadout names. The recommendation is the field (BL5, BL6). The answer lands with the boss-hull authoring + cut. +- **The demo exit meanwhile.** After this cut and until a Zhestokost boss Loadout is authored, the demo exit + zone logs "Zhestokost has no boss: it sets no Faction.BossLoadout." and the exit-without-boss line. No boss + spawns, `BossSpawned` stays false, and the gate stays sealed (RunGoal fails closed). **The demo cannot be + won.** The sealed-gate dialog says "Its guardian holds it shut", which is wrong while no guardian exists. + That stands until the boss lands; it is not reworded here. +- **Rejected.** A boss flag on hulls (operator, ruling `faction-boss-is-a-loadout-reference`). Excluding + hulls named by any boss Loadout from `AvailableProducts`: a boss-specific clause, barred by ruling + `off-market-is-general`. Price 0 as the off-market mark (BL4). Keeping a generated fallback: barred by + rulings `boss-gear-is-a-loadout` and `demo-boss-hull-invented`. -### Model page rows +### Cut sequence (boss strand) -Replace the rows in "Demo scope, Model page rows" (`Boss hull`, `Faction.BossHull`), and add the -boss Loadout row: - -| Kind | Identity | Lifecycle | Authority | -|---|---|---|---| -| Boss hull | `HullData.Boss` (key 33, bool) on any HullData: shipped, package, or agent-built stopgap | Set by `ship-authoring boss on\|off` on packages, or in Studio's inspector on catalog hulls. Derived per generation; stored nowhere | The hull's author sets the flag. `LoadoutGenerator.BossHullsOf` derives the faction from the product's maker | -| Boss Loadout | A `Loadout` record (`aetheria.loadout` v1) whose `HullProduct` is a faction's Boss hull product, at `Loadouts.KeyOf(name)` | Written by AetherDb `loadout-commit` (agents) or the in-game capture, both through `Loadouts.Commit`. Studio edits after that. Read at zone generation by `Loadouts.BossOf` | `Loadouts.BossOf` alone decides what a boss flies, and `Loadouts.Materialize` builds it. No generator draw | -| `Faction.BossHull` (key 10) | Retired; never reuse | Deleted by `boss-hull-derived`. The shipped records keep `''` in the slot (BH5) | none | - -### Cut sequence - -- `boss-hull-derived` (spec `cut-boss-hull-derived.r4`, branch at `095baf2f`). Its merge waits on - question `demo-boss-before-its-hull`. -- If `interim-catalog-boss` is chosen: `zhestokost-interim-boss`, a sibling content cut that lands - with r4. Through AetherDb it writes a catalog Boss hull copied from the hull today's demo boss - flies (BH10), plus its Zhestokost product and its Loadout (`loadout-commit`). It is replayed at - merges and replaced when the real Zhestokost Boss hull lands. -- `package-boss-loadout`: a package carries its boss Loadout, as it carries its product (ruling - `package-carries-product`, BH15). `ship.cc` gains Loadout records, Compose writes them under a - `mod-loadout:` key that `IsModKey` knows, and `ship-authoring loadout` writes them. It is needed - before the first package Boss hull can be a boss. `BossOf` already matches it unchanged, by - `HullProduct` key in the composed cache. -- `demo-cast-hulls`'s "Waits on" reads "boss-hull-derived (spec cut-boss-hull-derived.r4)" until it - merges. +- `boss-loadout` (spec `cut-boss-loadout.r1`): `Faction.BossLoadout` replaces `BossHull`; + `GenerateBossLoadout` is deleted; the boss refuses typed. +- Next: boss Loadout authoring. AetherDb `loadout-commit` (from h2, taking a hull design with an optional + `@product`) and a way to set `Faction.BossLoadout`. It lands with the answer to + `off-market-items-keep-a-maker` and the invented Zhestokost hull (follow-up `zhestokost-boss-loadout`). ## Product tiers @@ -11269,197 +11190,384 @@ with the wider scope taken when the FusedRounds premise is fixed. `no-save-compatibility-before-players` the cut may refuse it, but a refusal gate would be code written for nobody. -## Stand-in hulls (SI) +## Agent-built hulls -**Superseded 2026-10-18 by ruling `hull-art-communicates-form`:** the placeholder-look goal and the schematic-generated body below are set aside; agent-built hulls are low-poly models that communicate each ship's form and faction, beside hero assets. The probe's package path, anchors and replace-by-real-art lifecycle are evidence for the re-map; its visual grammar and cut sequence are not live. +Replaces section "Stand-in hulls (SI)". Imagination pass `imagination-ag6-hull-art2`, session `self-2026-10-18-ag6`, +2026-10-18, pinned to Aetheria `origin/master` d28ce42d (worktree `C:\aeth-wt\ag6-hull-art2`). Serves ruling +`hull-art-communicates-form`. It supersedes `hull-programmer-art-reads-as-stand-in`. The ruling asks for agent-built +low-poly hulls that communicate each ship's intended form and its faction ("A Zhestokost ammo tender shouldn't look the +same as a Pirate fighter"). It also asks for a couple of fully produced hero assets to compare them with, so that hull +variety is not gated on the operator's manual throughput. Research: +`docs/research/2026-10-18-agent-built-hull-art-prior-art.md` and `docs/research/2026-10-18-faction-form-language-prior-art.md`. -Imagination pass `imagination-ag6-hull-art`, session `self-2026-10-18-ag6`, 2026-10-18. Pinned to Aetheria -`origin/master` 6d10331c (worktree `C:\aeth-wt\ag6-hull-art`). Serves ruling -`hull-programmer-art-reads-as-stand-in`, which supersedes `hull-programmer-art-stopgap`. -Research input: `docs/research/2026-10-18-agent-built-hull-art-prior-art.md`. That file surveys making agent-built -art look good or deliberate. This ruling asks for the opposite, so the research's §B3 (low poly that reads as -deliberate) is evidence about what to avoid. The greybox "dev texture" convention below comes from general practice, -not from that file (one of the gaps listed at the end). +Kept from SI: the package path, the anchor rules (SI-B1 to SI-B3), and replace-by-real-art as one act by one owner. +Dropped from SI: the body generated from the schematic, the grey grid mark, and glyphs by mount kind. -Scratch root `S` = `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\1b477360-19e3-41e2-bde4-8932ace0b8fd\scratchpad\imagination-ag6-hull-art`. +Scratch root `S` = `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\1b477360-19e3-41e2-bde4-8932ace0b8fd\scratchpad\imagination-ag6-hull-art2`. ### Renders for the operator -- `S\probe\out\longinus_top_standin_vs_existing.png`: top view, stand-in (left) and the shipped `Longinus.fbx` (right), each at its own scale. -- `S\probe\out\longinus_iso_standin_vs_existing.png`: three-quarter view, same pair. -- `S\probe\out\longinus_combat_zoom_proxy.png`: both top views at ship lengths of 48, 80 and 128 px. -- `S\probe\out\longinus_top_same_metres.png`: both in one 40 m frame. The stand-in uses 2 m cells and the shipped mesh about 1 m per cell (SI-B5). +- `S\out\sheets\1_pair_same_scale.png`: the two probe hulls in three views (three-quarter, top, port side). Both use + the same camera, light and metres per pixel. On the left is the Zhestokost ammo tender "Podvoz", 23 m. On the right + is the Pirate fighter "Markdown", 15 m, built on a stolen Rossum & Douglas wedge. +- `S\out\sheets\3_pirate_vs_djinni_hero.png`: the Pirate fighter beside `Djinni.fbx`, the shipped Rossum & Douglas + hull. Djinni is grey because its materials are not read. +- `S\out\sheets\4_zhestokost_vs_operator_art.png`: the tender beside the operator's Zhestokost faction sheet + (`Asset Sources/Zhestokost.png`, cropped) and the shipped `Turret.fbx`. +- `S\out\sheets\5_combat_zoom_proxy.png`: both top views at ship lengths of 48, 80 and 128 px. +- `S\out\sheets\2_pair_own_framing.png`: each hull framed to its own size. -All four come from one scratch rasterizer (`S\probe\render.py`) with the same camera and light. The stand-in side is -read back from the `ship.glb` the package path accepted, so it is not a separate preview. +All renders come from one scratch flat-shaded rasterizer (`S\render.py`). They are read back from the `ship.glb` +files that the package path accepted. -### What the game reads (body facts) +### Probe (AH-P) -- **SI-B1. Package shape.** A mod hull is `GameData/Mods//ship.cc` plus `ship.glb`. The `.cc` holds `HullData` - (`mod-hull:`) and `ShipAuthoring` (`mod-ship:`; `ModelAsset`, `Anchors`, `SchematicLines`, `Joints`). - Source: `ShipAuthoring.cs:12-21`, `docs/moddable-ship-authoring.md:160-200`. -- **SI-B2. Anchors the game needs.** Exactly one each of `map-icon`, `hull-collider`, `shield` and `tractor` - (`ShipAuthoring.cs:288-290`). Each visible hardpoint has a mount anchor whose id is its mount id: `thruster-emitter`, - `radiator-mesh` or `weapon-mount`. Each weapon needs at least one `weapon-muzzle` whose ParentId is the mount - (`:325-356`). These nodes must carry a mesh: thruster-emitter, radiator-mesh, map-icon and hull-collider. The - shield node needs three positive scale radii (`ShipModCatalog.cs:256-262`). Weapon mounts and the tractor may be - empties. Internal hardpoints have no anchor. Every hardpoint needs a non-empty mount id. -- **SI-B3. Frame.** Blender Ship Root has the nose toward -Y, starboard toward -X and Z up. Cell (x, y) is centred at - `origin - 2(x, y)`, schematic +x is starboard and +y is forward, so row 0 is the stern - (`tools/blender/aetheria_ships/hull_grid.py:3-20`). Package exports with `export_yup` (`__init__.py:791`), so the - glTF nose is +Z. Hardpoint `Rotation` gives the facing: 0 nose, 1 port, 2 aft, 3 starboard - (`hull_grid.py:141-145`). A footprint shape is stored as placed, and validation reads it at `Position` without - rotating it (`ShipAuthoring.cs:310-320`). -- **SI-B4. Longinus HullData** (decoded from `GameData/Aetheria.cc` with cultcache-py, `S\grid.py`). It is 6 x 17 - cells, symmetric, with 13 hardpoints. Visible: Th.L and Th.R (2x2, rows 0-1, aft), Th.CW and Th.CCW (1x2, row 14, - port and starboard), En.L and En.R (1x2, row 8), La.L and La.R (1x3, flanks, rows 5-7), Ra.L and Ra.R (1x2, - row 2). Internal: Reactor, ControlModule, and Sensors, which has a **null mount id**. Because of that null id, a - package copy of the shipped Longinus is refused until the Sensors hardpoint is given an id (`ship_cc.replace_layout` - raised "needs 7 typed fields and a stable mount ID"). The probe gave it the id `Sensors`. -- **SI-B5. Scale.** The add-on authors at 2 m per cell (`hull_grid.py:11`, "Settings.SchematicCellSize"; New Ship - sizes "this many 2 m cells"). The shipped `Longinus.fbx` body is 5.2 x 16.8 m for 6 x 17 cells, about 1 m per cell - (`S\fbx.py`, mesh `Cube.043`). Ruling `schematic-and-model-are-separate-spaces` already allows the two to differ. - The stand-in uses the add-on's 2 m. -- **SI-B6. Materials today.** `ShipModVisual` imports the GLB with glTFast and keeps its glTF materials. The only - material it adds is the schematic line material (`ShipModVisual.cs:55-64`). `Aetheria/Hull` and the UV0 livery mask - belong to `ships-hull-material`, which has a spec but has not landed. So a stand-in's look today is whatever its - glTF materials say. -- **SI-B7. `ship-authoring create --like`** copies a shipped hull's stats with an empty shape and no hardpoints - (`ShipAuthoringCommands.cs:57-75`, `HullLike` clears `Shape` and `Hardpoints`). The layout comes from Blender or - from `ship_cc.replace_layout`. -- **SI-B8. OpenSCAD over LevelDsl, decided from source.** The Geometry.Csg `LevelDsl` has box, cut box, oriented box, - cylinder_z, dome_cap_z and floret_arm, with no hull or free-axis cone (research, Internal fit), and it needs a - cargo build. OpenSCAD nightly with Manifold is installed, and brush-tree's `openscad-target` already rules - OpenSCAD-shaped text. LevelDsl was not probed. +The probe has three layers, each a separate file: +- **Faction languages**, in `S\lang\`. `zhestokost.kit.scad`, `rnd.kit.scad` and `pirate.kit.scad` hold the shape + vocabulary as OpenSCAD modules. `*.lang.json` holds the identity and forbidden lines quoted from the lore, the + proportion ranges, the symmetry rule and the palette zones. The target folds each faction's JSON into its `.scad`; + see cut `agent-hull-source`. +- **Role briefs**, in `S\hulls\*.brief.json`. Each gives the faction, donor, role, the play line from + `Faction Play.md`, the reads the hull must show, the size class, the triangle budget and the mounts. +- **Hull sources**, in `S\hulls\*.scad`, written by the agent. They are about 40 lines each, compose only kit modules, + are split by colour zone, and echo their anchors. -### The probe (Longinus) +`S\build.py` renders each part through `openscad.com --backend Manifold --render all`, one process at a time. All 28 +PIDs are in `S\out\pids.log`, the first 3404 and the last 20348. It then writes `ship.glb` and runs the checks. -The stand-in source has three files (`S\probe\`): -- `standin.scad`: the vocabulary. Glyph per kind, kind colours, cell frame, part dispatch. -- `longinus.layout.scad`: generated from HullData by `gen_layout.py`, and never edited by hand. It holds the visible - mounts: id, kind, position, placed footprint and rotation. -- `longinus.standin.scad`: agent-authored, 25 lines. Four `hull()` segments make the body (stern block, flared - weapons section, spine, nose), plus a `deck()` height function. +- **AH-P1. Checks.** All pass (`S\out\\checks.json`). + - Tender: 3468 triangles. The bbox is 10.5 x 23.4 x 5.9 m, inside its 18-28 m size class. Body h/w is 0.42 and w/l + is 0.61, inside Zhestokost's ranges [0.25, 0.6] and [0.35, 0.75]. Bilateral symmetry has a worst mirror miss of + 0.0000 m. It has five mount anchors, and every weapon has a muzzle nose-ward of its mount. + - Fighter: 1466 triangles and 15 m long. The donor body's h/w is 0.20 and w/l 0.69, inside the R&D ranges. The + asymmetry rule holds. + - The vertex-based asymmetry figure (0.91 unmirrored) is too lenient, because tessellation alone breaks vertex + pairs. The silhouette measure below is the one to keep. +- **AH-P2. Package path, on Yggdrasil, GREEN** (`S\ygg-agenthull-2.log`). The run used the scratch clone + `...\imagination-ag6-hull-art\vclone` at a10b5d64. That is 6d10331c plus throwaway commits, never pushed; + `git diff 6d10331c d28ce42d` touches no ship code. The job: + 1. `ship-authoring create --like Longinus` for `agent.zhammotender` and `agent.pifighter`. + 2. A gameplay layout authored separately, with mount ids matching the hull source's anchors. + 3. `ship_cc.replace_visual` with the anchors. + 4. `validate`: "7 hardpoints, 10 anchors" and "5 hardpoints, 8 anchors (records and model)". + 5. `compose`: "Composed 2 mod ships". -`build.py` renders each part separately with `openscad.com --backend Manifold --render all -D part="…"` (one process -at a time, about 22 runs). It then writes `ship.glb`: a flat-faceted mesh per part, UV0 as a top-down projection in -cell units, and nodes tagged `extras.aetheria.id`. It also writes `anchors.json`. + Run 1 was red because it had no `/pins` or `/lfs` mounts (the `DOCKER_ARGS` recipe in briefs.md). +- **AH-P3. Silhouettes** (`S\out\sheets\metrics.json`). The silhouettes are scaled to their own bounding box, so the + comparison is of shape, not size. Tender against fighter: IoU 0.69 from the top and 0.52 from the side. Mirror IoU + from the top: tender 1.00, fighter 0.80, Djinni 1.00. These numbers are guardrails, not the judge. +- **AH-P4. What the renders show.** + - The two read as different ships at every size, including 48 px. The tender is a dark, broad stepped block with + twin handling booms and eight oxide-red magazines hung outboard. A top-down camera sees those magazines; the + first version hung them under the booms, where they were hidden from above, and was moved. The fighter is a pale + wedge with a gun as long as the hull on the port side, a mismatched bell drive on the starboard quarter, a teal + crew slash and primer plates. + - The faction read holds without labels: + - Zhestokost shows stepped masses, deep seams, plates, tow lugs, recessed machinery and a low broad body. + - The Pirate fighter shows the donor wedge plus additions, each with a mount: a saddle under the gun, struts on + the drive, a strut under the sensor pod. A cable run joins the gun to the drive. + - Weaknesses: + - The charcoal Zhestokost body is low-contrast on dark space at 48 px. + - The fighter's thin side profile carries little. + - Nothing yet has the operator's one-segment-bevel and weighted-normal finish. Box chamfers stand in for it. + Blender's Bevel and Weighted Normal at Package would supply the real finish (`shippable-part-bar`, brush-tree). +- **AH-P5. Not proven.** + - Unity: the ships were not flown, and `ShipModPlaySmoke` was not run. + - Blender was not in the loop. + - The real combat camera was not used. + - Only one agent authored both hulls. No second hull per faction tests whether the language holds a family + together; that is the next probe line. -- **SI-P1. Measurements** (`S\probe\build.py` output): - - The body has 104 triangles. 0 of its 60 vertices lie outside the hull cells, and the worst miss of the mirror - test is 0.0000 m (the body is symmetric). - - The GLB is 84 KB, with 28 nodes and 18 anchors. - - All 10 visible mounts project into their own footprint: weapons and radiators at the footprint centre, thrusters - on the footprint edge they face. - - The probe checked vertices, not edges, against the cells. Each segment is convex inside a rectangular run of - cells, which is why the vertex check suffices here; a stepped outline needs an edge check. -- **SI-P2. Package path, on Yggdrasil.** `ygg-verify` ran on a scratch clone at `2329782244d3`. That clone is - 6d10331c plus `standin-probe/`; its commits are throwaway and were never pushed to origin. Image - `mcr.microsoft.com/dotnet/sdk:10.0`, with pins `/pins/cultlib-git-45c2f40` and `/pins/cultlib-9c1b06cd` and the - `/lfs` catalog. Log: `S\ygg-standin-6.log`, GREEN. The steps were: - 1. `ship-authoring create … --like Longinus`. - 2. `ship_cc.replace_layout` with Longinus' shape and hardpoints, then `ship_cc.replace_visual` with the 18 anchors - (the add-on's own Python). - 3. `validate`, which reported "13 hardpoints, 18 anchors, 0 lines (records and model)", meaning - `ShipModCatalog.ReadPackage` judged the GLB. - 4. `compose`, which reported "Composed 1 mod ships", with no exclusions. +### Body facts found on the way (AH-B) - Runs 1-5 were red on job plumbing: LFS pointer, CultLib pins, empty layout from `--like`, and the null Sensors id. -- **SI-P3. Not proven.** The Unity side was not run: `ShipModPreview.Smoke -assemble` and `ShipModPlaySmoke.Run` - (thrusters, weapons with muzzles, collider, map icon). The probe's GLB writer is scratch Python, not the add-on's - Package Ship. Blender was not in the loop, because no current Blender is on Starfire (only 2.77 and 2.78 under - `D:\Applications`). Glyph proportions were checked only in the proxy renders, never at the real combat camera. -- **SI-P4. What the renders show.** - - From above, the stand-in states where every gun, launcher, engine and radiator is, by colour. At 48 px the - shipped mesh reads only as a silhouette. - - The body reads as an unfinished blockout: slab facets, a cell grid, no surface detail. Nobody would take it for - the shipped hull. - - The grid is texture noise at 48 px. In engine, mips average it away at distance. - - The first radiator fins were edge-on from above and vanished. They became panels tilted 30° outward. Energy - barrels were thickened from 0.14 to 0.28 m for the same reason. +- **AH-B1. A current Blender is installed.** `C:\Program Files (x86)\Steam\steamapps\common\Blender\blender.exe` + exists. Follow-up `blender-pin-operator` records it as Steam's 5.2.2 LTS. The brief's premise ("only 2.77/2.78") + came from `D:\Applications` and is wrong, so no install question is raised. +- **AH-B2. Package already derives map-icon, shield and tractor** (`_regenerate_anchors`, `__init__.py:1009`). A + hull source therefore emits only mounts and mesh anchors. That subtracts four anchors from what the probe built. +- **AH-B3. The add-on derives schematic Positions from the placement of model mounts.** `_plan_mounts` + (`__init__.py:409`) writes the HullData Position from where a weapon-mount empty sits. This conflicts with + `schematic-and-model-are-separate-spaces`, under which nothing is derived from model geometry, and it is pre-existing. + Cut `agent-hull-source` measures it first and reports it as a fork rather than bypassing it. Its owner is the + hardpoint walkthrough (`hardpoint-work-held-for-walkthrough`). +- **AH-B4. No shipped ship embodies a current faction language.** + - Djinni (R&D) is a rounded, bulbous body, not the lore's flying wedge. + - The operator said of Longinus (Alakrita) that she would be happy never to see it again. + - Turret is a Zhestokost turret, not a ship. -### Visual grammar (proposal; the look is question `standin-visual-mark`) + So the heroes are a real fork (question `agent-hull-hero-assets`). +- **AH-B5. The two Zhestokost sources disagree.** + - The lore page gives charcoal, iron and oxide, a low broad hull, and identity "without relying on red paint". + - The operator's faction sheet shows a tan body with dark breakup, orange-red stripes, a tall stepped tower, and a + forward boom with hanging magazines. -1. **Body.** A few convex `hull()` segments that follow the hull's outline and its concept (Concept Mill image or - description). Flat facets at `$fn = 8`. No bevels, panel lines, greebles or invented surface detail. One neutral - grey, carrying a dark line every 2 m cell on UV0, which is projected top-down in cell units. The grid is the - greybox mark, and because the cells are the schematic's, it also states size class. -2. **Mount glyphs by kind,** each in one saturated colour, on its footprint, facing its Rotation: - - energy: a puck and one long barrel; - - ballistic: a puck and two short thick barrels; - - launcher: a box with tube mouths on its facing face; - - thruster: a bell opening toward its facing, whose mouth disc is the `thruster-emitter` mesh; - - radiator: a panel tilted outward, which is itself the `radiator-mesh`. + The probe took its palette from the lore and the boom and magazines from the sheet (question + `zhestokost-language-source`). +- **AH-B6. A Pirate language is a composition.** The lore makes the Pirate body another faction's hull ("an + identifiable donor hull"). Bench 3 names donors: an AU miner, an R&D wedge. So the Pirate language is an additions + kit plus a donor language, never a body of its own. The checker reads the donor's proportions and the Pirates' + asymmetry rule. +- **AH-B7. The prompt-lab leak finding does not carry over to meshes.** For image models, shape motifs in a faction + block leak onto every hull (prompt-lab rule 3). For meshes nothing applies itself: a kit module appears only where a + hull source calls it. The language file can therefore hold shape vocabulary, and the role decides which pieces a + hull uses. - Internal hardpoints are not drawn, because the schematic UI owns them. -3. **Size.** Model metres are cells x 2 m (the add-on's rule). -4. **Faction.** None in the mesh. Once `ships-hull-material` lands, the body's UV0 is where the livery mask goes - (primary channel on the body). Until then the body is neutral grey. +### Pipeline (target) -### Model (SKILL step 0b): rows for the persistent kinds this adds +1. **Faction language** `tools/blender/aetheria_ships/factions/.scad`. One file holds the kit modules and a + header of constants: zone palette, symmetry rule, proportion ranges (a Pirate hull uses its donor's). The lore + sources are cited in comments. The operator owns it; agents draft it. +2. **Hull source** `GameData/Mods//hull.scad`. Its brief header gives faction, donor, role, length range, budget + and reads. It has one part per zone, and its anchors are echoed. An agent owns it. +3. **Build From Hull Source**, a new add-on operator. It runs OpenSCAD per part, one process at a time, and checks + against the language. It then makes the zone meshes (one material per zone) and the anchor objects under Ship Root. +4. **Save Layout** (the gameplay schematic, authored separately) and **Package Ship**, both existing: + - Package writes `ship.glb`, derives the map icon, shield and tractor, writes the anchors and runs validate. + - Zones become livery mask channels when `ships-hull-material` lands (`hull-paint-mask-texture`). +5. **Render and compare.** A contact sheet puts the hull beside its faction's hero and beside a different-faction + hull, at one scale and at combat size. A model reads the sheet and judges whether the hull reads as its faction and + role. No rule decides that, because it is a question of meaning. The operator rules on the first few. + +### Model (SKILL step 0b) | Kind | Identity | Lifecycle | Authority | |---|---|---|---| -| Stand-in source, `GameData/Mods//standin.scad` | The package directory, which equals `ShipAuthoring.Id`, names it. One per package, at a fixed name. Injective because package ids are. | Created by an agent pass for a hull with no operator art. Revised by agents. Deleted in the same change that lands operator art for that package. | The agent pass (Imagination for content, Hands for the cut) authors the body. The operator decides when it dies, by packaging her art. Forbidden writer: nothing hand-edits the layout. | -| Layout, generated | Derived from that package's HullData. | Regenerated on every build. Not committed, never edited. | HullData. Owner only under `standin-layout-follows-schematic` = follow-schematic. Under free-like-real-art this row disappears and the source holds mount placement. | -| Shared vocabulary, `tools/blender/aetheria_ships/standin/standin.scad` | Single file. | Revised by cuts; a revision re-renders every stand-in at its next build. | The ship add-on (Aetheria). | -| `ship.glb` | `ShipAuthoring.ModelAsset`: one slot per package. | While `standin.scad` exists, a build output of it. Afterwards, the output of the operator's Package. | Exactly one writer at a time. The slot holds one mesh, so two meshes can never both decide. | -| "This hull is a stand-in" | The presence of `standin.scad` in the package. **No new field.** | Ends when the file is deleted. | Derived. Build Stand-in refuses a package without the file, so it can never overwrite operator art. Any "which hulls are still stand-ins" view is a directory listing. | -| Anchors | `ShipAuthoring.Anchors`, ids = mount ids. | Rewritten by Package on each build, stand-in or art. Ids are stable across the swap. | Package Ship (`ship_cc.replace_visual`). The same anchor ids keep loadouts and saves valid across the swap. | +| Faction language, `factions/.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//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 | 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:`. | 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. | -Replacement when real art arrives: the operator packages her collection into the same package. Package writes -`ship.glb` and the anchors, and the same change deletes `standin.scad`. That is one act and one owner. -Open choice inside the cut, not an operator fork: whether Package deletes `standin.scad` itself or refuses until it is -deleted. Recommendation: Package deletes it when the bound collection was not built by Build Stand-in, and says so. +### Questions admitted -`standin.scad` ships inside the mod directory, so it may reach player builds. That is harmless, and it doubles as an -example for modders. The alternative is `tools/standin/hulls/.scad`, which breaks "presence in the package is the -status". +- `aetheria-release:question:agent-hull-hero-assets`: which meshes are the heroes. Recommended: `keepers-finished`. +- `aetheria-release:question:zhestokost-language-source`: whether Zhestokost follows the lore text, the faction sheet, + or both. Recommended: `sheet-palette-both-shapes`. -### Rationale +Neither blocks the first cut. -- **Why not Tripo or "deliberate low poly".** The ruling's test is "never mistaken for final art". Every element of - the grammar is chosen to fail the polish test on purpose: the grid, flat facets, kind colours, and no surface - detail. Each element also carries information (scale, mount kind, facing), so the look is utility. -- **Why the layout is generated.** - - Readability: the mounts the player sees agree with the schematic UI. - - Upkeep: a schematic edit re-renders the stand-in with no agent pass. - - Opus's weak spot: spatial placement is the failure mode the prior art documents most (LL3M §5; CAD - valid-but-wrong), and generation leaves only the body to judgment. +### Cut sequence - This is model geometry derived from cells, against `schematic-and-model-are-separate-spaces`, hence question - `standin-layout-follows-schematic`. -- **Why Blender for the target, not the probe's Python.** Package Ship already writes `ship.glb` and the anchors, - and runs validate. A second GLB writer would be a second authority over the same file. The cut imports OpenSCAD - part meshes into the bound collection with role tags, then calls Package. Brush-tree's `bake-only` and - `lowered-hull-through-package` point the same way. - -### Cut sequence (one line each; no spec admitted, because `standin-build` waits on both questions) - -1. `standin-build`. Add-on operator Build Stand-in (headless-capable): checks that `standin.scad` exists, generates - the layout from the bound `ship.cc`, runs `openscad.com` per part, imports the parts into the bound collection with - `aetheria.role`/`aetheria.id` tags, and calls Package Ship. It carries the vocabulary file and Longinus as its test - hull, gives the shipped Longinus Sensors hardpoint a mount id (SI-B4), and adds a `smoke_standin.py` beside - `smoke_package.py`. Verification: - - `ShipModPreview.Smoke -assemble` and `ShipModPlaySmoke.Run` on the package; - - the in-cells, mirror and in-footprint checks from SI-P1 (the last only under follow-schematic). - - Deploy owner: OpenSCAD in whatever image runs Blender smokes on Yggdrasil. Not established, because the Blender - smoke's host was not found. -2. `standin-package-retire`. Package Ship, on a package that still has `standin.scad`, deletes the file when the - collection is the operator's (model row above). It can fold into cut 1 if small. -3. `standin-role-hulls` (content). Stand-ins for the demo role hulls as their HullData land (follow-up - `demo-role-hulls`), and for the cast flyables until her art arrives. Each body is authored from the hull's - Concept Mill keeper or description (follow-up `hulls-through-concept-mill`). -4. After `ships-hull-material`: the stand-in mask, with primary channel = body, so faction liveries colour stand-ins. - A line in that spec's next revision, not a cut of its own. +- `agent-hull-source` (spec `aetheria-release:cut_spec:cut-agent-hull-source.r1`): the factions folder, + `hull_source.py`, Build From Hull Source, and Package deleting `hull.scad` when it packages operator art. The two + probe hulls become fixtures. Its first step measures AH-B3. +- `agent-hull-family-probe`: one more hull for each of the two factions (a Zhestokost gunboat, a Pirate tender on an + AU miner donor). It tests whether a language holds a family together, judged on a sheet beside the hero. Waits on + agent-hull-source. +- `agent-hulls-demo-roles`: hull sources for the demo's role hulls (follow-up `demo-role-hulls`) and the invented + Zhestokost boss hull (`demo-boss-hull-invented`), as packages through Package. Waits on agent-hull-family-probe, + `mods-in-tree` and `zhestokost-language-source`. +- `agent-hull-finish`: Bevel and Weighted Normal on agent hulls at Package, and zones into the livery mask. Waits on + `ships-hull-material`. ### Gaps -- Combat camera distance and angle were not read, so the zoom panel is a proxy. -- The greybox dev-texture convention is general practice, not evidence in the Eyes file. -- Where a current Blender runs headless for agents is not established on Starfire. The Package smoke's host was - not checked. -- Settings.SchematicCellSize = 2 m is cited from `hull_grid.py`, not read from `Settings.asset`. -- Scope reading, not a ruling: stand-ins are for hulls without operator art (demo role hulls; cast flyables until - hers land). Shipped legacy hulls keep their meshes. +- No Unity flight of either hull. +- No real combat camera. +- 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` 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 --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=/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/.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/.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. diff --git a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs index b50e0142d..10056b421 100644 --- a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs +++ b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs @@ -36,7 +36,7 @@ public sealed class CatalogRoleTests } // The whole of CB-R1 over every design in the catalog: each non-flat stat reads exactly one declared role, every - // declared role is read, and every seller authors each role once. Weapons, and designs of a kind the tool keeps a role + // declared role is read, and every seller authors each role once (a bin authors none: it reads its line's). Weapons, and designs of a kind the tool keeps a role // map for, must also carry a non-flat stat (a design with no stat at all has nothing for quality to vary). [Fact] public void ShippedCatalogRolesAreLive() @@ -59,13 +59,15 @@ public sealed class CatalogRoleTests broken.Add($"{design.Name}: declares [{string.Join(", ", declared)}] but non-flat stats read [{string.Join(", ", read)}]"); if (varying.Length == 0 && stats.Length > 0 && (design is WeaponItemData || Program.HasRoleMap(design))) broken.Add($"{design.Name}: every stat is flat"); - foreach (var product in products.Where(p => p.Design.Key.Equals(cache.RefOf(design).Key))) + foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(cache.RefOf(design).Key))) { var authored = (product.Roles ?? new List()).Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray(); if (!authored.SequenceEqual(declared)) broken.Add($"{design.Name}: product \"{product.Name}\" authors [{string.Join(", ", authored)}], not each of [{string.Join(", ", declared)}] once"); } } + foreach (var bin in products.Where(p => p.Bin != null)) + if (bin.Roles?.Count > 0 || bin.Assembly != null) broken.Add($"bin \"{bin.Name}\" authors roles or an assembly of its own"); Assert.True(broken.Count == 0, string.Join("\n", broken)); } @@ -264,7 +266,7 @@ public sealed class CatalogRoleTests var profiles = new Dictionary(Program.MakerProfiles) { ["Pirates"] = (.45f, .22f) }; foreach (var design in new[] { autocannon, lrm, srm, mine, drive }) { - var sellers = cache.GetAll().Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray(); + var sellers = cache.GetAll().Where(p => p.Bin == null && p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray(); Assert.NotEmpty(sellers); foreach (var product in sellers) { diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 6ad889fda..7478b3440 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -50,6 +50,7 @@ public sealed class ConsumableAiTests : IDisposable public GearData HeavyGun; public CultRecordRef Maker; public float Optimum; + public CultRecordKey[] Circuit; public void Store(ConsumableItemData design) => Assert.True(Hold.TryStore((ItemInstance) Items.CreateInstance(Items.CreateLot(design, Maker, .5f)))); @@ -97,9 +98,9 @@ public sealed class ConsumableAiTests : IDisposable } }; - private Scene Build(bool carryVent = false, bool carryOverdrive = false, bool carryDecoy = false, int stackableVents = 0, float effective = 500f) + private Scene Build(bool carryVent = false, bool carryOverdrive = false, bool carryDecoy = false, int stackableVents = 0, float effective = 500f, uint? runSeed = null, int orbits = 1) { - var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); + var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), Path.Combine(_root, Guid.NewGuid().ToString("N") + ".run.cc"), catalogWritable: true); _caches.Add(cache); cache.Upsert(new VerseGrammar { Revision = 1 }); cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" }); @@ -150,7 +151,10 @@ public sealed class ConsumableAiTests : IDisposable var maker = cache.RefOf(cache.GetByName("Maker")); EquippableItem Make(string name) where T : CraftedItemData => (EquippableItem) items.CreateInstance(items.CreateLot(cache.GetByName(name), maker, .5f)); - var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var galaxy = runSeed.HasValue ? RunSeedFixture.Continue(cache, "Test Zone", runSeed.Value) : null; + var zone = galaxy != null + ? new Zone(items, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy) + : new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); Ship NewShip(string gear) { var ship = new Ship(items, zone, Make("Skiff"), new EntitySettings()) @@ -174,8 +178,10 @@ public sealed class ConsumableAiTests : IDisposable // The agent joins as the zone admits a piloted ship (its Minion and CombatState come from production); the // locker is a gun ship nobody pilots. // One fixed orbit far from the fight, before the agent joins: a pilot that is not engaged patrols it, as production's would. - zone.Orbits.Add(new CultRecordKey("test-orbit"), new Orbit(new OrbitData { FixedPosition = float2(0, 5000) })); + for (var i = 0; i < orbits; i++) + zone.Orbits.Add(new CultRecordKey(orbits == 1 ? "test-orbit" : "test-orbit-" + i), new Orbit(new OrbitData { FixedPosition = float2(0, 5000) })); zone.Admit(scene.Agent, piloted: true); + scene.Circuit = ((PatrolOrbitsTask) zone.Agents.Single().Task).Circuit; zone.Admit(scene.Prey, piloted: false); zone.Admit(scene.Locker, piloted: false); // The flight is the only writer of the agent's Target: the agent senses the prey and holds a grudge on it, so the @@ -356,6 +362,25 @@ public sealed class ConsumableAiTests : IDisposable // The production path for the optimum: a ship the zone admits as piloted derives it from its gun's live stats // before it needs it, and re-derives it when its loadout changes. The gun's effective range is 500 and the // heavy gun's is 800, so the optimum moves with the weapons it has now. + // The run seed feeds drops and mines only (ruling run-seed-drops-only): the same pilot in the same zone samples its + // range with the same offset and draws the same patrol circuit at every run seed. Kills: the combat-sample dice or + // the patrol dice read DropSeed. + [Theory] + [InlineData(1u, 2u)] + [InlineData(0xC0FFEE42u, 0x2468ACE1u)] + public void ThePilotsDiceDoNotMoveWithTheRunSeed(uint first, uint second) + { + var a = Build(runSeed: first, orbits: 8); + var b = Build(runSeed: second, orbits: 8); + a.AssertInCombat(); + b.AssertInCombat(); + Assert.NotEqual(a.Zone.DropSeed, b.Zone.DropSeed); + Assert.Equal(a.Zone.CombatSeed, b.Zone.CombatSeed); + Assert.Equal(a.Optimum, b.Optimum); + Assert.Equal(4, a.Circuit.Length); + Assert.Equal(a.Circuit, b.Circuit); + } + [Fact] public void ASpawnedShipsOptimumFollowsItsWeaponsAndNeverReadsZero() { diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs index ae1895e75..54f46a57c 100644 --- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -51,6 +51,8 @@ public sealed partial class RunStartTests var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0], 0); RunSave.Commit(_cache, game, zones, _items.Lots); var restored = new Galaxy(_cache, _cache.GetGlobal(), _ => { }); + Assert.NotEqual(0u, galaxy.Seed); + Assert.Equal(galaxy.Seed, restored.Seed); Dictionary Standing(Galaxy g) => g.Factions.ToDictionary(faction => faction.Name, faction => g.FactionRelationships[faction]); Assert.Equal(new Dictionary diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 5673b205d..86bf4e698 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -622,6 +622,13 @@ public sealed class LoadoutTests : IDisposable var result = items.GetTier((CraftedItemInstance) items.CreateInstance(lot)); Assert.Equal(settings.Tiers[0].Name, result.tier.Name); Assert.Equal(2, result.upgrades); + + // The same width from the other side (follow-up tier-upgrade-step-two-sided): two steps less a hair is one upgrade, so a + // step narrower than .0499 reads two and a wider one reads none. + var under = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = settings.Tiers[0].Quality + 2 * .0499f - .0001f, Roles = new List() }); + var below = items.GetTier((CraftedItemInstance) items.CreateInstance(under)); + Assert.Equal(settings.Tiers[0].Name, below.tier.Name); + Assert.Equal(1, below.upgrades); } // S7 (docs/stats-and-power-cut.md Cut 1 findings): a Quality term must raise the lot's quality to its own diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs index f0b52f4ce..ecd65f8e1 100644 --- a/tests/Aetheria.Shared.Tests/LootTests.cs +++ b/tests/Aetheria.Shared.Tests/LootTests.cs @@ -61,7 +61,7 @@ public sealed class LootTests : IDisposable } private Lab Build(string zoneName = "Loot", float probability = .5f, float lifetime = 30f, float velocity = 25f, - bool well = false, float spread = 0f) + bool well = false, float spread = 0f, uint? runSeed = null) { var settings = new GameplaySettings { @@ -104,6 +104,7 @@ public sealed class LootTests : IDisposable cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = Solid(4, 4), Durability = 4 }); cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = Solid(1, 1), MaxStack = 100 }); cache.FlushAsync().Wait(); + var galaxy = runSeed.HasValue ? RunSeedFixture.Continue(cache, zoneName, runSeed.Value) : null; var ledger = new ProvenanceLedger(); for (var i = 1; i <= 9000; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; @@ -111,7 +112,9 @@ public sealed class LootTests : IDisposable var planets = new PlanetSettings(); var pack = new ZonePack { Radius = well ? 1000 : 0 }; if (well) WellFixture.Dig(cache, pack, planets, 300, 100); - var zone = new Zone(items, planets, pack, new GalaxyZone { Name = zoneName, Owner = null }, null); + var zone = galaxy != null + ? new Zone(items, planets, pack, galaxy.Zones[0], galaxy) + : new Zone(items, planets, pack, new GalaxyZone { Name = zoneName, Owner = null }, null); return new Lab { Items = items, Zone = zone }; } @@ -209,6 +212,61 @@ public sealed class LootTests : IDisposable Assert.True(Differ(dropsA, Released(first, count))); } + private List<(string Item, float2 Position, float2 Velocity)> DropsAt(string zoneName, uint runSeed) + { + var lab = Build(zoneName, runSeed: runSeed); + Kill(Spawn(lab, float3(31, 0, -17))); + return Released(lab, 0); + } + + // A new game rolls different drops from other games: the same ship, killed in the same zone, at another run seed. + [Fact] + public void ANewGameRollsDifferentDrops() + { + var one = DropsAt("Dice", 1); + Assert.True(one.Count > 2, "the fixture must drop enough for a draw to show"); + Assert.True(Differ(one, DropsAt("Dice", 2))); + } + + // Reloading one save repeats its drops, and the run's other zones still roll on their own names. + [Fact] + public void ReloadingASaveRepeatsItsDrops() + { + var first = DropsAt("Dice", 7); + Assert.True(first.Count > 2, "the fixture must drop enough for a draw to show"); + AssertSameDrops(first, DropsAt("Dice", 7)); + Assert.True(Differ(first, DropsAt("Other dice", 7))); + } + + // Pinned at eb27ced1, before run-seed: a zone with no galaxy, and a zone of a save written before run-seed (seed 0), + // roll on the zone's name alone and drop exactly what they dropped. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AZoneWithoutARunSeedKeepsItsDrops(bool restoredFromAnOldSave) + { + var drops = restoredFromAnOldSave ? DropsAt("Dice", 0) : Released(KillInNewLab("Dice"), 0); + Assert.Equal(new[] { "Spare", "Spare", "Spare", "Orex7", "Spare" }, drops.Select(d => d.Item)); + Assert.All(drops, d => Assert.Equal(new float2(31, -17), d.Position)); + var velocities = new[] + { + float2(-14.870228f, -20.096674f), float2(-4.9265895f, -24.509768f), float2(-17.977364f, 17.372805f), + float2(-5.908107f, 24.291857f), float2(-24.228783f, -6.1616626f) + }; + for (var i = 0; i < velocities.Length; i++) + { + Assert.Equal(velocities[i].x, drops[i].Velocity.x, 3); + Assert.Equal(velocities[i].y, drops[i].Velocity.y, 3); + } + } + + private Lab KillInNewLab(string zoneName) + { + var lab = Build(zoneName); + Kill(Spawn(lab, float3(31, 0, -17))); + return lab; + } + [Fact] public void GearDropsByProbabilityCargoAlways() { diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index a2deeb5cf..137d3f84a 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -67,7 +67,7 @@ public sealed class MineTests : IDisposable private Lab Build(float blast = 4f, float arming = 2f, float fuse = 2f, float life = 30f, float spread = 0f, float velocity = 0f, float damage = 100f, float range = 1000f, float cooldown = 1000f, Faction layerFaction = null, WeaponFuse? fuseKind = WeaponFuse.Proximity, bool well = false, float3? layerAt = null, int magazine = 100, - float minRange = 0f) + float minRange = 0f, uint? runSeed = null) { var settings = new GameplaySettings { @@ -114,6 +114,7 @@ public sealed class MineTests : IDisposable cache.Upsert(Gear("Shield", new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(1000000), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) }, 10)); cache.Upsert(Gear("TinyShield", new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(.001f), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) }, 10)); cache.FlushAsync().Wait(); + var galaxy = runSeed.HasValue ? RunSeedFixture.Continue(cache, "Mines", runSeed.Value) : null; var ledger = new ProvenanceLedger(); for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; @@ -121,7 +122,9 @@ public sealed class MineTests : IDisposable var planets = new PlanetSettings(); var pack = new ZonePack { Radius = well ? 1000 : 0 }; if (well) WellFixture.Dig(cache, pack, planets, 300, 100); - var zone = new Zone(items, planets, pack, new GalaxyZone { Name = "Mines", Owner = null }, null); + var zone = galaxy != null + ? new Zone(items, planets, pack, galaxy.Zones[0], galaxy) + : new Zone(items, planets, pack, new GalaxyZone { Name = "Mines", Owner = null }, null); var lab = new Lab { Items = items, Zone = zone, Data = data, ItemData = cache.GetByName("Mine Layer") }; var layerRef = items.ItemData.RefOf(items.ItemData.GetByName("Layer")); @@ -683,6 +686,35 @@ public sealed class MineTests : IDisposable Assert.False(FireControl.Refuses(lab.Weapon, lab.Layer)); } + // A new game scatters its mines differently: the same lay, at another run seed, launches at another velocity. + [Fact] + public void ANewGameScattersMinesDifferently() + { + Assert.NotEqual(LaunchAt(1), LaunchAt(2)); + } + + private float2 LaunchAt(uint? runSeed) + { + var lab = Build(spread: 40f, velocity: 5f, layerFaction: A, runSeed: runSeed); + var atLay = default(KinematicBody); + using var sub = lab.Zone.Mines.ObserveAdd().Subscribe(e => atLay = e.Value.Body); + lab.Layer.Direction = normalize(float2(1, 2)); + LayOne(lab); + return atLay.Velocity; + } + + // Pinned at eb27ced1, before run-seed: a zone with no galaxy, and a zone of a save written before run-seed (seed 0), + // scatter their mines on the zone's name alone. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AZoneWithoutARunSeedKeepsItsMineScatter(bool restoredFromAnOldSave) + { + var velocity = LaunchAt(restoredFromAnOldSave ? 0u : (uint?) null); + Assert.Equal(3.2546253f, velocity.x, 3); + Assert.Equal(3.795711f, velocity.y, 3); + } + [Fact] public void TheLaunchAngleIsTheMinesOwnDice() { @@ -697,9 +729,9 @@ public sealed class MineTests : IDisposable var mine = LayOne(lab); - // The angle is the first draw of (zone seed, mine stream, body id), in +-Spread/2, turning the mount + // The angle is the first draw of (the zone's DropSeed, mine stream, body id), in +-Spread/2, turning the mount // counterclockwise in the (x, y) plane. - var degreesTurned = SimulationDice.For(lab.Zone.CombatSeed, SimulationDice.MineStream, mine.Id.Value).NextFloat(-20f, 20f); + var degreesTurned = SimulationDice.For(lab.Zone.DropSeed, SimulationDice.MineStream, mine.Id.Value).NextFloat(-20f, 20f); var a = radians(degreesTurned); var m = FireControl.MountDirection(lab.WeaponItem).xz; var turned = float2(m.x * cos(a) - m.y * sin(a), m.x * sin(a) + m.y * cos(a)); diff --git a/tests/Aetheria.Shared.Tests/ProductBinTests.cs b/tests/Aetheria.Shared.Tests/ProductBinTests.cs new file mode 100644 index 000000000..c7b7de1fb --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ProductBinTests.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; +using Xunit; + +// A bin is a product of its maker's line above a floor (aetheria-release, cut product-tiers; ruling maker-design-tiers-allowed): +// the mint derives its distributions from the line on every lot, truncated below line mean + Floor deviations, and the lot is +// still the bin's. Lots here come from CreateLot(product), the production mint, on the AssemblyFromMakerTests fixture. +public sealed class ProductBinTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-bins-" + Guid.NewGuid().ToString("N")); + private readonly CultCache _cache; + private readonly ItemManager _items; + private readonly CultRecordRef _lens; + private readonly List _log = new List(); + + public ProductBinTests() + { + Directory.CreateDirectory(_root); + _cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true); + var gear = _cache.Upsert(new GearData + { + Name = "Lensed", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, + Roles = { new ItemRole { Name = "lens" } } + }); + _lens = new CultRecordRef(gear.Key); + _items = new ItemManager(_cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _log.Add) { Random = new CultMath.Random(7u) }; + } + + public void Dispose() + { + _cache.Dispose(); + Directory.Delete(_root, true); + } + + private static AssemblyDistribution A(float mean, float deviation) => new AssemblyDistribution { Mean = mean, StandardDeviation = deviation }; + + // A maker at assembly .6 +- .1 and a line of the Lensed design at lens .5 +- .1. + private FactionProductData Line(string name = "Line", float lensMean = .5f, float lensDeviation = .1f, AssemblyDistribution own = null, float makerMean = .6f) + { + var maker = _cache.Upsert(new Faction { Name = name + " Maker", Assembly = A(makerMean, .1f) }); + _cache.Upsert(new FactionProductData + { + Name = name, Design = _lens, Manufacturer = maker, Assembly = own, + Roles = { new ProductRole { Role = "lens", Mean = lensMean, StandardDeviation = lensDeviation } } + }); + return _cache.GetAll().Single(p => p.Name == name); + } + + private FactionProductData Bin(FactionProductData line, string name, float floor) + { + _cache.Upsert(new FactionProductData + { + Name = name, Design = line.Design, Manufacturer = line.Manufacturer, + Bin = new ProductBin { Line = _cache.RefOf(line), Floor = floor } + }); + return _cache.GetAll().Single(p => p.Name == name); + } + + private Lot[] Mint(FactionProductData product, int count) => + Enumerable.Range(0, count).Select(_ => _items.Lots[_items.CreateLot(product)]).ToArray(); + + private static float[] Fills(Lot[] lots) => lots.Select(l => l.Roles.Single().Quality).ToArray(); + + // Floor 0 is the top half of the line: nothing under the line's mean, in the fills or in the assembly. + [Fact] + public void BinnedFillsAndAssemblyNeverFallBelowTheFloor() + { + var lots = Mint(Bin(Line(), "Top Half", 0), 400); + Assert.All(lots, lot => + { + Assert.True(lot.Roles.Single().Quality >= .5f - 1e-5f, "a fill fell under the line's mean"); + Assert.True(lot.Quality >= .6f - 1e-5f, "an assembly fell under the maker's mean"); + }); + Assert.InRange(Fills(lots).Average(), .55f, .60f); + } + + // The floor is in the line's standard deviations: .67 on .5 +- .1 cuts at .567 and the truncated mean is .627. + [Fact] + public void FloorIsInDeviations() + { + var fills = Fills(Mint(Bin(Line(), "Top Quarter", .67f), 400)); + Assert.All(fills, fill => Assert.True(fill >= .567f - 1e-5f, "a fill fell under mean + floor deviations")); + Assert.InRange(fills.Average(), .61f, .645f); + } + + // A bin keeps no distribution of its own: after it exists, the line and the maker move and the bin follows. + [Fact] + public void BinDerivesFromItsLineNotACopy() + { + var line = Line(); + var bin = Bin(line, "Follower", 0); + Assert.Empty(bin.Roles); + Assert.Null(bin.Assembly); + line.Roles.Single().Mean = .7f; + var maker = _cache.Get(line.Manufacturer); + maker.Assembly = A(.8f, .1f); + _cache.Commit(batch => + { + batch.Upsert(typeof(FactionProductData), line, _cache.RefOf(line).Key); + batch.Upsert(typeof(Faction), maker, _cache.RefOf(maker).Key); + }); + var lots = Mint(bin, 300); + Assert.All(lots, lot => + { + Assert.True(lot.Roles.Single().Quality >= .7f - 1e-5f, "the bin read a copy of its line's fill"); + Assert.True(lot.Quality >= .8f - 1e-5f, "the bin read a copy of its maker's assembly"); + }); + } + + // A line's own assembly is the final test a bin of it sits: the maker's is read only when the line sets none. + [Fact] + public void BinReadsTheLinesOwnAssemblyBeforeTheMakers() + { + var bin = Bin(Line(own: A(.85f, .05f), makerMean: .4f), "Certified", 0); + Assert.All(Mint(bin, 300), lot => Assert.True(lot.Quality >= .85f - 1e-5f, "the bin assembled as its maker, not its line")); + } + + // 6 deviations above a .2 +- .01 line is out of reach in 64 draws: the mint returns the cutoff, .26, and does not loop. + [Fact] + public void UnreachableFloorMintsAtTheCutoff() + { + var bin = Bin(Line(lensMean: .2f, lensDeviation: .01f, own: A(.2f, .01f)), "Unreachable", 6); + Assert.All(Mint(bin, 20), lot => + { + Assert.Equal(.26f, lot.Roles.Single().Quality, 5); + Assert.Equal(.26f, lot.Quality, 5); + }); + } + + // The search draws at most 64 times, and stops at the first draw that clears the cutoff: the stream after it is what shows how + // many draws it took. An unreachable cutoff takes exactly 64, a deviation of 0 (draw equal to the cutoff) takes 1. + [Fact] + public void TheSearchDrawsAtMostSixtyFourTimesAndStopsAtTheFirstDrawThatClearsTheCutoff() + { + float AfterDraws(int draws) + { + var random = new CultMath.Random(11u); + for (var i = 0; i < draws; i++) random.NextGaussian(0, 1); + return random.NextGaussian(0, 1); + } + + var unreachable = new CultMath.Random(11u); + Assert.Equal(40f, unreachable.NextGaussianAbove(0, 1, 40f)); + Assert.Equal(AfterDraws(64), unreachable.NextGaussian(0, 1)); + + var immediate = new CultMath.Random(11u); + Assert.Equal(.5f, immediate.NextGaussianAbove(.5f, 0, 0)); + Assert.Equal(AfterDraws(1), immediate.NextGaussian(0, 1)); + } + + // Snapshot of the first five assemblies and fills at base, before the bin existed (seed 20261018, lens .5 +- .1, maker .6 +- .1). + [Fact] + public void UnbinnedMintConsumesTheStreamAsBefore() + { + var line = Line(); + _items.Random = new CultMath.Random(20261018u); + var lots = Mint(line, 5); + Assert.Empty(_log); + var assemblies = new[] { .59560823f, .68289334f, .57563853f, .5639112f, .59828424f }; + var fills = new[] { .6311789f, .5835483f, .47641385f, .46416125f, .45124212f }; + for (var i = 0; i < 5; i++) + { + Assert.Equal(assemblies[i], lots[i].Quality, 5); + Assert.Equal(fills[i], lots[i].Roles.Single().Quality, 5); + } + } + + // The lot is the bin's: its product, its title, and the line's maker as its brand. + [Fact] + public void ABinnedLotIsBrandedAsTheBin() + { + var line = Line(); + var bin = Bin(line, "Premiere", 0); + var lotId = _items.CreateLot(bin); + Assert.Equal(_cache.RefOf(bin), _items.Lots[lotId].Product); + var instance = _items.CreateInstance(lotId); + Assert.Equal("Premiere", _items.Title(instance)); + var (maker, product) = _items.Brand(instance); + Assert.Equal(_cache.Get(line.Manufacturer).Name, maker.Name); + Assert.Equal("Premiere", product.Name); + } + + // The line a bin draws from is the one Bin.Line names, not another line of the design: three lines of one design, by three + // makers, with the bin on the middle one by name, so a bin that reads the first or the last line of the design is far off. + [Fact] + public void ABinMintsFromExactlyTheLineItNames() + { + Line("A line", lensMean: .3f, makerMean: .2f); + var middle = Line("M line", lensMean: .6f, makerMean: .5f); + Line("Z line", lensMean: .9f, makerMean: .9f); + var lots = Mint(Bin(middle, "Middle Premiere", 0), 400); + Assert.All(lots, lot => + { + Assert.True(lot.Roles.Single().Quality >= .6f - 1e-5f, "a fill came from a line below the named one"); + Assert.True(lot.Quality >= .5f - 1e-5f, "an assembly came from a maker below the named line's"); + }); + Assert.InRange(Fills(lots).Average(), .64f, .72f); + Assert.InRange(lots.Average(lot => lot.Quality), .55f, .62f); + } + + private const string Canary = "canary-7f3a91"; + + private enum Damage { None, LineRemoved, LineIsBin } + + // A catalog on disk holding one line and a bin of it, damaged as asked, for AetheriaStores.Open to read. + private string DamagedCatalog(float floor, Damage damage) + { + var path = Path.Combine(_root, Guid.NewGuid().ToString("N"), "Aetheria.cc"); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + using var seed = AetheriaStores.Open(path, catalogWritable: true); + seed.Upsert(new VerseGrammar { Revision = 1 }); + var maker = seed.Upsert(new Faction { Name = Canary + " Maker" }); + var gear = seed.Upsert(new GearData { Name = "Lensed", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, Roles = { new ItemRole { Name = "lens" } } }); + var design = new CultRecordRef(gear.Key); + var line = seed.Upsert(new FactionProductData { Name = Canary + " Line", Design = design, Manufacturer = maker, Roles = { new ProductRole { Role = "lens" } } }); + var bin = seed.Upsert(new FactionProductData { Name = Canary, Design = design, Manufacturer = maker, Bin = new ProductBin { Line = line, Floor = floor } }); + if (damage == Damage.LineIsBin) + seed.Upsert(new FactionProductData { Name = Canary + " Nested", Design = design, Manufacturer = maker, Bin = new ProductBin { Line = bin, Floor = 0 } }); + if (damage == Damage.LineRemoved) seed.Remove(line.Key); + seed.FlushAsync().Wait(); + return path; + } + + // The runtime does not trust authoring for a bin: a NaN floor would weigh NaN and null every draw of its design. + [Theory] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void ACatalogWhoseBinFloorIsNotFiniteIsRefusedWhereItIsRead(float floor) + { + var refusal = Assert.Throws(() => AetheriaStores.Open(DamagedCatalog(floor, Damage.None))); + Assert.Contains("Floor", refusal.Message); + Assert.DoesNotContain(Canary, refusal.Message); + Assert.DoesNotContain(floor.ToString(), refusal.Message); + } + + [Fact] + public void ACatalogWhoseBinNamesNoLineIsRefusedWhereItIsRead() + { + var refusal = Assert.Throws(() => AetheriaStores.Open(DamagedCatalog(.67f, Damage.LineRemoved))); + Assert.Contains("Line", refusal.Message); + Assert.DoesNotContain(Canary, refusal.Message); + } + + [Fact] + public void ACatalogWhoseBinNamesAnotherBinIsRefusedWhereItIsRead() + { + var refusal = Assert.Throws(() => AetheriaStores.Open(DamagedCatalog(.67f, Damage.LineIsBin))); + Assert.Contains("Line", refusal.Message); + Assert.DoesNotContain(Canary, refusal.Message); + } + + // The refusals are of damage, not of bins: a sound bin on a sound line reads. + [Fact] + public void ACatalogWithASoundBinIsRead() + { + using var cache = AetheriaStores.Open(DamagedCatalog(.67f, Damage.None)); + Assert.Equal(.67f, cache.GetAll().Single(product => product.Name == Canary).Bin.Floor); + } + + // A bin whose line the catalog does not hold mints as a product with no roles of its own, and says so. + [Fact] + public void ABinOfALineTheCatalogLacksMintsAtDefaultsAndLogs() + { + var line = Line(); + var bin = Bin(line, "Orphan", 0); + _cache.Remove(_cache.RefOf(line).Key); + var lots = Mint(bin, 5); + Assert.Equal(5, lots.Length); + Assert.Equal(5, _log.Count); + Assert.All(_log, message => Assert.False(string.IsNullOrWhiteSpace(message))); + } +} diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index 888634f8e..89f262c62 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -63,7 +63,10 @@ public sealed class ProductChoiceTests : IDisposable } } - private Rig Build(params string[] lampOrder) + private Rig Build(params string[] lampOrder) => Build(new float[0], lampOrder); + + // binFloors adds one bin of lamp-a's line per floor, named "lamp-a bin" and "lamp-a bin 2"..., by the same maker. + private Rig Build(float[] binFloors, string[] lampOrder) { var root = Path.Combine(_root, "rig" + _catalogs++); Directory.CreateDirectory(root); @@ -97,6 +100,12 @@ public sealed class ProductChoiceTests : IDisposable // lampOrder holds product names; the record key of the i-th inserted is "k" + i, so keys and insertion order // both follow the permutation. for (var i = 0; i < lampOrder.Length; i++) Sell(lampOrder[i], lamp.Key, makers[lampOrder[i]], "k" + i); + for (var i = 0; i < binFloors.Length; i++) + seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData + { + Name = i == 0 ? "lamp-a bin" : "lamp-a bin " + (i + 1), Design = new CultRecordRef(lamp.Key), Manufacturer = alpha, + Bin = new ProductBin { Line = new CultRecordRef(new CultRecordKey("k" + Array.IndexOf(lampOrder, "lamp-a"))), Floor = binFloors[i] } + }, new CultRecordKey("bin" + i))); seed.FlushAsync().Wait(); } @@ -249,6 +258,90 @@ public sealed class ProductChoiceTests : IDisposable Assert.InRange(MakerDraws(rig, rig.OutEven, 1, "lamp-a", 300), 80, 120); } + // Ruling bin-share-by-floor: a bin of lamp-a's line takes the fraction of lamp-a's stock that would pass its floor, 1 - Phi(Floor), + // of lamp-a's preference, and the line keeps the rest, so the maker's presence for the design is what it was without the bin. + [Fact] + public void ABinTakesItsPassFractionOfItsMakersPreference() + { + var alone = Build("lamp-z", "lamp-a"); + var preference = alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-a")); + Assert.True(preference > 0); + + var quarter = Build(new[] { .67f }, new[] { "lamp-z", "lamp-a" }); + var generator = quarter.Outfitter(quarter.OutEven, 1, 0); + Assert.Equal(.2514f * preference, generator.ProductWeight(quarter.Product("lamp-a bin")), 3); + Assert.Equal(.7486f * preference, generator.ProductWeight(quarter.Product("lamp-a")), 3); + Assert.Equal(preference, generator.ProductWeight(quarter.Product("lamp-a bin")) + generator.ProductWeight(quarter.Product("lamp-a")), 3); + // Another maker's product, whose line has no bin, weighs what it did without any bin in the catalog. + Assert.Equal(alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-z")), generator.ProductWeight(quarter.Product("lamp-z")), 3); + + var half = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" }); + generator = half.Outfitter(half.OutEven, 1, 0); + Assert.Equal(.5f * preference, generator.ProductWeight(half.Product("lamp-a bin")), 3); + Assert.Equal(.5f * preference, generator.ProductWeight(half.Product("lamp-a")), 3); + + // The maker keeps its presence: Alpha's line and bin together draw as lamp-a alone did. + var lamp = quarter.Cache.GetByName("Lamp"); + var alpha = Enumerable.Range(1, 300).Count(i => quarter.Cache.Get(quarter.Outfitter(quarter.OutEven, Seed(i), 0).ChooseProduct(lamp).Manufacturer).Name == "Alpha"); + Assert.InRange(alpha, 180, 220); + } + + // A line gives up the sum of its bins' pass fractions and no more: two top-half bins leave it nothing, and a third does not + // take it below nothing. + [Fact] + public void ALineKeepsWhatItsBinsLeaveAndNeverLessThanNothing() + { + var two = Build(new[] { 0f, .67f }, new[] { "lamp-z", "lamp-a" }); + var alone = Build("lamp-z", "lamp-a"); + var preference = alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-a")); + Assert.Equal((1 - .5f - .2514f) * preference, two.Outfitter(two.OutEven, 1, 0).ProductWeight(two.Product("lamp-a")), 3); + + var three = Build(new[] { 0f, 0f, 0f }, new[] { "lamp-z", "lamp-a" }); + Assert.Equal(0f, three.Outfitter(three.OutEven, 1, 0).ProductWeight(three.Product("lamp-a"))); + } + + // Ruling bin-share-by-floor holds on every draw of a design, not only the preference-weighted one. When every reachable maker + // weighs 0 the ChooseProduct draw is by share alone, and Alpha's line and bin together keep the half they held as one product. + [Fact] + public void ABinLeavesItsMakersShareOfTheAllZeroWeightDrawUnchanged() + { + int AlphaDraws(Rig rig) + { + var lamp = rig.Cache.GetByName("Lamp"); + var zero = rig.Cache.GetByName("OutZero"); + return Enumerable.Range(1, 1000).Count(i => rig.Cache.Get(rig.Outfitter(zero, Seed(i), 0).ChooseProduct(lamp).Manufacturer).Name == "Alpha"); + } + + Assert.InRange(AlphaDraws(Build("lamp-z", "lamp-a")), 440, 560); + Assert.InRange(AlphaDraws(Build(new[] { .67f }, new[] { "lamp-z", "lamp-a" })), 440, 560); + Assert.InRange(AlphaDraws(Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" })), 440, 560); + } + + // The same for a required item that has fallen back to any manufacturer (OutNone is allied to nobody, so no product is on offer + // and the fallback weighs every product alike, but for the bins' shares). + [Fact] + public void ABinLeavesItsMakersShareOfTheRequiredFallbackDrawUnchanged() + { + int AlphaDraws(Rig rig) + { + var log = new List(); + var items = new ItemManager(rig.Cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), log.Add); + var lamp = rig.Cache.GetByName("Lamp"); + var alpha = Enumerable.Range(1, 1000).Count(i => + { + var random = new Random(Seed(i)); + var generator = new LoadoutGenerator(ref random, items, rig.Galaxy, null, rig.OutNone, .5f); + return rig.Cache.Get(generator.RandomProduct(0, design => design == lamp, required: true).product.Manufacturer).Name == "Alpha"; + }); + Assert.NotEmpty(log); + return alpha; + } + + Assert.InRange(AlphaDraws(Build("lamp-z", "lamp-a")), 440, 560); + Assert.InRange(AlphaDraws(Build(new[] { .67f }, new[] { "lamp-z", "lamp-a" })), 440, 560); + Assert.InRange(AlphaDraws(Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" })), 440, 560); + } + // A weight that read the allegiance alone would give 1 : .25 here, an 80% draw; the zone and allegiance together // give 1 : .125 for alpha at its own zone, 89%. [Fact] diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs index 6a91bb457..7ce78218f 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; using Xunit; // The weapon product lines the shipped catalog holds (campaign aetheria-release, cut product-lines-weapons; rulings @@ -99,4 +101,61 @@ public sealed class ProductLinesCatalogTests } } } + // Cut product-tiers: the six bins of the command's tier table are in the catalog, each a single product that names its line. + [Fact] + public void TierRowsAreShipped() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var table = ProductLinesCommand.TierTable(); + Assert.Equal(6, table.Count); + foreach (var row in table) + { + var bin = Assert.Single(cache.GetAll(), p => p.Name == row.Name); + var line = Assert.Single(cache.GetAll(), p => p.Name == row.Line); + Assert.Equal(cache.RefOf(line).Key, bin.Bin.Line.Key); + Assert.Equal(line.Design.Key, bin.Design.Key); + Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key); + Assert.Equal(row.Floor, bin.Bin.Floor); + Assert.Equal(row.Description, bin.Description); + Assert.Empty(bin.Roles); + Assert.Null(bin.Assembly); + } + } + + // A bin bins an unbinned line of its own design and maker, and the catalog holds no bin the table does not author. + [Fact] + public void BinsBinAnUnbinnedLineOfTheirOwnDesignAndMaker() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var bins = cache.GetAll().Where(p => p.Bin != null).ToArray(); + Assert.Equal(ProductLinesCommand.TierTable().Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal), bins.Select(b => b.Name).OrderBy(n => n, StringComparer.Ordinal)); + foreach (var bin in bins) + { + var line = cache.Get(bin.Bin.Line); + Assert.NotNull(line); + Assert.Null(line.Bin); + Assert.Equal(line.Design.Key, bin.Design.Key); + Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key); + Assert.True(bin.Bin.Floor >= 0 && float.IsFinite(bin.Bin.Floor)); + } + } + + // FactionProductData slot 7 is additive under the same schema id: a bin round-trips, and a record written before the slot + // existed (an array one element shorter) reads no bin. + [Fact] + public void BinIsSlotSevenAndACatalogWithoutItReadsNoBin() + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(FactionProductData).Assembly); + var binned = MessagePackSerializer.Deserialize( + MessagePackSerializer.Serialize(new FactionProductData { Name = "Binned", Bin = new ProductBin { Floor = .67f } }, options), options); + Assert.Equal(.67f, binned.Bin.Floor); + + var bytes = MessagePackSerializer.Serialize(new FactionProductData { Name = "Old", Description = "d" }, options); + Assert.Equal(0x98, bytes[0]); + Assert.Equal(0xC0, bytes[^1]); + var before = new byte[] { 0x97 }.Concat(bytes.Skip(1).Take(bytes.Length - 2)).ToArray(); + var old = MessagePackSerializer.Deserialize(before, options); + Assert.Equal("Old", old.Name); + Assert.Null(old.Bin); + } } diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs index 53b8fe773..fd8e02aa1 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs @@ -42,7 +42,9 @@ public abstract class ProductLinesCommandTests : IDisposable Assert.Equal(Names.Length, products.Length); _shipped = products.Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); _shippedKeys = products.Select(p => cache.RefOf(p).Key.Value).OrderBy(x => x, StringComparer.Ordinal).ToArray(); - foreach (var product in products) Assert.True(cache.Remove(cache.RefOf(product).Key)); + // A bin cannot outlive its line (the catalog read refuses it), so the bins of the removed lines go with them. + var binned = cache.GetAll().Where(p => p.Bin != null && products.Any(line => cache.RefOf(line).Key.Equals(p.Bin.Line.Key))).ToArray(); + foreach (var product in products.Concat(binned)) Assert.True(cache.Remove(cache.RefOf(product).Key)); cache.FlushAsync().Wait(); } diff --git a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs new file mode 100644 index 000000000..2899737e0 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// AetherDb's product-lines tiers command (cut product-tiers; rulings maker-design-tiers-allowed, bin-share-by-floor), run on a +// scratch copy of the shipped catalog with the table's bins removed from it. A standalone class: a tier row is not a Row. +public sealed class ProductTiersCommandTests : IDisposable +{ + private static readonly string[] Apply = { "tiers", "apply" }; + private static readonly string[] DryRun = { "tiers" }; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "product-tiers-command-" + Guid.NewGuid().ToString("N")); + private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + private readonly string[] _names = ProductLinesCommand.TierTable().Select(r => r.Name).ToArray(); + + public ProductTiersCommandTests() + { + Directory.CreateDirectory(Path.Combine(_root, "GameData")); + Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources")); + var shipped = AetherDb.FindRoot(); + File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath); + File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset")); + using var cache = Open(); + foreach (var bin in cache.GetAll().Where(p => _names.Contains(p.Name)).ToArray()) Assert.True(cache.Remove(cache.RefOf(bin).Key)); + cache.FlushAsync().Wait(); + } + + public void Dispose() => Directory.Delete(_root, true); + + private CultCache Open() + { + var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes); + return cache; + } + + private Dictionary Records() => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)).Records.ToDictionary(r => r.Key, StringComparer.Ordinal); + + private FactionProductData[] Bins() + { + using var cache = Open(); + return cache.GetAll().Where(p => _names.Contains(p.Name)).ToArray(); + } + + // Edits the scratch catalog before a refusal case; the bins the table authors stay out of it. + private void Edit(Action edit) + { + using var cache = Open(); + edit(cache); + cache.FlushAsync().Wait(); + } + + // The command refuses the row with a message naming the rule, and the catalog's bytes are as they were. + private void Refused(ProductLinesCommand.TierRow row, string phrase) + { + var before = File.ReadAllBytes(CatalogPath); + var error = Assert.Throws(() => ProductLinesCommand.RunTiers(new[] { row }, true, _root)); + Assert.Contains(phrase, error.Message); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + } + + [Fact] + public void A_dry_run_writes_nothing_and_apply_lands_the_table_once_and_replays_as_a_no_op() + { + var before = File.ReadAllBytes(CatalogPath); + Assert.Empty(Bins()); + + Assert.Equal(0, ProductLinesCommand.Run(DryRun, _root)); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + var landed = File.ReadAllBytes(CatalogPath); + Assert.NotEqual(before, landed); + Assert.Equal(6, Bins().Length); + + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + Assert.Equal(0, ProductLinesCommand.Run(DryRun, _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + } + + [Fact] + public void Each_bin_is_its_lines_design_and_maker_at_its_floor_with_nothing_of_its_own() + { + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + using var cache = Open(); + foreach (var row in ProductLinesCommand.TierTable()) + { + var bin = Assert.Single(cache.GetAll(), p => p.Name == row.Name); + var line = Assert.Single(cache.GetAll(), p => p.Name == row.Line); + Assert.Equal(cache.RefOf(line).Key, bin.Bin.Line.Key); + Assert.Equal(line.Design.Key, bin.Design.Key); + Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key); + Assert.Equal(row.Floor, bin.Bin.Floor); + Assert.Equal(row.Description, bin.Description); + Assert.Empty(bin.Roles); + Assert.Null(bin.Assembly); + Assert.Null(line.Bin); + } + } + + [Fact] + public void Apply_writes_only_the_records_it_authors() + { + var before = Records(); + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + var after = Records(); + Assert.Equal(before.Count + 6, after.Count); + foreach (var (key, record) in before) + { + Assert.True(after.TryGetValue(key, out var kept), "a record the command did not author is gone"); + Assert.Equal(record.StoredAt, kept.StoredAt); + Assert.True(record.Payload.AsSpan().SequenceEqual(kept.Payload), "a record the command did not author has changed bytes"); + } + } + + [Fact] + public void A_bin_already_authored_at_another_floor_is_refused() + { + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + Refused(new ProductLinesCommand.TierRow("Sparrow", "Goldcrest", .5f, "x"), "already belongs"); + } + + [Fact] + public void A_bin_already_authored_on_another_line_is_refused() + { + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + Refused(new ProductLinesCommand.TierRow("Spotlight", "Goldcrest", .67f, "x"), "already belongs"); + } + + [Fact] + public void A_line_the_catalog_does_not_hold_is_refused() + { + Refused(new ProductLinesCommand.TierRow("No Such Line", "Fresh Bin", 0f, "x"), "found 0"); + } + + [Fact] + public void An_ambiguous_line_is_refused() + { + Edit(cache => + { + var sparrow = cache.GetAll().Single(p => p.Name == "Sparrow"); + cache.Upsert(new FactionProductData { Name = "Sparrow", Design = sparrow.Design, Manufacturer = sparrow.Manufacturer }); + }); + Refused(new ProductLinesCommand.TierRow("Sparrow", "Fresh Bin", 0f, "x"), "found 2"); + } + + [Fact] + public void A_line_that_is_itself_a_bin_is_refused() + { + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + Refused(new ProductLinesCommand.TierRow("Goldcrest", "Fresh Bin", 0f, "x"), "itself a bin"); + } + + [Theory] + [InlineData(-.1f)] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + public void A_floor_that_is_negative_or_not_finite_is_refused(float floor) + { + Refused(new ProductLinesCommand.TierRow("Sparrow", "Fresh Bin", floor, "x"), "finite number"); + } + + [Theory] + [InlineData("Spotlight")] + [InlineData("SPOTLIGHT")] + public void A_bin_name_held_by_another_product_is_refused_ignoring_case(string name) + { + Refused(new ProductLinesCommand.TierRow("Sparrow", name, 0f, "x"), "already belongs"); + } + + [Fact] + public void A_bin_name_that_is_a_designs_is_refused() + { + string design; + using (var cache = Open()) design = cache.GetAll().First().Name; + Refused(new ProductLinesCommand.TierRow("Sparrow", design.ToUpperInvariant(), 0f, "x"), "name of a design"); + } +} diff --git a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs index 1f276c639..58c823af4 100644 --- a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs @@ -207,6 +207,110 @@ public sealed class RolesBackfillCommandTests : IDisposable finally { db.Cache.Dispose(); } } + // A bin authors no roles (ProductBin): the backfill that restores its line's missing roles leaves the bin's list empty. + [Fact] + public void A_binned_seller_gains_no_roles() + { + Reset(flattened: false, unnamed: false); + var db = AetherDb.Open(catalogWritable: true, root: _root); + var autocannon = db.Cache.GetAll().Single(weapon => weapon.Name == "Autocannon"); + var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Design.Key.Equals(db.Cache.RefOf(autocannon).Key) && p.Roles.Count == 2); + var sellerName = seller.Name; + seller.Roles = new List { seller.Roles.OrderBy(r => r.Role, StringComparer.Ordinal).First() }; + var bin = new FactionProductData + { + Name = "Binned " + sellerName, Design = seller.Design, Manufacturer = seller.Manufacturer, + Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f } + }; + db.Cache.Commit(batch => + { + batch.Upsert(typeof(FactionProductData), seller, db.Cache.RefOf(seller).Key); + batch.Upsert(typeof(FactionProductData), bin); + }); + db.Cache.Dispose(); + + Assert.Equal(0, Program.RolesBackfill(apply: true, root: _root)); + db = AetherDb.Open(root: _root); + try + { + Assert.Equal(2, db.Cache.GetAll().Single(p => p.Name == sellerName).Roles.Count); + Assert.Empty(db.Cache.GetAll().Single(p => p.Name == "Binned " + sellerName).Roles); + } + finally { db.Cache.Dispose(); } + } + + // The census reports a seller that authors no spread for a role of its design as a gap, and never a bin: a bin reads its line's. + [Fact] + public void The_census_reports_a_roleless_seller_and_never_a_bin() + { + var db = AetherDb.Open(catalogWritable: true, root: _root); + var autocannon = db.Cache.GetAll().Single(weapon => weapon.Name == "Autocannon"); + var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Design.Key.Equals(db.Cache.RefOf(autocannon).Key)); + var sellerName = seller.Name; + seller.Roles = new List(); + db.Cache.Commit(batch => + { + batch.Upsert(typeof(FactionProductData), seller, db.Cache.RefOf(seller).Key); + batch.Upsert(typeof(FactionProductData), new FactionProductData + { + Name = "Binned " + sellerName, Design = seller.Design, Manufacturer = seller.Manufacturer, + Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f } + }); + }); + db.Cache.Dispose(); + + db = AetherDb.Open(root: _root); + try + { + var gaps = Program.RoleGaps(db.Cache, db.Cache.GetAll().ToArray(), db.Cache.GetAll().ToArray()).ToArray(); + Assert.Contains(gaps, gap => gap.Product.Name == sellerName && gap.Missing.Length == 2); + Assert.DoesNotContain(gaps, gap => gap.Product.Name == "Binned " + sellerName); + } + finally { db.Cache.Dispose(); } + } + + // roles-migrate authors a role on every product that sells a design lacking one; a bin sells none of its own. + [Fact] + public void A_binned_seller_gains_no_roles_from_roles_migrate() + { + var db = AetherDb.Open(catalogWritable: true, root: _root); + var autocannon = db.Cache.GetAll().Single(weapon => weapon.Name == "Autocannon"); + var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Design.Key.Equals(db.Cache.RefOf(autocannon).Key)); + var bin = new FactionProductData + { + Name = "Binned " + seller.Name, Design = seller.Design, Manufacturer = seller.Manufacturer, + Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f } + }; + db.Cache.Commit(batch => batch.Upsert(typeof(FactionProductData), bin)); + db.Cache.Dispose(); + + Assert.Equal(0, Program.RolesMigrate(apply: true, root: _root)); + db = AetherDb.Open(root: _root); + try { Assert.Empty(db.Cache.GetAll().Single(p => p.Name == "Binned " + seller.Name).Roles); } + finally { db.Cache.Dispose(); } + } + + // sensor-stat-set authors an emission-discipline role on every product of a sensor design; a bin of one authors none. + [Fact] + public void A_binned_seller_gains_no_roles_from_sensor_stat_set() + { + var db = AetherDb.Open(catalogWritable: true, root: _root); + var sensor = db.Cache.GetAll().Where(d => d.Behaviors != null && d.Behaviors.OfType().Any()).OrderBy(d => d.Name, StringComparer.Ordinal).First(); + var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Bin == null && p.Design.Key.Equals(db.Cache.RefOf(sensor).Key)); + var bin = new FactionProductData + { + Name = "Binned " + seller.Name, Design = seller.Design, Manufacturer = seller.Manufacturer, + Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f } + }; + db.Cache.Commit(batch => batch.Upsert(typeof(FactionProductData), bin)); + db.Cache.Dispose(); + + Assert.Equal(0, Program.SensorStatSet(apply: true, root: _root)); + db = AetherDb.Open(root: _root); + try { Assert.Empty(db.Cache.GetAll().Single(p => p.Name == "Binned " + seller.Name).Roles); } + finally { db.Cache.Dispose(); } + } + [Fact] public void A_design_the_catalog_does_not_hold_refuses_and_lands_nothing() { diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 3bd075724..456561d76 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -170,6 +170,50 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(4242, cache.GetGlobal().Credits); } + // The run's seed rides the save: Continue restores it, Capture writes it back, and a save from before run-seed + // restores zero. + [Fact] + public void TheRunSeedRidesTheSave() + { + using (var cache = Open()) + { + var game = Game(cache); + game.Seed = 0xC0FFEE42u; + RunSave.Commit(cache, game, Zones(2, StageZoneContents(cache), 0), new ProvenanceLedger()); + } + + using (var cache = Open()) + { + var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + Assert.Equal(0xC0FFEE42u, galaxy.Seed); + var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); + // The zone's drop dice are its name's seed mixed with the run's. + Assert.Equal("Zone 0".StableHash() ^ SimulationDice.Mix(0xC0FFEE42u), galaxy.Zones[0].Contents.DropSeed); + var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0], 0); + Assert.Equal(0xC0FFEE42u, game.Seed); + RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); + } + + using (var cache = Open()) + Assert.Equal(0xC0FFEE42u, cache.GetGlobal().Seed); + + // A save written before run-seed holds no seed, and continues on name-only drops. + using (var cache = Open()) + RunSave.Commit(cache, Game(cache), Zones(2, StageZoneContents(cache), 0), new ProvenanceLedger()); + + using (var cache = Open()) + { + Assert.Equal(0u, cache.GetGlobal().Seed); + var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + Assert.Equal(0u, galaxy.Seed); + var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var zone = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); + Assert.Equal("Zone 0".StableHash(), zone.CombatSeed); + Assert.Equal(zone.CombatSeed, zone.DropSeed); + } + } + [Fact] public void ClearRemovesEveryRunRecord() { diff --git a/tests/Aetheria.Shared.Tests/RunSeedFixture.cs b/tests/Aetheria.Shared.Tests/RunSeedFixture.cs new file mode 100644 index 000000000..0152584b7 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunSeedFixture.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using GameCult.Caching; + +// A fixture's run at a fixed seed, restored as Continue restores it: one zone, committed through RunSave.Commit and +// read back by the Galaxy save constructor, so a test's galaxy carries the seed the way a real run's does. +internal static class RunSeedFixture +{ + public static Galaxy Continue(CultCache cache, string zoneName, uint seed) + { + var game = new SavedGame + { + Seed = seed, + Factions = new CultRecordRef[0], + Relationships = new FactionRelationship[0], + HomeZones = new Dictionary(), + BossZones = new Dictionary(), + DiscoveredZones = new[] { 0 }, + ActionBarBindings = new SavedActionBarBinding[0], + Entrance = 0, + Exit = -1 + }; + var zone = new SavedZone { Name = zoneName, AdjacentZones = new int[0], Factions = new int[0], Owner = -1 }; + RunSave.Commit(cache, game, new[] { zone }, new ProvenanceLedger()); + return new Galaxy(cache, cache.GetGlobal(), _ => { }); + } +} diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index efedc20dd..4b181f67e 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -115,6 +115,28 @@ public sealed partial class RunStartTests : IDisposable private const float Freezing = 273.15f; + // One minter: a generated galaxy never reads the clock, so seed 0 is refused by both generation constructors, naming + // the field and echoing no input (no ActualValue). The settings are the authored ones: a galaxy that stops refusing must be able to + // finish generating, so the test fails by assertion rather than wedging in the generator. + [Fact] + public void AGeneratedGalaxyRefusesSeedZero() + { + var authored = AuthoredSettings.Load(FindRepoRoot()); + var background = authored.Read("SectorBackgroundSettings"); + var sector = authored.Read("SectorGenerationSettings"); + sector.ZoneCount = 8; + var tutorial = authored.Read("TutorialGenerationSettings"); + + var main = Assert.Throws(() => new Galaxy(sector, background, _cache, _ => { }, null, 0)); + var prelude = Assert.Throws(() => + new Galaxy(tutorial, background, _cache, new PlayerSettings(), new DirectoryInfo(_root), _ => { }, null, 0)); + foreach (var refusal in new[] { main, prelude }) + { + Assert.Equal("seed", refusal.ParamName); + Assert.Null(refusal.ActualValue); + } + } + // A main-sector galaxy: each station's gear must come from manufacturers its faction can reach, the makers its // allegiance names, present in the galaxy or not. private Galaxy MainSectorGalaxy() diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs index 911f01ffb..2c82d5ebf 100644 --- a/tools/AetherDb/ProductLinesCommand.cs +++ b/tools/AetherDb/ProductLinesCommand.cs @@ -16,9 +16,13 @@ using GameCult.Caching; // carries no quality numbers. A row already in the catalog (same name, design and maker) is skipped, so the same command is the // first apply, the second apply and the replay onto a merged catalog. A maker that already sells the design is not refused: a // tier is allowed. Dry run unless passed "apply". +// product-lines tiers [apply] (cut product-tiers; rulings maker-design-tiers-allowed, bin-share-by-floor): authors the bins of +// those lines. A tier row is (line product name, bin name, floor, text); the bin gets Design and Manufacturer from its line, +// Bin = { Line, Floor }, and no Roles or Assembly of its own: the mint derives both from the line. public static class ProductLinesCommand { internal sealed record Row(string Design, string Name, string Maker, string Description); + internal sealed record TierRow(string Line, string Name, float Floor, string Description); private static readonly Row[] Weapons = { @@ -85,6 +89,19 @@ public static class ProductLinesCommand new("Industrial Thermostatic Heater", "WarmUp+-", "NiteLife", "Room temperature, whatever the room."), }; + // The bins of the shipped lines. The names and text are drafts the operator may rename. + private static readonly TierRow[] Tiers = + { + new("Sparrow", "Goldcrest", .67f, "Only the Sparrows that sang on the test bench. The rest still fly."), + new("Spotlight", "Spotlight Premiere", 0f, "Hand-picked for opening night."), + new("Rattler", "Rattler Select", 0f, "Pick of the batch. Still loud."), + new("SM-6 Marksman", "SM-6C Marksman Certified", 0f, "Individually certified to published tolerance.*"), + new("PowerHour 2.0", "PowerHour 2.0 VIP", .67f, "Skip the line. Same party, better parts."), + new("Juggernaut", "Juggernaut Guards", 0f, "The column's best drives go to the front of the column."), + }; + + internal static IReadOnlyList TierTable() => Tiers; + // Every line the command knows, by the word that follows "product-lines". private static readonly Dictionary Lines = new() { ["weapons"] = Weapons, ["minor-powers"] = MinorPowers, ["gear"] = Gear }; @@ -93,16 +110,19 @@ public static class ProductLinesCommand // A product's record key is derived from its name, so the same table lands the same records every time it is applied: the // replay at a merge reproduces the branch's blob, and nothing that orders products by key reads a different order after it. - private static CultRecordKey KeyOf(Row row) => - new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("product-line:" + row.Name)).AsSpan(0, 16)).ToString("N")); + private static CultRecordKey KeyOf(Row row) => KeyOf(row.Name); + + private static CultRecordKey KeyOf(string name) => + new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("product-line:" + name)).AsSpan(0, 16)).ToString("N")); public static int Run(string[] args, string root = null) { var apply = args.Contains("apply"); var line = args.FirstOrDefault(a => a != "apply"); + if (line == "tiers") return RunTiers(Tiers, apply, root); if (line == null || !Lines.TryGetValue(line, out var rows)) { - Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)} [apply]"); + Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)}|tiers [apply]"); return 1; } @@ -178,4 +198,66 @@ public static class ProductLinesCommand Console.WriteLine($"\nAuthored {missing.Length} products in Aetheria.cc"); return 0; } + + // The tiers line has Run's shape: resolve every row first, so a refusal leaves the catalog untouched, then land the missing + // bins in one batch that expects each of their keys absent. A bin already in the catalog (this line, this floor) is skipped, + // so the same command is the first apply and the replay onto a merged catalog. + internal static int RunTiers(IReadOnlyList tiers, bool apply, string root) + { + var db = AetherDb.Open(catalogWritable: apply, root: root); + var designs = db.Cache.GetAll().ToArray(); + var products = db.Cache.GetAll().ToArray(); + + var resolved = new List<(TierRow Row, FactionProductData Line, bool Present)>(); + for (var i = 0; i < tiers.Count; i++) + { + var row = tiers[i]; + var lines = products.Where(p => p.Name == row.Line).ToArray(); + if (lines.Length != 1) throw new InvalidOperationException($"Tier row {i}: expected exactly one line product, found {lines.Length}."); + var parent = lines[0]; + if (parent.Bin != null) throw new InvalidOperationException($"Tier row {i}: the line is itself a bin."); + if (!float.IsFinite(row.Floor) || row.Floor < 0) throw new InvalidOperationException($"Tier row {i}: the floor is not a finite number of at least 0."); + + var named = products.Where(p => string.Equals(p.Name, row.Name, StringComparison.OrdinalIgnoreCase)).ToArray(); + var present = named.Length == 1 && named[0].Name == row.Name && named[0].Bin != null && + named[0].Bin.Line.Key.Equals(db.Cache.RefOf(parent).Key) && named[0].Bin.Floor == row.Floor; + if (named.Length != 0 && !present) throw new InvalidOperationException($"Tier row {i}: the bin name already belongs to another product."); + if (!present && designs.Any(d => string.Equals(d.Name, row.Name, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Tier row {i}: the bin name is the name of a design."); + resolved.Add((row, parent, present)); + } + + var missing = resolved.Where(r => !r.Present).ToArray(); + Console.WriteLine("Product tiers:"); + foreach (var (row, parent, present) in resolved) + Console.WriteLine($" {(present ? "present" : "new ")} {row.Line} -> \"{row.Name}\" by {db.Cache.Get(parent.Manufacturer)?.ShortName}, floor {row.Floor:0.##}: {row.Description}"); + if (missing.Length == 0) + { + Console.WriteLine("\nThe tiers are already authored; nothing to do."); + return 0; + } + + if (!apply) + { + Console.WriteLine($"\nDry run. Pass \"apply\" to author {missing.Length} bins."); + return 0; + } + + if (!db.Cache.Commit(batch => + { + foreach (var (row, _, _) in missing) batch.Expect(KeyOf(row.Name), null); + foreach (var (row, parent, _) in missing) + batch.Upsert(typeof(FactionProductData), new FactionProductData + { + Name = row.Name, + Description = row.Description, + Design = parent.Design, + Manufacturer = parent.Manufacturer, + Bin = new ProductBin { Line = db.Cache.RefOf(parent), Floor = row.Floor }, + }, KeyOf(row.Name)); + })) + throw new InvalidOperationException("The catalog changed while the tiers were being authored; nothing was written. Run it again."); + Console.WriteLine($"\nAuthored {missing.Length} bins in Aetheria.cc"); + return 0; + } } diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 103915d86..c69d120aa 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -63,7 +63,7 @@ public static class Program return PlaceNamesCommand.Run(args.Skip(1).ToArray()); case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear [apply], doctrine-catalog [apply], assembly-from-maker [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply]"); return 1; } } @@ -129,24 +129,33 @@ public static class Program // or a product added after roles landed), so every sold, role-bearing design is checked against every // product that actually sells it, not only the ones this run's roles-migrate happened to touch. var roleGaps = 0; - foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0)) + foreach (var (product, item, missing) in RoleGaps(db.Cache, items, products)) { - var design = db.Cache.RefOf(item).Key; - var sellers = products.Where(p => p.Design.Key.Equals(design)).ToArray(); - foreach (var product in sellers) - { - var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role)); - var missing = item.Roles.Select(r => r.Name).Where(r => !covered.Contains(r)).ToArray(); - if (missing.Length == 0) continue; - roleGaps++; - Console.WriteLine($" GAP: {product.Name} sells {item.Name} but authors no spread for: {string.Join(", ", missing)}" + - " -- a newly minted lot rolls the ProductRole default (mean .5) for these, not this maker's own quality"); - } + roleGaps++; + Console.WriteLine($" GAP: {product.Name} sells {item.Name} but authors no spread for: {string.Join(", ", missing)}" + + " -- a newly minted lot rolls the ProductRole default (mean .5) for these, not this maker's own quality"); } Console.WriteLine($"\n{roleGaps} (product, role) pairs where a sold, role-bearing design's product authors no matching spread"); return 0; } + // Every seller of a role-bearing design that authors no spread for one of its roles. A bin authors no spread: it reads its + // line's (ProductBin), so it is never a gap. + internal static IEnumerable<(FactionProductData Product, EquippableItemData Design, string[] Missing)> RoleGaps( + GameCult.Caching.CultCache cache, IEnumerable items, IEnumerable products) + { + foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0)) + { + var design = cache.RefOf(item).Key; + foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(design))) + { + var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role)); + var missing = item.Roles.Select(r => r.Name).Where(r => !covered.Contains(r)).ToArray(); + if (missing.Length > 0) yield return (product, item, missing); + } + } + } + // Cut 7 (docs/stats-and-power-cut.md): "products author quality, designs author roles." Zero designs // declared a role and zero products authored role quality before this command; every field->role table below // is the one place that content is decided, so the operator reviews a legible table instead of hand-picking @@ -309,9 +318,9 @@ public static class Program // Dry run unless passed "apply", which alone opens the catalog writable; every record must derive cleanly or // nothing is written, the same all-or-nothing contract ShieldMigrate and BrownoutMigrate already use. Prints // the operator review table (grouped by item kind) either way. - private static int RolesMigrate(bool apply) + public static int RolesMigrate(bool apply, string root = null) { - var db = AetherDb.Open(catalogWritable: apply); + var db = AetherDb.Open(catalogWritable: apply, root: root); var items = db.Cache.GetAll().ToArray(); var products = db.Cache.GetAll().ToArray(); var changedDesigns = new List<(object Document, CultRecordKey Key)>(); @@ -366,6 +375,7 @@ public static class Program var byDesign = items.ToDictionary(i => db.Cache.RefOf(i).Key, i => i); foreach (var product in products.OrderBy(p => p.Name, StringComparer.Ordinal)) { + if (product.Bin != null) continue; // a bin authors no roles; the mint reads its line's if (!byDesign.TryGetValue(product.Design.Key, out var design) || design.Roles == null || design.Roles.Count == 0) continue; var existing = new HashSet((product.Roles ?? new List()).Select(r => r.Role)); @@ -1708,9 +1718,9 @@ public static class Program // product of such a design authors that role, and the Passive Array (a generic listener gear, ListenerData) is // authored with two products from the stealth makers. Prints the sensor census first. Dry run unless passed // "apply"; idempotent, so a second apply writes nothing and a catalog another cut changed is rerun, never merged. - private static int SensorStatSet(bool apply) + internal static int SensorStatSet(bool apply, string root = null) { - var db = AetherDb.Open(catalogWritable: apply); + var db = AetherDb.Open(catalogWritable: apply, root: root); var designs = db.Cache.GetAll().ToArray(); var products = db.Cache.GetAll().ToArray(); Faction MakerOf(FactionProductData p) => db.Cache.Get(p.Manufacturer); @@ -1749,7 +1759,7 @@ public static class Program design.Roles ??= new List(); if (!design.Roles.Any(r => r.Name == EmissionDiscipline)) { design.Roles.Add(new ItemRole { Name = EmissionDiscipline }); designChanged = true; } if (designChanged) changedDesigns.Add((design, key)); - foreach (var product in products.Where(p => p.Design.Key.Equals(key))) + foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(key))) { var maker = MakerOf(product) ?? throw new InvalidOperationException($"Product \"{product.Name}\" has no maker."); product.Roles ??= new List(); @@ -2217,7 +2227,7 @@ public static class Program var changedProducts = new List<(FactionProductData Document, CultRecordKey Key)>(); foreach (var design in targets) - foreach (var product in products.Where(p => p.Design.Key.Equals(db.Cache.RefOf(design).Key)).OrderBy(p => p.Name, StringComparer.Ordinal)) + foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(db.Cache.RefOf(design).Key)).OrderBy(p => p.Name, StringComparer.Ordinal)) { var maker = db.Cache.Get(product.Manufacturer); var profile = maker?.Name == "Pirates" ? PirateProfile diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 380fdef32..e52f4ffd1 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -22,7 +22,7 @@ import bpy import numpy from mathutils import Matrix, Vector -from . import hull_grid +from . import hull_grid, hull_source from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, encode_hardpoint, layout_revision, read, read_layout, replace_layout, replace_lines, replace_visual, valid_ship_id) @@ -609,6 +609,9 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): return _refuse(self, exc) +DEFAULT_OPENSCAD = r"C:\Tools\OpenSCAD-2026.09.23-x86-64\openscad.com" + + class AETHERIA_AP_preferences(bpy.types.AddonPreferences): bl_idname = __package__ @@ -636,8 +639,14 @@ class AETHERIA_AP_preferences(bpy.types.AddonPreferences): name="Bake max ray distance", default=0.0, min=0.0, unit="LENGTH", description="The longest ray, in metres, Bake From Source casts from the extruded surface. 0 means no limit") + openscad: bpy.props.StringProperty( + name="OpenSCAD", subtype="FILE_PATH", default=DEFAULT_OPENSCAD, + description="The OpenSCAD executable (openscad.com on Windows, 2026.09 or later: Build From Hull Source uses its " + "Manifold backend)") + def draw(self, context): self.layout.prop(self, "game_folder") + self.layout.prop(self, "openscad") self.layout.prop(self, "max_texture") self.layout.prop(self, "bake_size") self.layout.prop(self, "bake_cage_extrusion") @@ -1515,6 +1524,172 @@ def _frame_ship(context, collection, root, path, saved_shape): _recentre(root, hull_grid.cell_centre((float(origin[0]), float(origin[1])), centre[0], centre[1])) +HULL_BUILT = "aetheria.hull_built" # on the collection: {object name: fingerprint} of what Build From Hull Source made +HULL_COLLIDER = ("hull-collider", "collider", "Hull Collider") + + +def _hull_source_file(collection): + """The bound package's hull.scad, or None. A package holds one next to its ship.cc while its hull is agent-built.""" + stored = collection.get("aetheria.ship_cc") + if not stored or collection.get("aetheria.pending"): + return None + source = Path(bpy.path.abspath(stored)).with_name(hull_source.HULL_FILE) + return source if source.is_file() else None + + +def _fingerprint(obj): + """What Build made of obj, as it stands: its place under its parent, the anchor properties and, for a mesh, its geometry. + A copy has another name and an edit changes this, so neither is taken for Build's own.""" + values = [obj.parent.name if obj.parent else "", tuple(obj.location), tuple(obj.rotation_euler), tuple(obj.scale), + obj.get("aetheria.role", ""), obj.get("aetheria.id", ""), obj.get("aetheria.order", 0)] + if obj.type == "MESH": + values += [[tuple(v.co) for v in obj.data.vertices], [tuple(p.vertices) for p in obj.data.polygons]] + return hull_source.fingerprint(*values) + + +def _hull_built(collection): + """(untouched, changed): Build's objects found in the collection as it made them, and those it made that were edited. + The record is the only owner: an object is Build's when its name and fingerprint are the ones recorded.""" + record = collection.get(HULL_BUILT) + record = record.to_dict() if record is not None else {} + present = {obj.name: obj for obj in collection.all_objects if obj.name in record} + untouched = [obj for name, obj in present.items() if _fingerprint(obj) == record[name]] + return untouched, [obj for obj in present.values() if obj not in untouched] + + +def _hull_material(ship_id, zone, colour, emissive=False): + """The zone's material: a flat colour from the language palette, made once per ship and zone, recoloured on a rebuild.""" + name = f"{ship_id}.{zone}" + material = bpy.data.materials.get(name) or bpy.data.materials.new(name) + material.use_nodes = True + shader = _principled(material) + shader.inputs["Base Color"].default_value = (*colour, 1.0) + shader.inputs["Metallic"].default_value = 0.0 + shader.inputs["Roughness"].default_value = 0.85 + shader.inputs["Emission Color"].default_value = (*colour, 1.0) + shader.inputs["Emission Strength"].default_value = 1.0 if emissive else 0.0 + return material + + +def _hull_mesh(name, mesh, material, offset=(0.0, 0.0, 0.0), yaw=0.0): + """A flat-shaded mesh of the hull source's, expressed in the frame of an object that stands at offset, turned yaw.""" + cos, sin = math.cos(-yaw), math.sin(-yaw) + vertices = [((x - offset[0]) * cos - (y - offset[1]) * sin, (x - offset[0]) * sin + (y - offset[1]) * cos, + z - offset[2]) for x, y, z in mesh.vertices] + data = bpy.data.meshes.new(name) + data.from_pydata(vertices, [], [tuple(tri) for tri in mesh.triangles]) + data.update() + data.materials.append(material) + return data + + +def _build_hull_source(context, collection): + """Replaces the objects Build made before with the hull source's zone meshes, anchors and collider under Ship Root. + Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language, or the + collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout row.""" + source = _hull_source_file(collection) + if source is None: + raise ValueError("The bound package holds no hull.scad") + root = _ship_root(collection) + untouched, changed = _hull_built(collection) + if root is not None: + _refuse_loose_meshes(context, collection) + theirs = [obj for obj in _own_meshes(collection) if obj not in untouched] + if theirs or changed: + raise ValueError("The collection holds meshes the hull source did not make, or objects it made that were " + "changed; Build From Hull Source replaces only what it made and nobody touched") + addon = context.preferences.addons.get(__package__) + executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD + hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable)) + broken = hull.failed() + if broken: + raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken)) + ship_id = collection["aetheria.id"] + if root is None: + root = bpy.data.objects.new(SHIP_ROOT, None) + root["aetheria.ship_root"] = True + collection.objects.link(root) + for obj in untouched: + _discard_object(obj) + made = [] + + def mark(obj): + made.append(obj) + return obj + + def link(obj): + collection.objects.link(obj) + _attach_to_root(obj, root) + return mark(obj) + + for zone, mesh in hull.zones.items(): + name = zone.replace(":", ".") + link(bpy.data.objects.new(name, _hull_mesh(name, mesh, _hull_material(ship_id, name, hull.colours[zone])))) + placed, anchors = {}, {a.id: a for a in hull.anchors} + for anchor in hull.anchors: # a muzzle follows its mount in the echo order the source gave + yaw = math.radians(anchor.yaw) + if anchor.role in hull_source.MESH_ROLES: + colour, emissive = (hull.glow, True) if anchor.role == "thruster-emitter" else (hull.radiator, False) + material = _hull_material(ship_id, f"{anchor.role}.{anchor.id}", colour, emissive) + obj = link(bpy.data.objects.new( + anchor.id, _hull_mesh(anchor.id, hull.anchor_meshes[anchor.id], material, anchor.position, yaw))) + obj.location, obj.rotation_euler = anchor.position, (0.0, 0.0, yaw) + obj["aetheria.role"], obj["aetheria.id"] = anchor.role, anchor.id + elif anchor.role == "weapon-mount": + obj = mark(_make_empty(collection, anchor.id, anchor.role, root, anchor.position, yaw)) + else: + mount = anchors[anchor.parent] + turn = -math.radians(mount.yaw) + dx, dy = anchor.position[0] - mount.position[0], anchor.position[1] - mount.position[1] + local = (dx * math.cos(turn) - dy * math.sin(turn), dx * math.sin(turn) + dy * math.cos(turn), + anchor.position[2] - mount.position[2]) + order = 1 + len(_muzzles(placed[anchor.parent])) + obj = mark(_make_empty(collection, anchor.id, anchor.role, placed[anchor.parent], local, order=order)) + placed[anchor.id] = obj + role, anchor_id, name = HULL_COLLIDER + collider = link(bpy.data.objects.new(name, _hull_mesh( + name, hull.zones[hull.body_zone], _hull_material(ship_id, "collider", (0.5, 0.5, 0.5))))) + collider["aetheria.role"], collider["aetheria.id"] = role, anchor_id + collection[HULL_BUILT] = {obj.name: _fingerprint(obj) for obj in made} + return hull + + +def _retire_hull_source(collection): + """Deletes the package's hull.scad when the body is the operator's: the collection holds render meshes that Build did + not make as they stand, and no zone mesh of Build's is left. The anchors and collider she kept are hers from then on. + A hull never built, or built and packaged as it stands, keeps its source. Returns whether the file was deleted.""" + source = _hull_source_file(collection) + root = _ship_root(collection) + if source is None or root is None: + return False + untouched, _ = _hull_built(collection) + zones = [obj for obj in untouched if obj.type == "MESH" and "aetheria.role" not in obj] + if zones or not [obj for obj in _own_meshes(collection) if obj not in untouched]: + return False + source.unlink() + if HULL_BUILT in collection: + del collection[HULL_BUILT] + return True + + +class AETHERIA_OT_build_hull_source(bpy.types.Operator): + bl_idname = "aetheria.build_hull_source" + bl_label = "Build From Hull Source" + bl_description = ("Replace the hull's generated objects with the meshes, mount anchors and collider that the " + "package's hull.scad describes, after checking it against its faction's form language. Never " + "touches the layout or objects you made") + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + try: + collection, _ = _layout_path(context) + hull = _build_hull_source(context, collection) + self.report({"INFO"}, f"Built {len(hull.zones)} zones and {len(hull.anchors)} anchors from hull.scad") + return {"FINISHED"} + except _EXPECTED as exc: + return _refuse(self, exc) + + class AETHERIA_OT_package_ship(bpy.types.Operator): bl_idname = "aetheria.package_ship" bl_label = "Package Ship" @@ -1567,6 +1742,8 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): self.report({"ERROR"}, scene.aetheria_package_report) return {"CANCELLED"} scene.aetheria_package_report = f"Packaged {len(anchors)} anchors; the validator accepted the ship" + if _retire_hull_source(collection): + scene.aetheria_package_report += "; deleted hull.scad: this package ships your art" self.report({"INFO"}, scene.aetheria_package_report) return {"FINISHED"} @@ -1610,6 +1787,11 @@ class AETHERIA_PT_ship(bpy.types.Panel): row.operator("aetheria.flip_nose", icon="LOOP_BACK") row.operator("aetheria.rasterise_hull", icon="MESH_GRID") layout.operator("aetheria.bake_from_source", icon="RENDER_RESULT") + try: + if _hull_source_file(_ship_collection(context)) is not None: + layout.operator("aetheria.build_hull_source", icon="MESH_CUBE") + except ValueError: + pass if not state.ship_id: self._draw_package(context, layout) return @@ -1685,7 +1867,8 @@ classes = (AETHERIA_PG_cell, AETHERIA_PG_hardpoint, AETHERIA_PG_layout, AETHERIA_OT_save_layout, AETHERIA_OT_bind_ship_collection, AETHERIA_OT_capture_ship_lines, AETHERIA_OT_new_ship, AETHERIA_OT_flip_nose, AETHERIA_OT_bake_from_source, - AETHERIA_OT_rasterise_hull, AETHERIA_OT_package_ship, AETHERIA_AP_preferences, AETHERIA_PT_ship) + AETHERIA_OT_rasterise_hull, AETHERIA_OT_build_hull_source, AETHERIA_OT_package_ship, + AETHERIA_AP_preferences, AETHERIA_PT_ship) def register(): diff --git a/tools/blender/aetheria_ships/factions/lowpoly.scad b/tools/blender/aetheria_ships/factions/lowpoly.scad new file mode 100644 index 000000000..9979483a0 --- /dev/null +++ b/tools/blender/aetheria_ships/factions/lowpoly.scad @@ -0,0 +1,14 @@ +// Shared low-poly primitives (any faction). Frame: Blender Ship Root, nose -Y, starboard -X, up +Z. +// cbox: a box with one chamfer segment on every edge (the operator's one-segment-bevel bar, as geometry). +module cbox(s, c=0.2, center=true) { + t = center ? [0,0,0] : s/2; + translate(t) hull() { + cube([s.x, s.y-2*c, s.z-2*c], center=true); + cube([s.x-2*c, s.y, s.z-2*c], center=true); + cube([s.x-2*c, s.y-2*c, s.z], center=true); + } +} +// tube along +Y (nose is -Y, so a forward barrel uses rotate 180 or negative length) +module tube(r, l, fn=8) { rotate([-90,0,0]) cylinder(r=r, h=l, $fn=fn); } +// a straight strut between two points +module strut(a, b, r=0.08, fn=6) { hull() { translate(a) sphere(r, $fn=fn); translate(b) sphere(r, $fn=fn); } } diff --git a/tools/blender/aetheria_ships/factions/pirate.scad b/tools/blender/aetheria_ships/factions/pirate.scad new file mode 100644 index 000000000..8705869a1 --- /dev/null +++ b/tools/blender/aetheria_ships/factions/pirate.scad @@ -0,0 +1,43 @@ +// Pirate Coalition form language: the one owner of this faction's additions kit, symmetry rule and palette. Source: Faction Flavor > Pirate Coalition > Visual Identity: "An identifiable +// donor hull with asymmetrical additions, cut-down modules, adapted hardpoints, exposed cable routes, and bolted +// salvage plates. Each major addition has a plausible mounting and supply path." +// A Pirate hull = one donor-language body (another faction's language, named in the hull's brief and judged by its +// proportions) + these additions. This language draws no body and has no proportions of its own. +include +// Bolted salvage plate: a slightly tilted slab with proud bolt heads. +module pi_plate(w, l, t=0.12, tilt=4) { + rotate([tilt, -tilt*0.6, 0]) { cube([w, l, t], center=true); + for (x=[-w/2+0.15, w/2-0.15], y=[-l/2+0.15, l/2-0.15]) translate([x, y, t/2]) cylinder(r=0.07, h=0.06, $fn=6); } +} +// Exposed cable run: a chain of short segments sagging between clamp points. +module pi_cable(pts, r=0.06) { for (i=[0:len(pts)-2]) strut(pts[i], pts[i+1], r, 5); } +// Weld bead / adapter collar where an addition meets the donor. +module pi_collar(r, h=0.2) { cylinder(r=r, h=h, center=true, $fn=8); } +// Adapted hardpoint: a crude saddle (two plates + braces) that carries something the donor never had. +module pi_saddle(w, l, h) { + translate([0,0,h/2]) cube([w, l, 0.15], center=true); + for (x=[-w/2+0.1, w/2-0.1]) translate([x, 0, h/4]) cube([0.12, l*0.8, h/2], center=true); + strut([-w/2, -l/2, 0], [w/2, l/2, h], 0.05); strut([w/2, -l/2, 0], [-w/2, l/2, h], 0.05); +} +// Replacement drive: an oversize bell on a braced cradle, not matching the donor's flush engines. +module pi_drive(r, l) { + rotate([-90,0,0]) { cylinder(r1=r*0.55, r2=r, h=l*0.45, $fn=8); translate([0,0,-l*0.55]) cylinder(r=r*0.6, h=l*0.55, $fn=8); } +} +// Oversized industrial gun (a mass driver pulled off a mining rig): long box receiver, single heavy barrel. +module pi_big_gun(len, cal=0.2) { + cbox([0.9, len*0.35, 0.7], 0.08); + translate([0, -len*0.17, 0.05]) rotate([0,0,180]) tube(cal, len*0.65, 8); + translate([0, -len*0.82, 0.05]) rotate([0,0,180]) tube(cal*1.6, 0.35, 8); +} +// Bolted-on sensor pod: one sleek piece from a better ship, on a strut. +module pi_sensor_pod(l=1.4, r=0.3) { scale([1, l/(2*r), 1]) sphere(r, $fn=10); } + +PI_ZONES = [["primer", [0.48, 0.27, 0.22]], // primer-red salvage plates + ["weld", [0.70, 0.68, 0.62]], // welded additions: guns, drives, cables + ["accent", [0.10, 0.62, 0.60]], // the crew's own colour + ["recess", [0.08, 0.08, 0.09]]]; +PI_GLOW = [0.55, 0.85, 1.0]; +PI_RADIATOR = [0.70, 0.68, 0.62]; +// No body zone and no proportions: the donor's. The additions must make the top view visibly lopsided. +PI_LANG = ["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["asymmetric", 0.9]]; +if (part == "lang") echo(lang = PI_LANG); diff --git a/tools/blender/aetheria_ships/factions/rnd.scad b/tools/blender/aetheria_ships/factions/rnd.scad new file mode 100644 index 000000000..ae7da7241 --- /dev/null +++ b/tools/blender/aetheria_ships/factions/rnd.scad @@ -0,0 +1,30 @@ +// Rossum & Douglas form language: "the flying wedge". Source: Faction Flavor > Rossum & Douglas > Visual Identity. +// The one owner of this faction's shape kit, proportions, symmetry rule and palette. It also serves as a donor language: +// a Pirate hull builds on a stolen R&D wedge (Bench 3 "Jumble Sale"). +include +// Broad shallow trapezoid narrowing to a blunt nose (-Y), wide rear face (+Y), flat dorsal plane, +// bevelled perimeter, thicker centre section. One idea, no flourishes. +module rd_wedge(L, w_rear, w_nose, h_edge, h_centre) { + hull() { + translate([0, L/2-0.3, 0]) cube([w_rear, 0.6, h_edge], center=true); // rear face + translate([0, -L/2+0.3, 0]) cube([w_nose, 0.6, h_edge*0.8], center=true); // blunt nose + translate([0, L*0.05, 0]) cube([w_rear*0.45, L*0.7, h_centre], center=true); // thick centre + } +} +// Flush rear engine bank: shallow rectangular recesses across the rear face. +module rd_engine_bank(w, h, n=3) { + for (i=[0:n-1]) translate([(i-(n-1)/2)*(w/n), 0, 0]) cube([w/n*0.7, 0.5, h], center=true); +} +// Recessed window strip and identical hatches: barely interrupt the planes. +module rd_window(w=1.2) { cube([w, 0.35, 0.18], center=true); } +module rd_hatch() { cube([0.9, 1.1, 0.06], center=true); } + + +RD_ZONES = [["primary", [0.72, 0.74, 0.76]], // the wedge + ["secondary", [0.40, 0.47, 0.56]], // hatches, panels + ["accent", [0.90, 0.89, 0.84]], // stripes + ["recess", [0.10, 0.12, 0.18]]]; // windows, engine bank +RD_GLOW = [1.0, 0.75, 0.3]; +RD_RADIATOR = [0.40, 0.47, 0.56]; +RD_LANG = ["rnd", RD_ZONES, "primary", RD_GLOW, RD_RADIATOR, [[0.12, 0.35], [0.55, 1.1]], ["bilateral", 0.97]]; +if (part == "lang") echo(lang = RD_LANG); diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad new file mode 100644 index 000000000..c00139dd8 --- /dev/null +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -0,0 +1,60 @@ +// Zhestokost form language: the one owner of this faction's shape kit, proportions, symmetry rule and palette. +// Canon. Operator ruling zhestokost-language-lore-text: Zhestokost is industrial and brutalist, "the dream of the +// eternal Soviet, a society that has achieved total and unassailable permaculture via a carefully organized and +// brutally practical (self-policing) state". Form follows the lore page ("Faction Flavor and Visual Identity" > +// Zhestokost): low broad stepped masses, deep seams, charcoal, iron grey, dull oxide red. The operator's faction sheet +// (Asset Sources/Zhestokost.png) lends the handling boom with hanging magazines as vocabulary, not its palette. +// A hull composes these modules; nothing here places itself. +include +ZH_CH = 0.22; // chamfer: heavy, square, one segment +// A stepped mass: tiers shrink upward and pull in from both ends. "Low, broad ... stepped rectangular masses." +module zh_stepped(L, W, H, tiers=3, pull=0.12) { + for (i=[0:tiers-1]) let(k=i/tiers) + translate([0, 0, H*k + H/tiers/2]) cbox([W*(1-pull*i*1.6), L*(1-pull*i), H/tiers + 0.02], ZH_CH); +} +// Armour plate standing proud of a face, leaving a deep seam (the seam is the gap to the next plate). +module zh_plate(w, l, t=0.3) { cbox([w, l, t], 0.08); } +// Recessed machinery: a dark box set into a mass (subtract the pocket in the hull, then place this inside). +module zh_recess(w, l, d) { cube([w, l, d], center=true); } +// Ammunition magazine: square cassette with ribbed loading face and a hanger lug on top. +module zh_cassette(w=1.2, l=1.6, h=2.0) { + cbox([w, l, h], 0.12); + for (z=[-h*0.3, 0, h*0.3]) translate([0, -l/2-0.04, z]) cube([w*0.8, 0.12, 0.18], center=true); + translate([0, 0, h/2+0.2]) cube([0.3, 0.6, 0.4], center=true); +} +// Handling boom: square box beam with cross-members; magazines hang from it. +module zh_boom(len, w=0.9, h=0.7) { + cbox([w, len, h], 0.1); + for (y=[-len/2+0.6:1.6:len/2-0.4]) translate([0, y, -h/2-0.1]) cube([w+0.4, 0.25, 0.25], center=true); +} +// Towing point: a thick eye on a block. +module zh_tow_lug() { cbox([0.6, 0.6, 0.4], 0.08); translate([0,0,0.45]) rotate([0,90,0]) difference() { cylinder(r=0.38, h=0.25, center=true, $fn=8); cylinder(r=0.18, h=1, center=true, $fn=8); } } +// Ballistic mount: square base, slab mantlet, N thick barrels with a box muzzle brake. Barrels point -Y (nose). +module zh_gun(n=2, cal=0.22, len=3.0, base=1.6) { + cbox([base, base, 0.5], 0.12); + translate([0, 0, 0.55]) cbox([base*0.85, base*0.7, 0.7], 0.12); + for (i=[0:n-1]) translate([(i-(n-1)/2)*cal*3, -base*0.3, 0.6]) { + rotate([0,0,180]) tube(cal, len, 8); + translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04); + } +} +// Drive block: a flat rear face with square nozzles in a grid (recessed: emitters sit at the nozzle mouths). +module zh_drive_block(w, h, d, nx=2, nz=1, nozzle=1.0) { + difference() { + cbox([w, d, h], ZH_CH); + for (i=[0:nx-1], j=[0:nz-1]) translate([(i-(nx-1)/2)*(w/nx), d/2, (j-(nz-1)/2)*(h/nz)]) cube([nozzle, 0.8, nozzle], center=true); + } +} + + +// Palette zones. Value contrast carries the read at 48 px on dark space: the body is iron grey, the plates and seams +// are charcoal, the oxide red is the only hue. Zones become livery mask channels. +ZH_ZONES = [["primary", [0.42, 0.43, 0.45]], // iron grey: the stepped mass + ["secondary", [0.20, 0.20, 0.22]], // charcoal: armour plates, booms, guns, command block + ["accent", [0.55, 0.22, 0.14]], // dull oxide red: magazines, markings + ["recess", [0.08, 0.08, 0.09]]]; // recessed machinery +ZH_GLOW = [1.0, 0.62, 0.25]; +ZH_RADIATOR = [0.50, 0.51, 0.52]; +// [faction, zones, body zone, glow, radiator, [height/width range, width/length range], symmetry rule] +ZH_LANG = ["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97]]; +if (part == "lang") echo(lang = ZH_LANG); diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py new file mode 100644 index 000000000..08fa00b57 --- /dev/null +++ b/tools/blender/aetheria_ships/hull_source.py @@ -0,0 +1,391 @@ +"""Agent-built hulls: reads a hull.scad through OpenSCAD and judges it against its faction's form language. + +No bpy import. A hull source is a GameData/Mods//hull.scad that includes its faction's language file (and, for a +Pirate hull, its donor's) from factions/. Everything the add-on needs is echoed by one part of that file. The faction's language is owned by its own file in +factions/: the checks read it from there, by the faction the brief names, and the hull's own `lang` echo is only compared +with it (a hull that redefines or shadows an included language fails a check): + + brief ECHO brief = [faction, donor, role, [length_min, length_max], [tris_min, tris_max], reads] + lang ECHO lang = [faction, [[zone, [r, g, b]], ...], body zone, glow, radiator, proportion, symmetry] + (every included language file echoes its own, and so does factions/.scad run alone; a Pirate + language has no body zone and no proportion) + anchors ECHO anchor = [id, role, [x, y, z], yaw, parent] one line per anchor, yaw 0 nose, 90 port + the zone's geometry, OFF; a donor's zone is named ":" + emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor + +Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() returns a HullBuild; the caller refuses on any failed +check. Messages and check names are fixed text: nothing a hull source says is echoed back. +""" + +import hashlib +import json +import math +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Optional + +FACTIONS_DIR = Path(__file__).with_name("factions") +HULL_FILE = "hull.scad" +OPENSCAD_TIMEOUT = 180 # seconds, per part +SILHOUETTE_CELL = 0.25 # metres, the top-view raster the symmetry check compares +ANCHOR_ROLES = ("weapon-mount", "weapon-muzzle", "thruster-emitter", "radiator-mesh") +MESH_ROLES = {"thruster-emitter": "emit:", "radiator-mesh": "rad:"} +ECHO_PARTS = ("brief", "lang", "anchors") # parts that make no geometry; their ECHO lines are the answer +LANGUAGE_NAME = re.compile(r"[a-z][a-z0-9_]*") +ECHO_LINE = re.compile(r"^ECHO: (\w+) = (.*)$", re.MULTILINE) + +# A runner answers one part of the hull source with (OFF text, ECHO text). OFF text is "" when the part made no geometry. +Runner = Callable[[str], "tuple[str, str]"] + + +class HullSourceError(ValueError): + """The hull source could not be read. The message is fixed text.""" + + +@dataclass(frozen=True) +class Mesh: + vertices: tuple + triangles: tuple + + def bounds(self): + """((min x, y, z), (max x, y, z)).""" + columns = list(zip(*self.vertices)) + return tuple(min(c) for c in columns), tuple(max(c) for c in columns) + + +@dataclass(frozen=True) +class Language: + faction: str + zones: tuple # ((name, (r, g, b)), ...) + body: str # the zone whose proportions the language judges; "" for a language with no body of its own + glow: tuple + radiator: tuple + proportion: Optional[tuple] # ((h/w min, max), (w/l min, max)); None for a language that takes its donor's + symmetry: tuple # ("bilateral", minimum mirror overlap) or ("asymmetric", maximum mirror overlap) + + def zone_names(self): + return [name for name, _ in self.zones] + + def colour(self, zone): + return dict(self.zones)[zone] + + +@dataclass(frozen=True) +class Brief: + faction: str + donor: str + role: str + length: tuple + triangles: tuple + reads: tuple + + +@dataclass(frozen=True) +class Anchor: + id: str + role: str + position: tuple + yaw: float + parent: str + + +@dataclass(frozen=True) +class Check: + name: str + ok: bool + detail: str = "" + + +@dataclass +class HullBuild: + brief: Brief + languages: dict # faction -> Language + zones: dict # zone key ("primary", "rnd:primary") -> Mesh + colours: dict # zone key -> (r, g, b) + anchors: list + anchor_meshes: dict # anchor id -> Mesh + glow: tuple + radiator: tuple + body_zone: str # the zone key the collider is made from + checks: list = field(default_factory=list) + + def failed(self): + return [check for check in self.checks if not check.ok] + + +def parse_off(text): + """(vertices, triangles) of an OFF file, faces fanned into triangles. An empty or absent text is an empty mesh.""" + lines = [line.split() for line in text.splitlines() if line.strip() and not line.lstrip().startswith("#")] + if not lines or lines[0][0] != "OFF": + return Mesh((), ()) + try: + header = lines[1] if len(lines[0]) == 1 else lines[0][1:] + vertex_count, face_count = int(header[0]), int(header[1]) + first = 2 if len(lines[0]) == 1 else 1 + vertices = tuple(tuple(float(v) for v in lines[first + i][:3]) for i in range(vertex_count)) + triangles = [] + for row in lines[first + vertex_count:first + vertex_count + face_count]: + ring = [int(v) for v in row[1:1 + int(row[0])]] + triangles += [(ring[0], ring[i], ring[i + 1]) for i in range(1, len(ring) - 1)] + if any(not 0 <= v < vertex_count for tri in triangles for v in tri): + raise ValueError + except (ValueError, IndexError): + raise HullSourceError("The hull source made geometry that is not a readable OFF file") from None + return Mesh(vertices, tuple(triangles)) + + +def parse_echoes(text, name): + """The values of every 'ECHO: = ' line. OpenSCAD prints JSON-compatible values.""" + values = [] + for key, raw in ECHO_LINE.findall(text): + if key != name: + continue + try: + values.append(json.loads(raw)) + except ValueError: + raise HullSourceError(f"The hull source echoed a {name} that is not plain numbers, strings and lists") from None + return values + + +def fingerprint(*values): + """A digest of numbers, strings and the lists of them, floats to five decimals: equal exactly when the values are.""" + digest = hashlib.sha256() + + def add(value): + if isinstance(value, (list, tuple)): + digest.update(b"[") + for item in value: + add(item) + digest.update(b"]") + else: + digest.update((f"{round(value, 5) + 0.0:.5f};" if isinstance(value, float) else f"{value!r};").encode()) + + add(values) + return digest.hexdigest() + + +def _vector(value, size, numbers=True): + if not isinstance(value, list) or len(value) != size or (numbers and not all(_number(v) for v in value)): + raise HullSourceError("The hull source echoed a vector of the wrong shape") + return tuple(value) + + +def _number(value): + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _text(value): + if not isinstance(value, str): + raise HullSourceError("The hull source echoed a name that is not a string") + return value + + +def read_brief(text): + (raw,) = _exactly_one(parse_echoes(text, "brief"), "brief") + if not isinstance(raw, list) or len(raw) != 6 or not isinstance(raw[5], list): + raise HullSourceError("The hull source's brief has the wrong shape") + return Brief(_text(raw[0]), _text(raw[1]), _text(raw[2]), _vector(raw[3], 2), _vector(raw[4], 2), + tuple(_text(r) for r in raw[5])) + + +def _proportion(raw): + if raw == []: + return None + if not isinstance(raw, list) or len(raw) != 2: + raise HullSourceError("A faction language has the wrong shape") + return _vector(raw[0], 2), _vector(raw[1], 2) + + +def read_languages(text): + """Every language echoed in text, in echo order.""" + languages = [] + for raw in parse_echoes(text, "lang"): + if not isinstance(raw, list) or len(raw) != 7 or not isinstance(raw[1], list): + raise HullSourceError("A faction language has the wrong shape") + zones = tuple((_text(z[0]), _vector(z[1], 3)) for z in raw[1] if isinstance(z, list) and len(z) == 2) + if len(zones) != len(raw[1]): + raise HullSourceError("A faction language has the wrong shape") + proportion = _proportion(raw[5]) + symmetry = _vector(raw[6], 2, numbers=False) + if symmetry[0] not in ("bilateral", "asymmetric") or not _number(symmetry[1]): + raise HullSourceError("A faction language names a symmetry rule it does not have") + language = Language(_text(raw[0]), zones, _text(raw[2]), _vector(raw[3], 3), _vector(raw[4], 3), proportion, + symmetry) + if language.body and language.body not in language.zone_names(): + raise HullSourceError("A faction language's body zone is not one of its zones") + languages.append(language) + return languages + + +def read_anchors(text): + anchors = [] + for raw in parse_echoes(text, "anchor"): + if not isinstance(raw, list) or len(raw) != 5 or not _number(raw[3]): + raise HullSourceError("An anchor has the wrong shape") + anchors.append(Anchor(_text(raw[0]), _text(raw[1]), _vector(raw[2], 3), float(raw[3]), _text(raw[4]))) + return anchors + + +def _exactly_one(values, what): + if len(values) != 1: + raise HullSourceError(f"The hull source must echo exactly one {what}") + return values + + +def mirror_overlap(triangles_xy, cell=SILHOUETTE_CELL): + """Intersection over union of the top-view silhouette with its reflection across x = 0. The silhouette is every raster + cell whose centre a triangle covers (a feature thinner than a cell may not show).""" + cells = set() + for (ax, ay), (bx, by), (cx, cy) in triangles_xy: + det = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy) + if abs(det) < 1e-12: + continue + for i in range(math.floor(min(ax, bx, cx) / cell), math.floor(max(ax, bx, cx) / cell) + 1): + px = (i + 0.5) * cell + for j in range(math.floor(min(ay, by, cy) / cell), math.floor(max(ay, by, cy) / cell) + 1): + py = (j + 0.5) * cell + u = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / det + v = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / det + if u >= 0 and v >= 0 and u + v <= 1: + cells.add((i, j)) + mirrored = {(-i - 1, j) for i, j in cells} + union = len(cells | mirrored) + return len(cells & mirrored) / union if union else 1.0 + + +def _triangles_xy(mesh): + return [tuple(mesh.vertices[v][:2] for v in tri) for tri in mesh.triangles] + + +def _language(library, faction): + """The language of factions/.scad, which owns it: exactly one echo, naming that faction.""" + (language,) = _exactly_one(read_languages(library(faction)), "language") + if language.faction != faction: + raise HullSourceError("A faction language file echoes the language of another faction") + return language + + +def build(runner, library): + """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with + (the ECHO text of that faction's own language file). Raises HullSourceError when the source cannot be read at all; + otherwise returns a HullBuild whose failed() lists the checks it breaks.""" + _, text = runner("brief") + brief = read_brief(text) + _, text = runner("lang") + included = read_languages(text) + own = _language(library, brief.faction) + donor = _language(library, brief.donor) if brief.donor else None + owned = [own] + ([donor] if donor else []) + zone_keys = list(own.zone_names()) + [f"{donor.faction}:{z}" for z in (donor.zone_names() if donor else ())] + colours = {z: own.colour(z) for z in own.zone_names()} + colours.update({f"{donor.faction}:{z}": donor.colour(z) for z in (donor.zone_names() if donor else ())}) + zones = {key: parse_off(runner(key)[0]) for key in zone_keys} + _, text = runner("anchors") + anchors = read_anchors(text) + ids = [a.id for a in anchors] + if len(set(ids)) != len(ids) or any(a.role not in ANCHOR_ROLES for a in anchors): + raise HullSourceError("The hull source's anchors repeat an id or name a role a hull source does not make") + anchor_meshes = {a.id: parse_off(runner(MESH_ROLES[a.role] + a.id)[0]) for a in anchors if a.role in MESH_ROLES} + judge = donor or own + body_zone = f"{donor.faction}:{donor.body}" if donor else own.body + result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, + own.glow, own.radiator, body_zone) + by_faction = lambda language: language.faction + result.checks = [Check("the hull includes its faction's language files and changes none of them", + sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] + result.checks += _checks(result, own, judge) + return result + + +def _checks(hull, own, judge): + brief, checks = hull.brief, [] + empty = [key for key, mesh in hull.zones.items() if not mesh.triangles] + checks.append(Check("every zone of the language has geometry", not empty, f"{len(empty)} empty")) + missing = [a.id for a in hull.anchors if a.role in MESH_ROLES and not hull.anchor_meshes[a.id].triangles] + checks.append(Check("every emitter and radiator anchor has a mesh", not missing, f"{len(missing)} without")) + checks.append(Check("a donor is named exactly when the language has no proportions of its own", + bool(brief.donor) == (own.proportion is None), "")) + body = hull.zones[hull.body_zone] if hull.body_zone in hull.zones else Mesh((), ()) + if body.triangles and judge.proportion: + low, high = body.bounds() + width, length, height = (high[0] - low[0]), (high[1] - low[1]), (high[2] - low[2]) + ratios = (height / width if width else float("inf"), width / length if length else float("inf")) + (hw_lo, hw_hi), (wl_lo, wl_hi) = judge.proportion + checks.append(Check("the body's proportions lie inside the language's ranges", + hw_lo <= ratios[0] <= hw_hi and wl_lo <= ratios[1] <= wl_hi, + f"h/w {ratios[0]:.2f}, w/l {ratios[1]:.2f}")) + else: + checks.append(Check("the body's proportions lie inside the language's ranges", False, "no body to judge")) + every = [m for m in list(hull.zones.values()) + list(hull.anchor_meshes.values()) if m.triangles] + if every: + overlap = mirror_overlap([t for m in every for t in _triangles_xy(m)]) + rule, bound = own.symmetry + checks.append(Check(f"the top view obeys the language's {rule} rule", + overlap >= bound if rule == "bilateral" else overlap <= bound, f"mirror overlap {overlap:.2f}")) + ys = [v[1] for m in every for v in m.vertices] + length = max(ys) - min(ys) + checks.append(Check("the length lies inside the brief's range", brief.length[0] <= length <= brief.length[1], + f"{length:.1f} m")) + else: + checks.append(Check("the top view obeys the language's symmetry rule", False, "no geometry")) + checks.append(Check("the length lies inside the brief's range", False, "no geometry")) + triangles = sum(len(m.triangles) for m in every) + checks.append(Check("the triangle count lies inside the brief's budget", + brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}")) + by_id = {a.id: a for a in hull.anchors} + unmuzzled = [a.id for a in hull.anchors if a.role == "weapon-mount" + and not any(m.role == "weapon-muzzle" and m.parent == a.id for m in hull.anchors)] + forward = [m.id for m in hull.anchors if m.role == "weapon-muzzle" and m.parent in by_id + and by_id[m.parent].role == "weapon-mount" and m.position[1] >= by_id[m.parent].position[1]] + orphans = [m.id for m in hull.anchors if m.role == "weapon-muzzle" + and (m.parent not in by_id or by_id[m.parent].role != "weapon-mount")] + checks.append(Check("every weapon-mount has a muzzle nose-ward of it and no muzzle is orphaned", + not (unmuzzled or forward or orphans), + f"{len(unmuzzled)} without a muzzle, {len(forward)} not nose-ward, {len(orphans)} orphaned")) + return checks + + +def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): + """A Runner that runs OpenSCAD (Manifold backend, full render) on source, one process at a time. on_start(pid) is + called with each process's id. OPENSCADPATH is the language folder, so a hull source includes .""" + env = dict(os.environ, OPENSCADPATH=str(library)) + + def run(part): + with tempfile.TemporaryDirectory() as folder: + target = Path(folder) / ("part.echo" if part in ECHO_PARTS else "part.off") + command = [str(executable), "--backend", "Manifold", "--render", "all", "-D", f'part="{part}"', + "-o", str(target), str(source)] + try: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) + except OSError: + raise HullSourceError("OpenSCAD could not be started") from None + if on_start: + on_start(process.pid) + try: + output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise HullSourceError("OpenSCAD did not finish a part in time") from None + if process.returncode != 0: + raise HullSourceError("OpenSCAD refused a part of the hull source") + written = target.read_text(encoding="utf-8", errors="replace") if target.exists() else "" + return ("", written + output) if part in ECHO_PARTS else (written, output) + + return run + + +def language_library(executable, library=FACTIONS_DIR, on_start=None): + """The `library` build() reads languages from: faction -> the ECHO text of library/.scad run alone, so a hull + source (or a file beside it) cannot stand in for the faction's own file.""" + def read(faction): + file = library / f"{faction}.scad" + if not LANGUAGE_NAME.fullmatch(faction) or not file.is_file(): + raise HullSourceError("The hull source names a faction that has no language file") + return openscad_runner(executable, file, library=library, on_start=on_start)("lang")[1] + + return read diff --git a/tools/blender/tests/fixtures/hulls/pi-fighter/hull.scad b/tools/blender/tests/fixtures/hulls/pi-fighter/hull.scad new file mode 100644 index 000000000..3af7a1812 --- /dev/null +++ b/tools/blender/tests/fixtures/hulls/pi-fighter/hull.scad @@ -0,0 +1,50 @@ +// Pirate Coalition fighter "Markdown" on a stolen Rossum & Douglas wedge. An agent-built hull source: the donor body +// comes from the Rossum & Douglas language, every addition from the Pirate language. +// part = lang | brief | anchors | rnd: | primer | weld | accent | recess | emit: +include +include +part = "rnd:primary"; + +HULL_FACTION = "pirate"; +HULL_DONOR = "rnd"; +HULL_ROLE = "fighter"; +HULL_LENGTH = [9, 16]; +HULL_TRIANGLES = [300, 6000]; +HULL_READS = ["the stolen R&D wedge is still recognisable (flat dorsal plane, blunt nose, flush engine face)", + "one oversized gun out of proportion to the hull, on an adapted mount", + "a replacement drive that does not match", "bolted salvage plates, an exposed cable run, one stolen sensor pod", + "asymmetric"]; +if (part == "brief") echo(brief = [HULL_FACTION, HULL_DONOR, HULL_ROLE, HULL_LENGTH, HULL_TRIANGLES, HULL_READS]); + +L = 11; WR = 8; WN = 2.4; +module cut() { translate([-WR/2+0.4, L/2-0.6, 0]) rotate([0,0,35]) cube([3.2, 2.6, 3], center=true); } +module wedge() { difference() { rd_wedge(L, WR, WN, 0.55, 1.5); cut(); translate([0, L/2, 0]) rd_engine_bank(5.4, 0.4, 3); translate([0, -L/2+2.2, 0.75]) rd_window(1.4); } } +module stripe() { translate([1.1, 0.5, 0.75]) cube([0.9, L*0.55, 0.04], center=true); } +module donor_hatches() { for (y=[-1.2, 1.4]) translate([-1.4, y, 0.75]) rd_hatch(); } +module gun() { translate([2.3, -0.8, 0.45]) { pi_saddle(1.2, 2.4, 0.5); translate([0, 0, 0.9]) pi_big_gun(9.0, 0.18); } } +module drive() { translate([-2.5, L/2-0.2, 0.15]) { pi_drive(1.05, 2.6); + for (a=[[0.9,-1.4,0.2],[-0.9,-1.4,0.2],[0,-1.4,0.9]]) strut([0,0,0], a, 0.07); } } +module patch() { translate([-WR/2+1.25, L/2-1.25, 0.2]) rotate([0,0,35]) rotate([0,90,0]) pi_plate(0.7, 2.8, 0.12, 0); } +module plates() { translate([1.0, -L/2+1.9, 0.62]) pi_plate(1.4, 1.2, 0.12, 6); translate([-0.6, 2.4, 0.8]) pi_plate(1.8, 1.4, 0.12, -5); } +module welded_engine() { translate([1.8, L/2+0.05, 0]) cube([1.6, 0.12, 0.4], center=true); } +module cables() { pi_cable([[2.0, 0.6, 1.0], [1.0, 1.6, 0.9], [-0.6, 2.8, 0.95], [-1.6, 3.6, 0.85], [-2.3, 4.0, 0.9]]); } +module pod() { translate([-1.4, -2.6, 1.3]) pi_sensor_pod(1.5, 0.32); strut([-1.4, -2.6, 0.7], [-1.4, -2.6, 1.1], 0.07); } +module crew_slash() { translate([-0.2, -1.6, 0.78]) rotate([0,0,-38]) cube([0.55, 4.4, 0.04], center=true); } + +if (part == "rnd:primary") wedge(); +if (part == "rnd:secondary") donor_hatches(); +if (part == "rnd:accent") stripe(); +if (part == "rnd:recess") { translate([0, -L/2+2.2, 0.72]) rd_window(1.3); translate([0, L/2-0.25, 0]) cube([1.6, 0.05, 0.36], center=true); } +if (part == "primer") { patch(); plates(); welded_engine(); } +if (part == "weld") { gun(); drive(); cables(); } +if (part == "accent") { crew_slash(); pod(); } +if (part == "recess") translate([-2.5, L/2-0.2, 0.15]) pi_collar(1.15, 0.15); +if (part == "emit:Th.Main") translate([-2.5, L/2-0.2+1.17, 0.15]) rotate([90,0,0]) cylinder(r=0.95, h=0.05, center=true, $fn=8); +if (part == "emit:Th.L") translate([0, L/2-0.22, 0]) cube([1.2, 0.05, 0.32], center=true); +// Ids are HullData mount ids: the gameplay layout addresses these by name only. +if (part == "anchors") { + echo(anchor=["Gun.P", "weapon-mount", [2.3, -0.8, 1.35], 0, ""]); + echo(anchor=["Gun.P.muzzle", "weapon-muzzle", [2.3, -0.8-0.17*9.0-0.65*9.0-0.4, 1.4], 0, "Gun.P"]); + echo(anchor=["Th.Main", "thruster-emitter", [-2.5, L/2-0.2+1.17, 0.15], 180, ""]); + echo(anchor=["Th.L", "thruster-emitter", [0, L/2-0.22, 0], 180, ""]); +} diff --git a/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad b/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad new file mode 100644 index 000000000..dcd20ec00 --- /dev/null +++ b/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad @@ -0,0 +1,49 @@ +// Zhestokost ammunition tender "Podvoz". An agent-built hull source: composes the Zhestokost language only. +// part = lang | brief | anchors | primary | secondary | accent | recess | emit: | rad: +// Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard. +include +part = "primary"; + +HULL_FACTION = "zhestokost"; +HULL_DONOR = ""; +HULL_ROLE = "tender"; +HULL_LENGTH = [18, 28]; +HULL_TRIANGLES = [300, 6000]; +HULL_READS = ["ammunition magazines carried where a rearming ship can see and reach them", + "a handling boom that brings magazines forward", "towing points", + "one defensive ballistic mount, not a warship battery", "low, broad, heavy, slow"]; +if (part == "brief") echo(brief = [HULL_FACTION, HULL_DONOR, HULL_ROLE, HULL_LENGTH, HULL_TRIANGLES, HULL_READS]); + +L = 15; W = 10; H = 4.2; Y0 = 3; // main mass centred at y=+3 (nose is -Y) +module pockets() { for (s=[-1,1], y=[-1, 4]) translate([s*W/2, Y0+y, 1.3]) cube([1.2, 2.6, 1.4], center=true); } +module body() { difference() { translate([0, Y0, 0]) zh_stepped(L, W, H, 3, 0.10); pockets(); } } +module plates() { for (s=[-1,1], i=[0:3]) translate([s*(W/2+0.05), Y0-5.4+i*3.6, 0.6]) rotate([0,0,90]) rotate([90,0,0]) zh_plate(3.2, 1.0, 0.3); } +module band() { for (s=[-1,1], i=[0:3]) translate([s*(W/2+0.22), Y0-5.4+i*3.6, 0.6]) cube([0.04, 3.2, 0.3], center=true); } +module booms() { for (s=[-1,1]) translate([s*2.6, -5.0, H-0.3]) { zh_boom(13); translate([0, 5.6, -0.9]) cbox([1.4, 1.4, 1.6], 0.12); } } +module magazines() { for (s=[-1,1], i=[0:3]) translate([s*3.75, -10.4+i*1.9, H-1.7]) zh_cassette(1.2, 1.5, 2.0); } +module arms() { for (s=[-1,1], i=[0:3]) translate([s*3.2, -10.4+i*1.9, H-0.45]) cube([1.6, 0.3, 0.3], center=true); } +module bridge() { translate([0, Y0+5.6, H]) { cbox([4.4, 2.6, 1.4], ZH_CH); translate([0, 0.3, 1.2]) cbox([2.8, 1.8, 1.0], ZH_CH); + translate([0, -1.32, 0.35]) cube([3.2, 0.06, 0.3], center=true); } } +module lugs() { for (s=[-1,1], y=[Y0-L/2+0.8, Y0+L/2-1.0]) translate([s*(W/2-1.6), y, H/3+0.2]) zh_tow_lug(); } +module drive() { translate([0, Y0+L/2+0.6, 1.3]) zh_drive_block(6.0, 2.2, 1.6, 2, 1, 1.3); } +module gun() { translate([0, Y0+3.2, H]) zh_gun(2, 0.2, 2.6, 1.6); } +module rad(s) { translate([s*(W/2+1.35), Y0+5.2, 2.0]) cube([2.5, 2.2, 0.12], center=true); } +module recess_fill() { for (s=[-1,1], y=[-1, 4]) translate([s*(W/2-0.55), Y0+y, 1.3]) cube([0.1, 2.5, 1.3], center=true); } + +if (part == "primary") { body(); drive(); } +if (part == "secondary") { plates(); booms(); arms(); lugs(); gun(); bridge(); } +if (part == "accent") { magazines(); band(); } +if (part == "recess") recess_fill(); +if (part == "rad:Ra.L") rad(1); +if (part == "rad:Ra.R") rad(-1); +if (part == "emit:Th.L") translate([ 1.5, Y0+L/2+1.05, 1.3]) cube([1.2, 0.05, 1.2], center=true); +if (part == "emit:Th.R") translate([-1.5, Y0+L/2+1.05, 1.3]) cube([1.2, 0.05, 1.2], center=true); +// Ids are HullData mount ids: the gameplay layout addresses these by name only. +if (part == "anchors") { + echo(anchor=["Gun.D", "weapon-mount", [0, Y0+3.2, H+0.6], 0, ""]); + echo(anchor=["Gun.D.muzzle", "weapon-muzzle", [0, Y0+3.2-0.48-2.6-0.3, H+1.15], 0, "Gun.D"]); + echo(anchor=["Th.L", "thruster-emitter", [ 1.5, Y0+L/2+1.05, 1.3], 180, ""]); + echo(anchor=["Th.R", "thruster-emitter", [-1.5, Y0+L/2+1.05, 1.3], 180, ""]); + echo(anchor=["Ra.L", "radiator-mesh", [ W/2+1.35, Y0+5.2, 2.0], 0, ""]); + echo(anchor=["Ra.R", "radiator-mesh", [-W/2-1.35, Y0+5.2, 2.0], 0, ""]); +} diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py new file mode 100644 index 000000000..cabfddfd8 --- /dev/null +++ b/tools/blender/tests/smoke_hull_source.py @@ -0,0 +1,475 @@ +"""Headless Blender smoke for Build From Hull Source, with the real OpenSCAD and the real faction language files. + + blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_hull_source.py + +Environment: CULTLIB_PACKAGES and SMOKE_GAME_FOLDER as smoke_package.py's header says (the published AetherDb under +ModTools, GameData/Aetheria.cc), msgpack importable, and OpenSCAD 2026.09 or later at SMOKE_OPENSCAD (default: the add-on +preference's default). SMOKE_PID_LOG, when set, receives one line per OpenSCAD process. Each ship is created under +/GameData/Mods/smoke.hull.*, which this smoke removes when it ends. + +For each of the two fixture hulls (tests/fixtures/hulls: the Zhestokost ammunition tender and the Pirate fighter on a +Rossum & Douglas wedge) the smoke makes a ship from a stand-in box (New Ship), authors its gameplay layout separately +(rows named for the hull source's anchors, at cells that have nothing to do with where the model puts its gear), copies +the hull.scad into the package and runs the panel's operators: + + Build: zone meshes with one palette material each, the mount anchors, the emitter and radiator meshes and a collider, + all recorded on the collection by name and fingerprint under Ship Root, each at the pose the hull source gives it + (a mount yawed 90 degrees included), and no layout row changed by Build itself. + A second Build replaces only its own objects: an object the author made, a mesh and an empty, is still there. + Build refuses, naming a rule and no input, a hull source that breaks its language's or brief's rule, and changes nothing. + Build refuses a hull source that redefines or shadows its faction's language (reassigned variable, second echo, a + language file beside the hull), and changes nothing. + Build refuses, and changes nothing, a collection that holds a render mesh it did not make, a copy of a generated zone, + or a generated zone or mount that was edited in place. + Package accepts the built ship (the C# validator), writes ship.glb with a node per anchor, and leaves hull.scad; a + Build after Package still works. + The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package deletes hull.scad in + that one act and says so, and Build then refuses. + A hull that was never built keeps its hull.scad through the same deletion rule. +""" + +import json +import math +import os +import shutil +import struct +import sys +import traceback +from pathlib import Path + +import addon_utils +import bmesh +import bpy + +REPO = Path(__file__).resolve().parents[3] +PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory") +GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb")) +OPENSCAD = os.environ.get("SMOKE_OPENSCAD") +PID_LOG = os.environ.get("SMOKE_PID_LOG") +MODS = GAME / "GameData" / "Mods" +FIXTURES = REPO / "tools" / "blender" / "tests" / "fixtures" / "hulls" +THRUSTER, RADIATOR, BALLISTIC = "3", "6", "10" + +# fixture folder, ship id, stand-in box (x, y) metres and its length in cells, the layout rows (mount id, kind, cell), +# the body zone the collider copies, and the palette each zone must carry +HULLS = ( + ("zh-ammo-tender", "smoke.hull.tender", (10.0, 23.4), 12, + (("Gun.D", BALLISTIC, (2, 3)), ("Th.L", THRUSTER, (0, 0)), ("Th.R", THRUSTER, (1, 0)), + ("Ra.L", RADIATOR, (2, 0)), ("Ra.R", RADIATOR, (3, 0))), + "primary", {"primary": (0.42, 0.43, 0.45), "secondary": (0.20, 0.20, 0.22), "accent": (0.55, 0.22, 0.14), "recess": (0.08, 0.08, 0.09)}), + ("pi-fighter", "smoke.hull.fighter", (8.0, 15.0), 8, + (("Gun.P", BALLISTIC, (3, 3)), ("Th.Main", THRUSTER, (0, 0)), ("Th.L", THRUSTER, (1, 0))), + "rnd.primary", {"rnd.primary": (0.72, 0.74, 0.76), "primer": (0.48, 0.27, 0.22), "weld": (0.70, 0.68, 0.62), + "accent": (0.10, 0.62, 0.60), "rnd.accent": (0.90, 0.89, 0.84)}), +) + + +# per fixture: the variable the hull's language is in, a value that redefines it (every rule loosened), the language file a +# copy of which beside hull.scad would shadow the include, and a colour in it +ATTACKS = { + "zh-ammo-tender": ("ZH_LANG", '["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0, 9], [0, 9]], ["asymmetric", 0.0]]', + "zhestokost.scad", "[0.42, 0.43, 0.45]"), + "pi-fighter": ("PI_LANG", '["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["bilateral", 0.0]]', + "pirate.scad", "[0.48, 0.27, 0.22]"), +} +EXECUTABLE = None # the OpenSCAD the add-on preference names, set by main +hull_source = None # aetheria_ships.hull_source, set by main + + +def require(condition, message): + if not condition: + raise SystemExit(message) + + +def select(obj): + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + + +def run(operator): + try: + return operator(), "" + except RuntimeError as exc: + return {"CANCELLED"}, str(exc) + + +def approx(a, b, tolerance=1e-3): + return all(abs(x - y) <= tolerance for x, y in zip(a, b)) + + +def generated(collection): + """Build's objects, by the record Build keeps on the collection.""" + record = collection.get("aetheria.hull_built") + return {obj.name: obj for obj in collection.all_objects if record is not None and obj.name in record} + + +def layout_rows(scene): + return [(hp.mount_id, hp.kind, hp.armor, hp.firing_arc, hp.footprint) for hp in scene.aetheria_layout.hardpoints] + + +def glb_nodes(path): + data = Path(path).read_bytes() + length, kind = struct.unpack_from(" 0, + f"Zone {zone} is not a mesh under Ship Root") + material = obj.data.materials[0] + base = next(n for n in material.node_tree.nodes if n.type == "BSDF_PRINCIPLED").inputs["Base Color"].default_value + require(approx(tuple(base)[:3], colour), f"Zone {zone} is not its palette colour: {tuple(base)}") + require(not any(p.use_smooth for p in obj.data.polygons), f"Zone {zone} is not flat shaded") + for role, ids in anchors_by_role.items(): + for anchor_id in ids: + obj = objects.get(anchor_id) + require(obj is not None and obj.get("aetheria.role") == role and obj.get("aetheria.id") == anchor_id and obj.parent is not None, + f"Anchor {anchor_id} ({role}) was not built") + colliders = [o for o in objects.values() if o.get("aetheria.role") == "hull-collider"] + require(len(colliders) == 1 and colliders[0].type == "MESH", "Build did not make exactly one collider mesh") + shape = lambda obj: sorted(tuple(round(c, 4) for c in v.co) for v in obj.data.vertices) + require(shape(colliders[0]) == shape(objects[body]), "The collider is not the body zone's mesh") + return objects + + +def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette): + collection, box, package = make_ship(scene, fixture, ship_id, extent, length, rows) + state = scene.aetheria_layout + root = bpy.data.objects[next(o.name for o in collection.all_objects if o.get("aetheria.ship_root"))] + rows_before = layout_rows(scene) + source = package / "hull.scad" + lang_var, lang_value, shadowed, shadow_colour = ATTACKS[fixture] + runner = hull_source.openscad_runner(EXECUTABLE, source) + require(source.is_file(), "The fixture hull.scad was not copied") + require("Build From Hull Source" == bpy.ops.aetheria.build_hull_source.get_rna_type().name, "Build is not registered") + + # Build, and a rule that fails before anything changes. + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"FINISHED"}, f"Build was refused: {message}") + roles = {} + for obj in generated(collection).values(): + role = obj.get("aetheria.role") + if role in ("weapon-mount", "thruster-emitter", "radiator-mesh", "weapon-muzzle"): + roles.setdefault(role, []).append(obj.name) + require(roles.get("weapon-mount") and roles.get("weapon-muzzle") and roles.get("thruster-emitter"), f"Anchors missing: {roles}") + first = check_built(collection, root, body, palette, roles) + for muzzle in roles["weapon-muzzle"]: + mount = first[muzzle].parent + require(mount.get("aetheria.role") == "weapon-mount" and first[muzzle].get("aetheria.order") == 1, "A muzzle is not ordered under its mount") + require(first[muzzle].matrix_world.translation.y < mount.matrix_world.translation.y, "A muzzle is not nose-ward of its mount") + require(layout_rows(scene) == rows_before, "Build changed the layout buffer") + check_poses(collection, root, runner) + if fixture == "zh-ammo-tender": + original = source.read_text(encoding="utf-8") + yawed_mount(scene, box, collection, root, package, source, original, runner) + first = generated(collection) + print(f"PASS {number}.1 {fixture} builds: {len(first)} objects, {sum(len(v) for v in roles.values())} anchors") + + # A second Build replaces only its own objects; the author's own empty and mesh (outside render meshes' reach: a mesh + # is tested separately) stay. + keep = bpy.data.objects.new("author note", None) + collection.objects.link(keep) + keep.parent = root + for obj in first.values(): + obj["smoke.stale"] = True + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"FINISHED"}, f"The second Build was refused: {message}") + second = generated(collection) + require(sorted(second) == sorted(first), "The second Build changed the set of generated objects") + require(not any(obj.get("smoke.stale") for obj in second.values()), "The second Build kept an object of the first") + require(bpy.data.objects.get("author note") is keep and keep.parent == root, "Build removed an object the author made") + require(len([o for o in collection.all_objects if o.name.startswith("Hull Collider")]) == 1, "A second Build left a second collider") + print(f"PASS {number}.2 a second Build replaces only its own objects") + + # Operator art in the collection: Build refuses and changes nothing. + art_mesh = bpy.data.meshes.new("author hull") + art_mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0)], [], [(0, 1, 2)]) + art = bpy.data.objects.new("author hull", art_mesh) + collection.objects.link(art) + art.parent = root + names = sorted(o.name for o in collection.all_objects) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "did not make" in message, f"Build overwrote or ignored operator art: {message!r}") + require(sorted(o.name for o in collection.all_objects) == names, "A refused Build changed objects") + for echoed in (ship_id, str(GAME), "author hull"): + require(echoed not in message, f"The refusal echoes an input ({echoed})") + bpy.data.objects.remove(art) + print(f"PASS {number}.3 Build refuses operator art and changes nothing") + + # A hull source that breaks the brief: the refusal names the rule and nothing changes. + original = source.read_text(encoding="utf-8") + source.write_text(original.replace("HULL_LENGTH = [", "HULL_LENGTH = [200, 300]; UNUSED = ["), encoding="utf-8") + names = sorted(o.name for o in collection.all_objects) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "the length lies inside the brief's range" in message, f"A broken brief was built: {message!r}") + require(sorted(o.name for o in collection.all_objects) == names, "A refused Build changed objects") + for echoed in (ship_id, str(GAME), fixture): + require(echoed not in message, f"The refusal echoes an input ({echoed})") + source.write_text(original, encoding="utf-8") + print(f"PASS {number}.4 Build refuses a hull that breaks its brief") + + # Package: the validator accepts the built ship and the GLB holds a node per anchor. + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"FINISHED"} and "validator accepted" in report, f"Package refused the built ship: {report!r} {message!r}") + require(source.is_file(), "Package deleted a hull source whose hull is still built from it") + nodes = {n.get("name"): n for n in glb_nodes(package / "ship.glb")} + for role, ids in roles.items(): + for anchor_id in ids: + require(anchor_id in nodes, f"The GLB has no node for {anchor_id}") + for zone in palette: + require(zone in nodes, f"The GLB has no mesh node for zone {zone}") + moved = [r for r in (hp for hp in state.hardpoints) if r.mount_id in roles["weapon-mount"]] + print(f"INFO {number}.5 weapon rows after Package (fork package-derives-schematic-from-model): " + f"{[(hp.mount_id, (hp.x, hp.y)) for hp in moved]} (authored at {[r[2] for r in rows if r[1] == BALLISTIC]})") + print(f"PASS {number}.5 Package accepts the built ship and keeps hull.scad") + + # What Package did to the built objects is not an edit: Build still replaces them. + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"FINISHED"}, f"Build after Package was refused: {message!r}") + print(f"PASS {number}.6 Build after Package replaces its own objects") + + # A hull source that redefines or shadows its faction's language: refused, nothing changes. + attacks = [] + for label, change in ( + ("reassigns the language after the include", lambda text: text + f"\n{lang_var} = {lang_value};\n"), + ("echoes a second language", lambda text: text + f'\nif (part == "lang") echo(lang = {lang_value});\n')): + attacks.append((label, lambda change=change: source.write_text(change(original), encoding="utf-8"), lambda: None)) + shadow = package / shadowed + shadow_text = (REPO / "tools" / "blender" / "aetheria_ships" / "factions" / shadowed).read_text(encoding="utf-8") + require(shadow_text.count(shadow_colour) >= 1, "The shadow's colour is not in the language file") + attacks.append(("shadows the language file beside itself", + lambda: shadow.write_text(shadow_text.replace(shadow_colour, "[1, 0, 1]"), encoding="utf-8"), + lambda: shadow.unlink())) + for label, apply, undo in attacks: + apply() + names = sorted(o.name for o in collection.all_objects) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + undo() + source.write_text(original, encoding="utf-8") + require(result == {"CANCELLED"} and "includes its faction's language files" in message, + f"A hull that {label} was built: {message!r}") + require(sorted(o.name for o in collection.all_objects) == names, "A refused Build changed objects") + for echoed in (ship_id, str(GAME), fixture): + require(echoed not in message, f"The refusal echoes an input ({echoed})") + print(f"PASS {number}.7 Build refuses a hull that redefines or shadows its language") + + # An author's copy of a generated zone, and an edit of a generated zone or mount, are the author's: Build refuses + # and leaves them as they are. + zone = bpy.data.objects[body] + piece = zone.copy() + piece.data = zone.data.copy() + piece.name = "my piece" + collection.objects.link(piece) + names = sorted(o.name for o in collection.all_objects) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "did not make" in message, f"Build ignored a copy of a generated zone: {message!r}") + require(sorted(o.name for o in collection.all_objects) == names and bpy.data.objects.get("my piece") is piece, + "Build removed a copy of a generated zone") + mesh = piece.data + bpy.data.objects.remove(piece) + bpy.data.meshes.remove(mesh) + original_vertices = [tuple(v.co) for v in zone.data.vertices] + zone.data.vertices[0].co.z += 5.0 + zone.data.update() + names = sorted(o.name for o in collection.all_objects) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "did not make" in message, f"Build ignored an edited zone: {message!r}") + require(sorted(o.name for o in collection.all_objects) == names and abs(zone.data.vertices[0].co.z - original_vertices[0][2] - 5.0) < 1e-3, + "Build discarded an edited zone") + api = sys.modules["aetheria_ships"] + require(api._retire_hull_source(collection) is False and source.is_file(), + "One edited zone, with the others still Build's, retired hull.scad") + for vertex, co in zip(zone.data.vertices, original_vertices): + vertex.co = co + zone.data.update() + mount = bpy.data.objects[roles["weapon-mount"][0]] + at = tuple(mount.location) + mount.location.x += 1.0 + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and abs(mount.location.x - at[0] - 1.0) < 1e-6, f"Build discarded a moved mount: {message!r}") + mount.location = at + print(f"PASS {number}.8 Build refuses and keeps a copy and an edit of what it made") + + # The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package ships the ship + # and retires hull.scad in that one act; Build is gone with it. + for name in list(generated(collection)): + obj = generated(collection)[name] + if obj.type == "MESH" and "aetheria.role" not in obj: + bpy.data.objects.remove(obj) + art_mesh = bpy.data.meshes.new("author hull") + bm = bmesh.new() + bmesh.ops.create_cube(bm, size=1.0) + bm.to_mesh(art_mesh) + bm.free() + art = bpy.data.objects.new("author hull", art_mesh) + collection.objects.link(art) + art.parent = root + require(any(o.get("aetheria.role") for o in generated(collection).values()), "The scene kept no anchor of Build's") + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"FINISHED"} and "deleted hull.scad" in report, f"Package did not retire the hull source: {report!r} {message!r}") + require(not source.exists(), "hull.scad is still there after the author's art was packaged") + require("aetheria.hull_built" not in collection, "The record of Build's objects outlived hull.scad") + require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and bpy.data.objects.get("author hull") is art, f"Build ran after takeover: {message!r}") + print(f"PASS {number}.9 her art with Build's anchors kept retires hull.scad, with a report; Build then refuses") + return collection + + +def check_poses(collection, root, runner): + """Every built anchor stands where the hull source says in Ship Root's frame, turned as it says, and the emitter and + radiator meshes are the source's meshes in that frame.""" + inverse = root.matrix_world.inverted() + for anchor in hull_source.read_anchors(runner("anchors")[1]): + obj = bpy.data.objects[anchor.id] + local = inverse @ obj.matrix_world + require(approx(tuple(local.translation), anchor.position), f"{anchor.id} stands at {tuple(local.translation)}, not {anchor.position}") + if anchor.role != "weapon-muzzle": + turn = local.to_euler().z + want = math.radians(anchor.yaw) + require(approx((math.cos(turn), math.sin(turn)), (math.cos(want), math.sin(want))), + f"{anchor.id} is turned {math.degrees(turn):.1f} degrees, not {anchor.yaw}") + if anchor.role in hull_source.MESH_ROLES: + made = sorted(tuple(round(c, 3) for c in (inverse @ obj.matrix_world @ v.co)) for v in obj.data.vertices) + wanted = sorted(tuple(round(c, 3) for c in v) for v in hull_source.parse_off(runner(hull_source.MESH_ROLES[anchor.role] + anchor.id)[0]).vertices) + require(len(made) == len(wanted) and all(approx(a, b, 2e-3) for a, b in zip(made, wanted)), + f"The mesh of {anchor.id} is not the source's mesh in Ship Root's frame") + + +def yawed_mount(scene, box, collection, root, package, source, original, runner): + """A mount the source turns 90 degrees stands, turned, where it says, and its muzzle follows it.""" + changed = original.replace('[0, Y0+3.2, H+0.6], 0, ""]', '[0, Y0+3.2, H+0.6], 90, ""]') + require(changed != original, "The tender's mount is not where the pose test turns it") + source.write_text(changed, encoding="utf-8") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"FINISHED"}, f"Build refused a turned mount: {message!r}") + check_poses(collection, root, runner) + require(abs(bpy.data.objects["Gun.D"].rotation_euler.z - math.pi / 2) < 1e-4, "The mount is not turned 90 degrees") + source.write_text(original, encoding="utf-8") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"FINISHED"}, f"Build refused the restored source: {message!r}") + print("PASS 1.10 a turned mount and its muzzle stand where the source puts them") + + +def clear(collection): + """Removes a finished ship's objects: Blender names objects once per file, and anchors are found by name.""" + for obj in list(collection.all_objects if collection else ()): + bpy.data.objects.remove(obj) + + +def never_built(scene): + """A hull.scad whose hull was never built has no art in the collection: the deletion rule keeps it.""" + ship_id = "smoke.hull.unbuilt" + collection, box, package = make_ship(scene, "pi-fighter", ship_id, (8.0, 15.0), 8, ()) + api = sys.modules["aetheria_ships"] + require(api._retire_hull_source(collection) is False and (package / "hull.scad").is_file(), + "A hull that was never built lost its hull.scad") + print("PASS 3 a hull that was never built keeps its hull.scad") + + +def main(): + require(bpy.app.version[:2] == (5, 2), f"This smoke is for Blender 5.2; this is {bpy.app.version_string}") + bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True) + sys.path.insert(0, str(REPO / "tools" / "blender")) + addon_utils.enable("aetheria_ships", default_set=True, handle_error=None) + import aetheria_ships + global hull_source, EXECUTABLE + hull_source = aetheria_ships.hull_source + aetheria_ships._brokkr_cultlib = lambda context: PACKAGES + preferences = bpy.context.preferences.addons["aetheria_ships"].preferences + preferences.game_folder = str(GAME) + if OPENSCAD: + preferences.openscad = OPENSCAD + EXECUTABLE = preferences.openscad + real = aetheria_ships.hull_source.openscad_runner + + def logged(executable, source, **kwargs): + kwargs.pop("on_start", None) + + def on_start(pid): + if PID_LOG: + with open(PID_LOG, "a") as log: + log.write(f"{pid}\n") + return real(executable, source, on_start=on_start, **kwargs) + + aetheria_ships.hull_source.openscad_runner = logged + for _, ship_id, *_ in HULLS: + require(not (MODS / ship_id).exists(), f"{MODS / ship_id} exists; remove it first") + scene = bpy.context.scene + previous = None + for number, (fixture, ship_id, extent, length, rows, body, palette) in enumerate(HULLS, start=1): + clear(previous) + previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette) + clear(previous) + never_built(scene) + + +created = not MODS.exists() +try: + main() +except SystemExit as stop: + print(f"SMOKE_HULL_SOURCE_FAILED: {stop}", flush=True) + code = 1 +except Exception: + traceback.print_exc() + print("SMOKE_HULL_SOURCE_FAILED", flush=True) + code = 1 +else: + print("SMOKE_HULL_SOURCE_OK", flush=True) + code = 0 +finally: + for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"),): + shutil.rmtree(MODS / ship_id, ignore_errors=True) + if created: + shutil.rmtree(MODS, ignore_errors=True) +sys.stdout.flush() +os._exit(code) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py new file mode 100644 index 000000000..a7bd201d2 --- /dev/null +++ b/tools/blender/tests/test_hull_source.py @@ -0,0 +1,552 @@ +"""Blender-free tests for aetheria_ships/hull_source.py, which reads a hull.scad through OpenSCAD and judges it against its +faction's form language. A fake runner answers each part with fixture OFF and ECHO text, so no OpenSCAD runs here; the +real OpenSCAD and the real language files are exercised by smoke_hull_source.py. + + python -m unittest discover -s tools/blender/tests -p "test_hull_source.py" + +hull_source is loaded by path because the package __init__ imports bpy. +""" + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +_spec = importlib.util.spec_from_file_location( + "hull_source", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_source.py") +hs = importlib.util.module_from_spec(_spec) +sys.modules["hull_source"] = hs +_spec.loader.exec_module(hs) + +SECRET = "SECRET-NAME-7" # a value a hull source supplies and no refusal may echo + + +def box(x0, x1, y0, y1, z0, z1): + """An OFF text for an axis-aligned box: 8 vertices, 6 quads (12 triangles once fanned).""" + v = [(x, y, z) for z in (z0, z1) for y in (y0, y1) for x in (x0, x1)] + quads = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)] + rows = ["OFF", "8 6 0"] + [f"{x} {y} {z}" for x, y, z in v] + [f"4 {a} {b} {c} {d}" for a, b, c, d in quads] + return "\n".join(rows) + + +def echo(name, value): + return f"ECHO: {name} = {json.dumps(value)}\n" + + +def language(faction="zhestokost", zones=("primary", "secondary"), body="primary", proportion=((0.25, 0.6), (0.35, 0.75)), + symmetry=("bilateral", 0.97)): + palette = [[z, [0.1 * (i + 1), 0.2, 0.3]] for i, z in enumerate(zones)] + return echo("lang", [faction, palette, body, [1, 0.6, 0.2], [0.5, 0.5, 0.5], [list(p) for p in proportion] if proportion + else [], list(symmetry)]) + + +def brief(faction="zhestokost", donor="", length=(8, 14), triangles=(10, 100)): + return echo("brief", [faction, donor, "tender", list(length), list(triangles), ["a read"]]) + + +class Fixture: + """A hull source as the fake runner sees it: part -> (OFF, ECHO). library holds the ECHO text of each faction's own + language file; the hull's own `lang` echo is the files it includes, unless hull_lang says otherwise.""" + + def __init__(self): + self.library = {"zhestokost": language()} + self.hull_lang = None + # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle nose-ward. + self.parts = { + "brief": ("", brief()), + "primary": (box(-3, 3, -5, 5, 0, 2), ""), + "secondary": (box(-1, 1, -1, 1, 2, 3), ""), + "anchors": ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]) + + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])), + "emit:Th.A": (box(0.5, 1.5, 5, 5.1, 0.5, 1.5), ""), + } + + def runner(self): + return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.parts[part] + + def read_library(self, faction): + if faction not in self.library: + raise hs.HullSourceError("no such language file") + return self.library[faction] + + def build(self): + return hs.build(self.runner(), self.read_library) + + def failed(self): + return [c.name for c in self.build().failed()] + + +class ReadingTests(unittest.TestCase): + def test_off_fans_polygons_into_triangles(self): + mesh = hs.parse_off(box(0, 1, 0, 1, 0, 1)) + self.assertEqual((len(mesh.vertices), len(mesh.triangles)), (8, 12)) + self.assertEqual(mesh.bounds(), ((0, 0, 0), (1, 1, 1))) + + def test_off_accepts_a_header_with_counts_on_the_first_line_and_skips_comments(self): + mesh = hs.parse_off("OFF 3 1 0\n# c\n0 0 0\n1 0 0\n0 1 0\n3 0 1 2\n") + self.assertEqual(mesh.triangles, ((0, 1, 2),)) + + def test_nothing_is_an_empty_mesh_and_a_broken_file_is_refused_without_echoing_it(self): + self.assertEqual(hs.parse_off("").triangles, ()) + with self.assertRaises(hs.HullSourceError) as caught: + hs.parse_off(f"OFF\n3 1 0\n0 0 0\n1 0 0\n0 1 0\n3 0 1 9 {SECRET}\n") + self.assertNotIn(SECRET, str(caught.exception)) + + def test_a_face_naming_the_vertex_one_past_the_last_is_refused(self): + with self.assertRaises(hs.HullSourceError): + hs.parse_off("\n".join(["OFF", "3 1 0", "0 0 0", "1 0 0", "0 1 0", "3 0 1 3"])) + + def test_echoes_are_read_by_name_and_only_plain_values_are_accepted(self): + text = echo("anchor", ["a", 1]) + echo("other", [2]) + echo("anchor", ["b", 2.5e-05]) + self.assertEqual(hs.parse_echoes(text, "anchor"), [["a", 1], ["b", 2.5e-05]]) + with self.assertRaises(hs.HullSourceError) as caught: + hs.parse_echoes(f"ECHO: anchor = [{SECRET}, undef]\n", "anchor") + self.assertNotIn(SECRET, str(caught.exception)) + + def test_a_brief_is_exactly_one_echo_of_six_fields(self): + self.assertEqual(hs.read_brief(brief()).faction, "zhestokost") + for text in ("", brief() + brief(), echo("brief", ["x"])): + with self.assertRaises(hs.HullSourceError): + hs.read_brief(text) + + def test_a_pirate_language_reads_with_no_body_and_no_proportion(self): + pirate = hs.read_languages(language("pirate", ("primer", "weld"), body="", proportion=None, + symmetry=("asymmetric", 0.9)))[0] + self.assertEqual((pirate.body, pirate.proportion, pirate.symmetry), ("", None, ("asymmetric", 0.9))) + + def test_a_language_naming_an_unknown_rule_or_a_body_outside_its_zones_is_refused(self): + with self.assertRaises(hs.HullSourceError): + hs.read_languages(language(symmetry=("mostly", 0.5))) + with self.assertRaises(hs.HullSourceError): + hs.read_languages(language(body="keel")) + + +class MirrorOverlapTests(unittest.TestCase): + @staticmethod + def top(text): + mesh = hs.parse_off(text) + return hs._triangles_xy(mesh) + + def test_a_centred_box_overlaps_its_reflection_completely(self): + self.assertEqual(hs.mirror_overlap(self.top(box(-2, 2, 0, 4, 0, 1))), 1.0) + + def test_nothing_overlaps_itself_completely(self): + self.assertEqual(hs.mirror_overlap([]), 1.0) + + def test_a_box_on_one_side_does_not_overlap_it_at_all(self): + self.assertEqual(hs.mirror_overlap(self.top(box(1, 3, 0, 4, 0, 1))), 0.0) + + def test_a_box_straddling_the_axis_unevenly_overlaps_by_the_shared_part(self): + # x in [-1, 3]: the reflection covers [-3, 1]; shared [-1, 1] = 2 of a union 6 wide + self.assertAlmostEqual(hs.mirror_overlap(self.top(box(-1, 3, 0, 4, 0, 1))), 2 / 6, places=2) + + +class JudgementTests(unittest.TestCase): + def test_a_hull_that_keeps_every_rule_passes_every_check(self): + self.assertEqual(Fixture().failed(), []) + + def test_the_language_decides_proportions_not_the_hull(self): + # The same 6 x 10 x 2 body: inside Zhestokost's ranges, outside a language that wants tall narrow bodies. + fixture = Fixture() + fixture.library["zhestokost"] = language(proportion=((0.8, 1.2), (0.1, 0.3))) + self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"]) + + def test_a_body_too_tall_for_the_language_fails_proportion_and_nothing_else(self): + fixture = Fixture() + fixture.parts["primary"] = (box(-3, 3, -5, 5, 0, 5), "") + self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"]) + + def test_a_body_too_wide_for_its_length_fails_proportion_and_nothing_else(self): + fixture = Fixture() + fixture.parts["primary"] = (box(-6, 6, -5, 5, 0, 4), "") # h/w 0.33 but w/l 1.2 + self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"]) + + def test_every_zone_of_the_language_is_coloured_with_its_own_palette_entry(self): + built = Fixture().build() + self.assertEqual(built.colours, {"primary": (0.1, 0.2, 0.3), "secondary": (0.2, 0.2, 0.3)}) + + def test_a_body_too_narrow_for_the_language_fails_proportion(self): + fixture = Fixture() + fixture.parts["primary"] = (box(-1, 1, -5, 5, 0, 1), "") + self.assertIn("the body's proportions lie inside the language's ranges", fixture.failed()) + + def test_a_bilateral_language_refuses_a_lopsided_hull(self): + fixture = Fixture() + fixture.parts["secondary"] = (box(2, 6, -1, 1, 2, 3), "") + self.assertEqual(fixture.failed(), ["the top view obeys the language's bilateral rule"]) + + def test_a_bilateral_bound_is_met_by_exactly_that_overlap(self): + fixture = Fixture() + fixture.library["zhestokost"] = language(symmetry=("bilateral", 1.0)) + self.assertEqual(fixture.failed(), []) + + def test_an_asymmetric_language_refuses_a_symmetric_hull(self): + fixture = Fixture() + fixture.library["zhestokost"] = language("zhestokost", symmetry=("asymmetric", 0.9)) + self.assertEqual(fixture.failed(), ["the top view obeys the language's asymmetric rule"]) + + def test_an_asymmetric_language_accepts_a_lopsided_hull(self): + fixture = Fixture() + fixture.library["zhestokost"] = language("zhestokost", symmetry=("asymmetric", 0.9)) + fixture.parts["secondary"] = (box(2, 8, -4, 4, 0, 3), "") + self.assertEqual(fixture.failed(), []) + + def test_a_zone_the_language_names_must_have_geometry(self): + fixture = Fixture() + fixture.parts["secondary"] = ("", "") + self.assertEqual(fixture.failed(), ["every zone of the language has geometry"]) + + def test_the_triangle_budget_is_a_range_with_two_ends(self): + for budget in ((10, 20), (1000, 2000)): + fixture = Fixture() + fixture.parts["brief"] = ("", brief(triangles=budget)) + self.assertEqual(fixture.failed(), ["the triangle count lies inside the brief's budget"], budget) + + def test_the_budget_counts_the_triangles_of_the_emitter_meshes_too(self): + # 12 + 12 triangles of zones, 12 of the emitter: a budget of 30 holds without the emitter and not with it + fixture = Fixture() + fixture.parts["brief"] = ("", brief(triangles=(10, 30))) + self.assertEqual(fixture.failed(), ["the triangle count lies inside the brief's budget"]) + + def test_the_length_is_a_range_with_two_ends(self): + for length in ((1, 5), (30, 40)): + fixture = Fixture() + fixture.parts["brief"] = ("", brief(length=length)) + self.assertEqual(fixture.failed(), ["the length lies inside the brief's range"], length) + + MUZZLE = "every weapon-mount has a muzzle nose-ward of it and no muzzle is orphaned" + + def test_a_muzzle_aft_of_its_mount_is_refused(self): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, 1, 3], 0, "Gun.A"]) + + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) + + def test_a_muzzle_level_with_its_mount_is_not_nose_ward(self): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [1, 0, 3], 0, "Gun.A"])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) + + def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) + + def test_a_muzzle_whose_parent_is_not_a_weapon_mount_is_refused(self): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""]) + + echo("anchor", ["Th.A.muzzle", "weapon-muzzle", [1, 2, 1], 0, "Th.A"])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) + + def test_an_emitter_with_no_mesh_is_refused(self): + fixture = Fixture() + fixture.parts["emit:Th.A"] = ("", "") + self.assertEqual(fixture.failed(), ["every emitter and radiator anchor has a mesh"]) + + def test_the_collider_zone_is_the_languages_body(self): + self.assertEqual(Fixture().build().body_zone, "primary") + + +class DonorTests(unittest.TestCase): + """A Pirate hull: the Pirate language has no body and no proportions, so the donor's decide.""" + + @staticmethod + def pirate(donor_proportion=((0.12, 0.35), (0.55, 1.1)), donor="rnd"): + fixture = Fixture() + fixture.parts["brief"] = ("", brief("pirate", donor, length=(8, 14))) + fixture.library = {"pirate": language("pirate", ("primer", "weld"), body="", proportion=None, + symmetry=("asymmetric", 0.9)), + "rnd": language("rnd", ("primary", "secondary"), proportion=donor_proportion)} + for key in ("primary", "secondary"): + del fixture.parts[key] + fixture.parts["rnd:primary"] = (box(-3, 3, -5, 5, 0, 1), "") # h/w 0.17, w/l 0.6 + fixture.parts["rnd:secondary"] = (box(-1, 1, -1, 1, 1, 1.2), "") + fixture.parts["primer"] = (box(2, 4, -4, -2, 0, 1), "") + fixture.parts["weld"] = (box(2, 6, 0, 4, 0, 1), "") + return fixture + + def test_the_donors_proportions_judge_the_body(self): + fixture = self.pirate() + self.assertEqual(fixture.build().body_zone, "rnd:primary") + self.assertEqual(fixture.failed(), []) + self.assertEqual(self.pirate(donor_proportion=((0.5, 0.9), (0.55, 1.1))).failed(), + ["the body's proportions lie inside the language's ranges"]) + + def test_the_donors_zones_are_required_and_coloured_from_the_donor(self): + fixture = self.pirate() + fixture.parts["rnd:secondary"] = ("", "") + self.assertEqual(fixture.failed(), ["every zone of the language has geometry"]) + built = fixture.build() + for zone in ("primary", "secondary"): + self.assertEqual(built.colours[f"rnd:{zone}"], built.languages["rnd"].colour(zone)) + self.assertNotEqual(built.colours["rnd:primary"], built.colours["rnd:secondary"]) + + DONOR = "a donor is named exactly when the language has no proportions of its own" + + def test_a_pirate_hull_that_names_no_donor_is_refused(self): + fixture = self.pirate(donor="") + del fixture.library["rnd"] + for key in ("rnd:primary", "rnd:secondary"): + del fixture.parts[key] + self.assertIn(self.DONOR, fixture.failed()) + + def test_a_hull_whose_language_has_proportions_of_its_own_may_not_name_a_donor(self): + fixture = Fixture() + fixture.parts["brief"] = ("", brief("zhestokost", "rnd")) + fixture.library["rnd"] = language("rnd", ("primary", "secondary")) + fixture.parts["rnd:primary"] = (box(-3, 3, -5, 5, 0, 1), "") + fixture.parts["rnd:secondary"] = (box(-1, 1, -1, 1, 1, 1.2), "") + self.assertIn(self.DONOR, fixture.failed()) + + +class LanguageOwnershipTests(unittest.TestCase): + """The faction's language file owns its palette, proportions and symmetry rule: the hull's own `lang` echo is compared + with it and never judged against.""" + + OWN = "the hull includes its faction's language files and changes none of them" + PROPORTIONS = "the body's proportions lie inside the language's ranges" + + def redefined(self, **language_arguments): + fixture = Fixture() + fixture.hull_lang = language(**language_arguments) + return fixture + + def test_a_hull_that_includes_the_languages_unchanged_passes(self): + self.assertEqual(Fixture().failed(), []) + + def test_a_hull_that_redefines_its_palette_fails_and_is_still_coloured_from_the_file(self): + fixture = Fixture() + fixture.hull_lang = echo("lang", ["zhestokost", [["primary", [1, 0, 1]], ["secondary", [1, 0, 1]]], "primary", + [1, 0.6, 0.2], [0.5, 0.5, 0.5], [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97]]) + self.assertEqual(fixture.failed(), [self.OWN]) + built = fixture.build() + self.assertEqual(built.colours["primary"], built.languages["zhestokost"].colour("primary")) + self.assertNotEqual(built.colours["primary"], (1, 0, 1)) + + def test_a_hull_that_adds_a_zone_to_its_palette_fails(self): + self.assertEqual(self.redefined(zones=("primary", "secondary", "extra")).failed(), [self.OWN]) + + def test_a_hull_that_redefines_the_proportions_fails_and_is_judged_by_the_file(self): + fixture = self.redefined(proportion=((0.0, 9.0), (0.0, 9.0))) + fixture.parts["primary"] = (box(-3, 3, -5, 5, 0, 5), "") # too tall for the file's ranges + self.assertEqual(sorted(fixture.failed()), sorted([self.OWN, self.PROPORTIONS])) + + def test_a_hull_that_redefines_the_symmetry_rule_fails_and_is_judged_by_the_file(self): + fixture = self.redefined(symmetry=("asymmetric", 0.99)) + self.assertEqual(fixture.failed(), [self.OWN]) + fixture.parts["secondary"] = (box(2, 6, -1, 1, 2, 3), "") # lopsided: the file's bilateral rule refuses it + self.assertIn("the top view obeys the language's bilateral rule", fixture.failed()) + + def test_a_second_echo_of_the_language_after_the_first_fails(self): + fixture = Fixture() + fixture.hull_lang = language() + language(symmetry=("asymmetric", 0.99)) + self.assertEqual(fixture.failed(), [self.OWN]) + + def test_the_genuine_language_echoed_after_an_altered_one_does_not_save_the_hull(self): + fixture = Fixture() + fixture.hull_lang = language(symmetry=("asymmetric", 0.99)) + language() + self.assertEqual(fixture.failed(), [self.OWN]) + + def test_a_hull_that_echoes_no_language_or_another_factions_fails(self): + for text in ("", language("rnd")): + fixture = Fixture() + fixture.hull_lang = text + self.assertEqual(fixture.failed(), [self.OWN]) + + def test_a_pirate_hull_must_include_its_donors_language_too(self): + fixture = DonorTests.pirate() + fixture.hull_lang = language("pirate", ("primer", "weld"), body="", proportion=None, symmetry=("asymmetric", 0.9)) + self.assertEqual(fixture.failed(), [self.OWN]) + + def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self): + asked = [] + fixture = DonorTests.pirate() + hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction)) + self.assertEqual(sorted(asked), ["pirate", "rnd"]) + + +class FakeOpenScad: + """Stands in for subprocess.Popen: records each command and environment, writes `off` where -o points, and answers + with `output` and `returncode`.""" + + def __init__(self, returncode=0, off="", output=""): + self.returncode, self.off, self.output, self.calls = returncode, off, output, [] + self.pid = 4242 + + def __call__(self, command, **kwargs): + self.calls.append((command, kwargs.get("env"))) + if self.off: + Path(command[command.index("-o") + 1]).write_text(self.off, encoding="utf-8") + return self + + def communicate(self, timeout=None): + return self.output, None + + def kill(self): + pass + + +class LanguageLibraryTests(unittest.TestCase): + def test_a_faction_name_that_is_not_a_plain_word_or_has_no_file_is_refused_without_a_process(self): + with tempfile.TemporaryDirectory() as folder, mock.patch.object(hs.subprocess, "Popen") as popen: + library = Path(folder) / "languages" + library.mkdir() + (library / "zhestokost.scad").write_text("", encoding="utf-8") + (Path(folder) / "escape.scad").write_text("", encoding="utf-8") # a file outside the library + read = hs.language_library("openscad", library) + for faction in ("../escape", "Zhestokost", "", "nobody", "zhestokost.scad"): + with self.assertRaises(hs.HullSourceError) as caught: + read(faction) + self.assertNotIn("nobody", str(caught.exception)) + popen.assert_not_called() + + def test_the_language_is_read_from_the_factions_own_file_run_alone(self): + with tempfile.TemporaryDirectory() as folder: + library = Path(folder) + (library / "zhestokost.scad").write_text("", encoding="utf-8") + openscad = FakeOpenScad(output=language()) + with mock.patch.object(hs.subprocess, "Popen", openscad): + text = hs.language_library("openscad", library)("zhestokost") + self.assertEqual(hs.read_languages(text)[0].faction, "zhestokost") + command = openscad.calls[0][0] + self.assertEqual(command[-1], str(library / "zhestokost.scad")) + self.assertEqual(command[command.index("-D") + 1], 'part="lang"') + + +class RunnerTests(unittest.TestCase): + def run_part(self, part, openscad, **arguments): + with mock.patch.object(hs.subprocess, "Popen", openscad): + return hs.openscad_runner("scad", "hull.scad", **arguments)(part) + + def test_openscad_is_asked_for_a_full_render_on_the_manifold_backend_of_one_part(self): + openscad = FakeOpenScad() + self.run_part("primary", openscad) + command = openscad.calls[0][0] + self.assertEqual(command[0], "scad") + self.assertEqual(command[command.index("--backend") + 1], "Manifold") + self.assertEqual(command[command.index("--render") + 1], "all") + self.assertEqual(command[command.index("-D") + 1], 'part="primary"') + self.assertEqual(command[-1], "hull.scad") + + def test_the_language_folder_is_what_openscad_searches_for_includes(self): + openscad = FakeOpenScad() + self.run_part("primary", openscad, library=Path("some") / "languages") + self.assertEqual(openscad.calls[0][1]["OPENSCADPATH"], str(Path("some") / "languages")) + + def test_a_part_openscad_fails_to_make_is_refused_without_echoing_its_output(self): + with self.assertRaises(hs.HullSourceError) as caught: + self.run_part("primary", FakeOpenScad(returncode=1, output=SECRET)) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_echo_parts_answer_the_echo_and_geometry_parts_the_off(self): + self.assertEqual(self.run_part("brief", FakeOpenScad(output=brief())), ("", brief())) + self.assertEqual(self.run_part("primary", FakeOpenScad(off=box(0, 1, 0, 1, 0, 1), output="log")), + (box(0, 1, 0, 1, 0, 1), "log")) + + def test_each_process_is_reported_to_on_start(self): + started = [] + self.run_part("primary", FakeOpenScad(), on_start=started.append) + self.assertEqual(started, [4242]) + + +class FingerprintTests(unittest.TestCase): + def test_equal_values_give_equal_digests_and_a_change_gives_another(self): + base = hs.fingerprint("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 3) + self.assertEqual(base, hs.fingerprint("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 3)) + for other in (("b", (1.0, 2.5), [[0.0, 1.0, 2.0]], 3), ("a", (1.0, 2.501), [[0.0, 1.0, 2.0]], 3), + ("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 4), ("a", (1.0, 2.5), [[0.0, 1.0], [2.0]], 3)): + self.assertNotEqual(base, hs.fingerprint(*other)) + + def test_a_list_of_two_empty_lists_is_not_one_list_holding_one(self): + self.assertNotEqual(hs.fingerprint([], []), hs.fingerprint([[]])) + + def test_float_noise_below_five_decimals_and_a_negative_zero_do_not_change_it(self): + self.assertEqual(hs.fingerprint((1.0, 0.0)), hs.fingerprint((1.0 + 1e-8, -0.0))) + self.assertEqual(hs.fingerprint((-1e-9,)), hs.fingerprint((0.0,))) + + +class RefusalTests(unittest.TestCase): + def test_a_source_that_includes_no_language_for_its_faction_is_refused(self): + fixture = Fixture() + fixture.parts["brief"] = ("", brief("nobody")) + with self.assertRaises(hs.HullSourceError): + fixture.build() + + def test_a_named_donor_that_has_no_language_file_is_refused(self): + fixture = Fixture() + fixture.parts["brief"] = ("", brief("zhestokost", "rnd")) + with self.assertRaises(hs.HullSourceError): + fixture.build() + + def test_a_language_file_that_echoes_another_faction_or_not_exactly_one_language_is_refused(self): + for text in (language("rnd"), language() + language(), ""): + fixture = Fixture() + fixture.library["zhestokost"] = text + with self.assertRaises(hs.HullSourceError): + fixture.build() + + def test_anchors_that_repeat_an_id_or_name_an_unknown_role_are_refused_without_echoing_them(self): + for anchors in (echo("anchor", [SECRET, "weapon-mount", [0, 0, 0], 0, ""]) * 2, + echo("anchor", [SECRET, "map-icon", [0, 0, 0], 0, ""])): + fixture = Fixture() + fixture.parts["anchors"] = ("", anchors) + with self.assertRaises(hs.HullSourceError) as caught: + fixture.build() + self.assertNotIn(SECRET, str(caught.exception)) + + def test_a_name_that_is_not_a_string_is_refused_wherever_the_source_gives_one(self): + def refused(fixture): + with self.assertRaises(hs.HullSourceError): + fixture.build() + + for index in range(3): # faction, donor, role + fixture = Fixture() + raw = ["zhestokost", "", "tender", [8, 14], [10, 100], ["a read"]] + raw[index] = 7 + fixture.parts["brief"] = ("", echo("brief", raw)) + refused(fixture) + fixture = Fixture() + fixture.parts["brief"] = ("", echo("brief", ["zhestokost", "", "tender", [8, 14], [10, 100], [7]])) + refused(fixture) + for anchor in ([7, "weapon-mount", [0, 0, 3], 0, ""], ["Gun.A", 7, [0, 0, 3], 0, ""], + ["Gun.A", "weapon-mount", [0, 0, 3], 0, 7]): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", anchor)) + refused(fixture) + for raw in ([7, [["primary", [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]], + ["zhestokost", [[7, [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]], + ["zhestokost", [["primary", [1, 1, 1]]], 7, [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]]): + with self.assertRaises(hs.HullSourceError): + hs.read_languages(echo("lang", raw)) + + def test_a_malformed_anchor_is_refused_without_echoing_it(self): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", [SECRET, "weapon-mount", [0, 0], 0, ""])) + with self.assertRaises(hs.HullSourceError) as caught: + fixture.build() + self.assertNotIn(SECRET, str(caught.exception)) + + def test_check_names_and_details_carry_no_hull_supplied_text(self): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", [SECRET, "weapon-mount", [0, 0, 3], 0, ""])) + fixture.parts["emit:Th.A"] = ("", "") + for check in fixture.build().checks: + self.assertNotIn(SECRET, check.name + check.detail) + + def test_the_module_writes_no_json_and_starts_no_second_process(self): + source = (Path(hs.__file__)).read_text(encoding="utf-8") + for forbidden in ("json.dump", "ThreadPool", "concurrent.futures", "multiprocessing"): + self.assertNotIn(forbidden, source) + self.assertEqual(source.count("subprocess.Popen"), 1) + + +if __name__ == "__main__": + unittest.main()