From ee78b85fd01dc0501a8b049de5007c1ddaa194dd Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:26:02 +0200 Subject: [PATCH 01/15] hull_source: delete the nose-ward muzzle rule; gun facing is HullData's The Blender scene cannot know which way a hardpoint points: HullData's hardpoint rotation owns that (ruling aetheria-release:ruling:gun-facing-is-hulldata). Deletes the forward list and its term in the muzzle check, the two facing tests, and the smoke's nose-ward require. Pairing stays whole. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/hull_source.py | 8 +++----- tools/blender/tests/smoke_hull_source.py | 1 - tools/blender/tests/test_hull_source.py | 17 ++--------------- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index e513a7454..50a932e0d 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -344,13 +344,11 @@ def _checks(hull, own, judge): 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")) + checks.append(Check("every weapon-mount has a muzzle and no muzzle is orphaned", + not (unmuzzled or orphans), + f"{len(unmuzzled)} without a muzzle, {len(orphans)} orphaned")) return checks diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 41ef836a6..c544339ad 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -215,7 +215,6 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett 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": diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index c1045dba0..2a1f737f4 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -57,7 +57,7 @@ class Fixture: 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. + # 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. self.parts = { "brief": ("", brief()), "primary": (box(-3, 3, -5, 5, 0, 2), ""), @@ -221,20 +221,7 @@ class JudgementTests(unittest.TestCase): 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]) + MUZZLE = "every weapon-mount has a muzzle and no muzzle is orphaned" def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self): fixture = Fixture() From 6f12283dc01d46a9dfbcfe7e8dab11f56a23e71e Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:26:15 +0200 Subject: [PATCH 02/15] gravity map: laned-belts mapped and admitted; lane host and half-width cells filled Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-gravity-map.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/aetheria-gravity-map.md b/docs/aetheria-gravity-map.md index d87657cbd..b95ddec91 100644 --- a/docs/aetheria-gravity-map.md +++ b/docs/aetheria-gravity-map.md @@ -131,11 +131,11 @@ Cells not filled, plainly: whether a per-body multiplier on c or the core is eve | **Rock** (`Asteroid`) | (belt key, index) is the `ChunkId`. The index order is fixed at generation and never re-sorted, because wear (`ChunkWear`) is keyed by it. | Persisted (Distance, Phase, Size, RotationSpeed) about the belt's parent; wear and respawn live on Zone. | The generator draws a rock. Zone owns its wear and its pose (`AsteroidBelt.Pose`). | | **Period rule** | A property of the belt, not of the rock: the persisted flag `AsteroidBeltData.RidesRail` (MessagePack key 11, JSON `ridesRail`). False: each rock reads its own distance (`PeriodAround(parent, rock.Distance)`), an annulus. True: every rock takes its rail's period (`Orbits[rail].Period`), so a Trojan swarm keeps its shape (ruling `trojans-ride-host-period-with-stations`; on own-distance rails it shears within an hour, B60). | Persisted at generation. A belt saved before key 11 reads false, the annulus it was (CultCache defaults a missing slot, B61). | Zone's constructor alone writes `AsteroidBelt.Periods`, through PeriodAround or the rail's own Period; the flag only says which. Forbidden: a period formula in Pose, BeltTargets, DrawAhead, the renderer or the generator, and a second flag or shape enum read anywhere else. | | **Extent rule** | What a belt's rail adds to its parent's L_F: `AsteroidBelt.Extent(railDistance)`, the outermost rock for an annulus, the rail's own Distance for a swarm, whose rocks' periods never read their own distance. So a swarm moves no well (B61). An edge (Oort-like) belt must add nothing beyond the window, or the family's fade band narrows (B60). | Derived at zone build. | `AsteroidBelt.Extent`, read by Zone (the only WellNode builder in play) and by the station tests' rebuilt tree. The generator's clearance tree never sees a swarm, which is not a GeneratorPlanet. | -| **Belt host** | A generator-time relation only. A Trojan host is a planet or gas giant (PlanetMass < mass <= SunMass) whose parent is a body, at a distance > 0. A ring member is never a host: its rail's pull adds its partners' (WellTree.PullAround), so a rail copied beside it would take another period and shear (B61). The swarm's rail copies the host's (Parent, Distance, Phase); that copy is all Zone needs, and nothing persists the host. A lane's host waits on laned-belts. | Lives for one GenerateZone call. | ZoneGenerator.GenerateZone's swarm pass. | +| **Belt host** | A generator-time relation only. A Trojan host is a planet or gas giant (PlanetMass < mass <= SunMass) whose parent is a body, at a distance > 0. A ring member is never a host: its rail's pull adds its partners' (WellTree.PullAround), so a rail copied beside it would take another period and shear (B61). The swarm's rail copies the host's (Parent, Distance, Phase); that copy is all Zone needs, and nothing persists the host. A lane's host is a body that keeps its slot with room on both sides of the lane (cut laned-belts: half the eligible bodies, never a Trojan host, one shape per parent). | Lives for one GenerateZone call. | ZoneGenerator.GenerateZone's swarm pass. | | **Lagrange station orbit** | A bodyless OrbitData on a host's rail, 1/6 turn ahead of or behind it (CreateLagrangeOrbit). On a swarm host it sits inside the swarm, a mining outpost among the rocks (ruling `trojans-ride-host-period-with-stations`); rocks are on the floater layer below it, so nothing collides. | Created at generation and persisted. | ZoneGenerator's station placement, unchanged: candidacy reads the GeneratorPlanet tree, where no swarm exists, so no swarm can bar its host. | | **Belt generation settings** | `ZoneGenerationSettings` in Settings.asset (an LFS object): BeltProbability 0.25, BeltMassCeiling 500, AsteroidBeltWidth (a ceiling that never binds, B56), AsteroidCount = sqrt(mass x D) + 11. Trojans: TrojanProbability 0.15 per host, TrojanArc 0.07 turn per swarm, TrojanHalfWidth 0.06 D, TrojanCount = 0.04 D + 8 rocks round each of L4 and L5 (tuning defaults for a flight, B61). The code default of TrojanProbability is 0, so settings built in code draw no swarm. | Authored. | Settings.asset. Forbidden: a tunable shape's numbers as constants in code. The swarm's random stream salt is a ZoneGenerator constant, not a tunable. | -Cells not filled, plainly: the lane's host set and half-width (laned-belts' map proposes the visual margin, ruling `lanes-two-sided-visual-margin`); where an edge cloud's rocks sit between a root's window and the zone boundary (edge-material's map, rulings `edge-material-root-only` and `edge-material-in-the-dark`). The edge material's place in the potential is not a question: it stays out of the extent, because the law breaks otherwise (B60). +Cells not filled, plainly: where an edge cloud's rocks sit between a root's window and the zone boundary (edge-material's map, rulings `edge-material-root-only` and `edge-material-in-the-dark`). The edge material's place in the potential is not a question: it stays out of the extent, because the law breaks otherwise (B60). ## Rationale @@ -180,5 +180,5 @@ Mapped and admitted (2026-10-14, base 11b2d7ad): `aetheria-gravity:cut_spec:cut- - **grid-noise-presentation**: the Sector Waves noise (after grid-lowering on layer 10, drawn only into the nebula patch height) and fine-grid detail move to non-slope presentation (grid-gravity-only-ruled); it also owns the Main Menu's own Surface Displacement brushes (B29, follow-up `menu-grid-brushes`). Waits on grid-lowering. - **top-speed-field**: top speed becomes a hull field; no drag below it, drag on the excess above it; the VelocityLimit clamp (Behaviors/VelocityLimit.cs) and always-on hull drag (Ship.cs:127-129) go; AI pilots brake actively; open: drag shape above the cap, consumables as modifiers, whether loot and wreckage come to rest (aetheria-gravity:ruling:no-drag-below-top-speed, target invariant orbits-do-not-decay). Waits on potential-owner and orbits-from-field. - **belt-shapes** (follow-up `belt-shapes`, ruling `belt-shapes-direction`; B60, B61; order by ruling `trojans-first`). One cut per shape. trojan-swarms is mapped and admitted (2026-10-18, base 67517f40): `aetheria-gravity:cut_spec:cut-trojan-swarms.r1`. - - **laned-belts**: only a body with room on both sides of its lane keeps a laned belt, and the lane's half-width is its own visual margin, not the placement Roche margin (ruling `lanes-two-sided-visual-margin`; 468 eligible at the Roche width, B60). Folds in `belt-after-belt-edge-pin`, in the same Place loop. + - **laned-belts** (mapped and admitted 2026-10-21, base d52b50f9: `aetheria-gravity:cut_spec:cut-laned-belts.r1`; lane half-width 5 body radii, a flight default): only a body with room on both sides of its lane keeps a laned belt, and the lane's half-width is its own visual margin, not the placement Roche margin (ruling `lanes-two-sided-visual-margin`; 468 eligible at the Roche width, B60). Folds in `belt-after-belt-edge-pin`, in the same Place loop. - **edge-material**: one sparse Oort-like cloud per root (per subzone root in a multi-root zone), from its family's window out toward the zone boundary, nothing at planets' rims (ruling `edge-material-root-only`); kept out of the extent (B60) and skipped by the populated radius, so its rocks sit in the dark fade (ruling `edge-material-in-the-dark`). From 51e82624d5c4de502b7955827eb16166fef23bcd Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:26:17 +0200 Subject: [PATCH 03/15] test_hull_source: pin that a muzzle facing any way passes the hull source Muzzles aft of and beside their mounts pass every check, so a reintroduced facing judgement fails a test. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 2a1f737f4..4cfcaf2e8 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -223,6 +223,16 @@ class JudgementTests(unittest.TestCase): MUZZLE = "every weapon-mount has a muzzle and no muzzle is orphaned" + def test_a_muzzle_facing_any_way_is_accepted(self): + # Which way a gun points is HullData's hardpoint rotation; the hull source never judges it. + 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", ["Gun.B", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.B.muzzle", "weapon-muzzle", [1, 0, 3], 0, "Gun.B"]) + + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])) + self.assertEqual(fixture.failed(), []) + 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, ""])) From 9c50b7499b24cee7c0f5a28df30f051f1957039b Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:28:40 +0200 Subject: [PATCH 04/15] FactionProductData.NotOffered: an off-market product is never drawn, stocked, chosen or a gear home One reader, LoadoutGenerator.IsOffered, read by IsAvailable (ahead of the no-faction shortcut), the AvailableProducts candidates (so the any-maker fallback cannot reach one) and SoldHulls. A Loadout that pins the product still builds it. Rulings off-market-is-unoffered-product and boss-hulls-exclusive. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FactionProduct.cs | 6 ++ .../Scripts/ServerShared/LoadoutGenerator.cs | 16 ++- .../ProductChoiceTests.cs | 102 +++++++++++++++++- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/ServerShared/FactionProduct.cs b/Assets/Scripts/ServerShared/FactionProduct.cs index b9a3f0009..14f7af57d 100644 --- a/Assets/Scripts/ServerShared/FactionProduct.cs +++ b/Assets/Scripts/ServerShared/FactionProduct.cs @@ -38,6 +38,12 @@ public class FactionProductData // no Assembly; the mint derives both from the line. [Inspectable, JsonProperty("bin"), Key(7)] public ProductBin Bin; + + // Off the market (ruling off-market-is-unoffered-product): the product keeps its maker, brand, quality and price, but no + // station stocks it, no generation or chooser draws it, and its hull gives no gear a home. It reaches a ship only through a + // Loadout that pins it. LoadoutGenerator.IsOffered is its one reader. + [Inspectable, JsonProperty("notOffered"), Key(8)] + public bool NotOffered; } // How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 2e925a201..0e2d8fb00 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -197,6 +197,8 @@ public class LoadoutGenerator entry.design != null && entry.design.Price > 0 && entry.product.Manufacturer.IsSet() && + // A not-offered product is never a candidate, so the any-manufacturer fallback below cannot reach it either. + IsOffered(entry.product) && (!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) && (filter?.Invoke(entry.design) ?? true)) .ToArray(); @@ -216,9 +218,10 @@ public class LoadoutGenerator // bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station // stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior. // Only a hull some product sells gives gear a home: a hull no faction builds (the Smoke Test Hull) is a test fixture, so its - // hardpoints never put gear on a shelf. The one definition of the hulls HasHome considers. + // hardpoints never put gear on a shelf. An off-market hull gives no gear a home either: only its pinned Loadout flies it. + // The one definition of the hulls HasHome considers. public static HullData[] SoldHulls(CultCache items) => - items.GetAll().Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); + items.GetAll().Where(IsOffered).Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); public static bool HasHome(EquippableItemData design, HullData[] hulls) => design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull || @@ -231,9 +234,14 @@ public class LoadoutGenerator // always reaches its own manufacturer's gear, and otherwise gear made by a manufacturer its allegiance names // (operator, 2026-09-30: allegiance lists only other factions). Whether that manufacturer is present in the galaxy // does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available). + // A not-offered product is available to nobody, a scenario stage included (ruling boss-hulls-exclusive). public bool IsAvailable(FactionProductData product) => - Galaxy == null || Faction == null || - ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer); + IsOffered(product) && ( + Galaxy == null || Faction == null || + ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer)); + + // Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered. + public static bool IsOffered(FactionProductData product) => !product.NotOffered; // 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 diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index 89f262c62..d049e8c05 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -65,8 +65,10 @@ public sealed class ProductChoiceTests : IDisposable 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) + // binFloors adds one bin of lamp-a's line per floor, named "lamp-a bin" and "lamp-a bin 2"..., by the same maker. Every rig also + // holds a Barge, the one hull with a ballistic hardpoint, and a Gun only it mounts, each sold by Alpha. notOffered names the + // products the rig takes off the market. + private Rig Build(float[] binFloors, string[] lampOrder, params string[] notOffered) { var root = Path.Combine(_root, "rig" + _catalogs++); Directory.CreateDirectory(root); @@ -92,10 +94,18 @@ public sealed class ProductChoiceTests : IDisposable Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } }); var lamp = seed.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10 }); + var barge = seed.Upsert(new HullData + { + Name = "Barge", HullType = HullType.Ship, Shape = shape, Price = 100, + Hardpoints = { new HardpointData { Type = HardpointType.Ballistic, Position = HardpointCell, Shape = new Shape() } } + }); + var gun = seed.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Ballistic, Shape = new Shape(), Price = 10 }); void Sell(string name, CultRecordKey design, CultRecordRef maker, string key) => seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData - { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }, new CultRecordKey(key))); + { Name = name, Design = new CultRecordRef(design), Manufacturer = maker, NotOffered = notOffered.Contains(name) }, new CultRecordKey(key))); Sell("Skiff by Alpha", skiff.Key, alpha, "skiff-a"); + Sell("Barge by Alpha", barge.Key, alpha, "barge-a"); + Sell("Gun by Alpha", gun.Key, alpha, "gun-a"); var makers = new Dictionary> { ["lamp-a"] = alpha, ["lamp-z"] = zeta, ["lamp-m"] = mu }; // lampOrder holds product names; the record key of the i-th inserted is "k" + i, so keys and insertion order // both follow the permutation. @@ -367,5 +377,91 @@ public sealed class ProductChoiceTests : IDisposable { Assert.Equal(4, typeof(Loadout).GetField(nameof(Loadout.HullProduct)).GetCustomAttribute().IntKey); Assert.Equal(3, typeof(LoadoutSlot).GetField(nameof(LoadoutSlot.Product)).GetCustomAttribute().IntKey); + Assert.Equal(8, typeof(FactionProductData).GetField(nameof(FactionProductData.NotOffered)).GetCustomAttribute().IntKey); + } + + // Ruling off-market-is-unoffered-product: a not-offered product is never chosen, by a faction that reaches its maker or by a + // stage with no faction, which otherwise reaches every maker (ruling boss-hulls-exclusive). + [Fact] + public void ANotOfferedProductIsNeverChosen() + { + var rig = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-a"); + var lamp = rig.Cache.GetByName("Lamp"); + for (var i = 1; i <= 64; i++) + { + Assert.Equal("lamp-z", rig.Outfitter(null, Seed(i)).ChooseProduct(lamp).Name); + Assert.Equal("lamp-z", rig.Outfitter(rig.OutEven, Seed(i), 0).ChooseProduct(lamp).Name); + // OutA reaches only Alpha, whose lamp is off the market. + Assert.Null(rig.Outfitter(rig.OutA, Seed(i)).ChooseProduct(lamp)); + } + Assert.False(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-a"))); + Assert.True(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-z"))); + } + + // The any-manufacturer fallback of a required draw is closed to off-market products: when every product of the design is + // not offered the draw yields nothing, for a faction that reaches the maker, one that reaches nobody, and no faction. + [Fact] + public void ARequiredDrawNeverFallsBackToANotOfferedProduct() + { + var rig = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-z", "lamp-a"); + var lamp = rig.Cache.GetByName("Lamp"); + foreach (var faction in new[] { rig.OutA, rig.OutNone, null }) + for (var i = 1; i <= 16; i++) + Assert.Null(rig.Outfitter(faction, Seed(i)).RandomProduct(0, design => design == lamp, required: true).product); + + // With one product still offered, a faction that reaches only the other maker falls back to it: the empty draw above is + // the field's doing, not a closed fallback. + var one = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-a"); + var oneLamp = one.Cache.GetByName("Lamp"); + for (var i = 1; i <= 16; i++) + Assert.Equal("lamp-z", one.Outfitter(one.OutA, Seed(i)).RandomProduct(0, design => design == oneLamp, required: true).product.Name); + } + + // A hull sold only off the market is no gear's home: gear only it mounts is never drawn, so no station stocks it. + [Fact] + public void AnOffMarketHullGivesNoGearAHome() + { + var sold = Build("lamp-a"); + var soldGun = sold.Cache.GetByName("Gun"); + Assert.Contains(sold.Cache.GetByName("Barge"), LoadoutGenerator.SoldHulls(sold.Cache)); + Assert.Equal("Gun by Alpha", sold.Outfitter(null, 1).RandomProduct(0, design => design == soldGun, required: true).product.Name); + + var off = Build(new float[0], new[] { "lamp-a" }, "Barge by Alpha"); + var barge = off.Cache.GetByName("Barge"); + var gun = off.Cache.GetByName("Gun"); + var hulls = LoadoutGenerator.SoldHulls(off.Cache); + Assert.DoesNotContain(barge, hulls); + Assert.Contains(off.Cache.GetByName("Skiff"), hulls); + Assert.False(LoadoutGenerator.HasHome(gun, hulls)); + foreach (var faction in new[] { off.OutA, null }) + for (var i = 1; i <= 16; i++) + { + var generator = off.Outfitter(faction, Seed(i)); + Assert.Null(generator.RandomProduct(0, design => design == gun, required: true).product); + Assert.Null(generator.RandomProduct(0, design => design == barge, required: true).product); + } + } + + // A Loadout that pins a not-offered hull product still builds it, under its brand: that is how a boss flies its hull. + [Fact] + public void APinnedNotOfferedHullStillMaterializes() + { + var rig = Build(new float[0], new[] { "lamp-a" }, "Barge by Alpha"); + var fit = new Loadout + { + Name = "Boss barge", + Hull = rig.Cache.RefOf(rig.Cache.GetByName("Barge")), + HullProduct = rig.ProductRef("Barge by Alpha"), + WeaponGroups = new int[0][] + }; + var failures = new List(); + var ship = Materialize(rig, fit, rig.Outfitter(rig.OutA, 1).ChooseProduct, failures); + Assert.Empty(failures); + Assert.Equal("Barge by Alpha", rig.Items.Brand(ship.Hull).Product.Name); + + // Unpinned, the same hull has no product to be built as. + fit.HullProduct = default; + Assert.Null(Materialize(rig, fit, rig.Outfitter(null, 1).ChooseProduct, failures)); + Assert.NotEmpty(failures); } } From 9cedb4efe667dc8efe306a6008a48f359785ceb5 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:28:41 +0200 Subject: [PATCH 05/15] AetherDb ship-authoring product --not-offered writes the package product off the market Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/ShipProductTests.cs | 26 +++++++++++++++++-- tools/AetherDb/ShipAuthoringCommands.cs | 16 +++++++----- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index f6f3bad10..77242916f 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -272,6 +272,8 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM"); RefusedAndUntouched("product", path, "ACM", "--name", " "); RefusedAndUntouched("product", path, "ACM", "--remove"); + RefusedAndUntouched("product", path, "ACM", "--not-offered"); + RefusedAndUntouched("product", path, "ACM", "--name", "--not-offered"); var (code, _, error) = Run("product", path, "ACM", "--name", "Acme Skiff", "--description", "Reliable."); Assert.True(code == 0, error); @@ -283,10 +285,29 @@ public sealed class ShipProductTests : IDisposable Assert.Equal(ShipModCatalog.HullKey("mod.skiff"), product.Design.Key); Assert.Equal(hull.Roles.Select(role => role.Name), product.Roles.Select(role => role.Role)); Assert.Equal("Reliable.", product.Description); + Assert.False(product.NotOffered); + + // --not-offered writes the product off the market (ruling off-market-is-unoffered-product), with or without a description. + var (offCode, offOutput, offError) = Run("product", path, "ACM", "--name", "Acme Boss Skiff", "--not-offered"); + Assert.True(offCode == 0, offError); + Assert.Contains("not offered", offOutput); + var off = Assert.Single(ShipAuthoringStore.Load(path).Products); + Assert.True(off.NotOffered); + Assert.Equal("Acme Boss Skiff", off.Name); + Assert.Null(off.Description); + Assert.Equal(0, Run("product", path, "ACM", "--name", "Acme Boss Skiff", "--description", "Unsold.", "--not-offered").Code); + off = Assert.Single(ShipAuthoringStore.Load(path).Products); + Assert.True(off.NotOffered); + Assert.Equal("Unsold.", off.Description); // The maker by name replaces the product by short name: a maker has one product of the hull. - Assert.Equal(0, Run("product", path, "Acme", "--name", "Renamed").Code); - Assert.Equal("Renamed", Assert.Single(ShipAuthoringStore.Load(path).Products).Name); + // Written again without the flag, it is back on the market. + var (renamedCode, renamedOutput, _) = Run("product", path, "Acme", "--name", "Renamed"); + Assert.Equal(0, renamedCode); + Assert.DoesNotContain("not offered", renamedOutput); + var renamed = Assert.Single(ShipAuthoringStore.Load(path).Products); + Assert.Equal("Renamed", renamed.Name); + Assert.False(renamed.NotOffered); RefusedAndUntouched("price", path, "-1"); RefusedAndUntouched("price", path, "1.5"); @@ -294,6 +315,7 @@ public sealed class ShipProductTests : IDisposable Assert.Equal(0, Run("price", path, "250").Code); Assert.Equal(250, ShipAuthoringStore.Read(path).Hull.Price); + RefusedAndUntouched("product", path, "ACM", "--remove", "--not-offered"); Assert.Equal(0, Run("product", path, "ACM", "--remove").Code); Assert.Empty(ShipAuthoringStore.Load(path).Products); RefusedAndUntouched("product", path, "ACM", "--remove"); diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index 9fd7e690a..b745e3dbf 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -8,7 +8,7 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] | --remove) | price | compose "; + private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | compose "; public static int Run(string[] args) { @@ -93,8 +93,11 @@ public static class ShipAuthoringCommands } if (args[0] == "product" && args.Length >= 4) { - var remove = args.Length == 4 && args[3] == "--remove"; - var named = args.Length >= 5 && args[3] == "--name" && (args.Length == 5 || args.Length == 7 && args[5] == "--description"); + // A trailing --not-offered takes the product off the market (ruling off-market-is-unoffered-product); --remove takes none. + var notOffered = args[args.Length - 1] == "--not-offered"; + var count = notOffered ? args.Length - 1 : args.Length; + var remove = !notOffered && count == 4 && args[3] == "--remove"; + var named = count >= 5 && args[3] == "--name" && (count == 5 || count == 7 && args[5] == "--description"); if (!remove && !named) { Console.Error.WriteLine(Usage); return 1; } var (hull, ship, products) = ShipAuthoringStore.Load(args[1]); var (makerKey, makerName) = ShippedFaction(args[2]); @@ -112,14 +115,15 @@ public static class ShipAuthoringCommands ShipAuthoringStore.WriteProduct(cache, ship.Id, new FactionProductData { Name = args[4], - Description = args.Length == 7 ? args[6] : null, + Description = count == 7 ? args[6] : null, Design = new CultRecordRef(ShipModCatalog.HullKey(ship.Id)), Manufacturer = new CultRecordRef(makerKey), - Roles = hull.Roles.Select(role => new ProductRole { Role = role.Name }).ToList() + Roles = hull.Roles.Select(role => new ProductRole { Role = role.Name }).ToList(), + NotOffered = notOffered }); cache.FlushAsync().Wait(); } - Console.WriteLine($"{ship.Id}: {(remove ? "removed" : "wrote")} the product by {makerName}."); + Console.WriteLine($"{ship.Id}: {(remove ? "removed" : "wrote")} the product by {makerName}{(notOffered ? ", not offered" : "")}."); return 0; } Console.Error.WriteLine(Usage); From 3d005ab5597f58365733223fb3cb0444f5c0f76d Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:30:04 +0200 Subject: [PATCH 06/15] LoadoutGenerator: IsAvailable's off-market comment names no boss term Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/LoadoutGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 0e2d8fb00..671faf678 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -234,7 +234,7 @@ public class LoadoutGenerator // always reaches its own manufacturer's gear, and otherwise gear made by a manufacturer its allegiance names // (operator, 2026-09-30: allegiance lists only other factions). Whether that manufacturer is present in the galaxy // does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available). - // A not-offered product is available to nobody, a scenario stage included (ruling boss-hulls-exclusive). + // A not-offered product is available to nobody, a stage with no faction included. public bool IsAvailable(FactionProductData product) => IsOffered(product) && ( Galaxy == null || Faction == null || From bd5d27c52b0106d678f4723eca7229ecc66b4ebf Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:31:47 +0200 Subject: [PATCH 07/15] test_hull_source: a mount is not paired by another gun's muzzle Kills the surviving and->or mutant in the kept pairing rule's unmuzzled list. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 4cfcaf2e8..362664de7 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -239,6 +239,11 @@ class JudgementTests(unittest.TestCase): 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]) + # Another gun's muzzle does not pair this one. + fixture.parts["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", ["Gun.B", "weapon-mount", [2, 0, 3], 0, ""])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) def test_a_muzzle_whose_parent_is_not_a_weapon_mount_is_refused(self): fixture = Fixture() From 0c6f359cb5a76051cdf944bcbc0c4c855fda8415 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:34:50 +0200 Subject: [PATCH 08/15] ProductLinesCatalogTests: FactionProductData's wire pin covers slot 8, and a record written before it is on the market Co-Authored-By: Claude Opus 5.5 --- .../ProductLinesCatalogTests.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs index 7ce78218f..3bb4ceb60 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs @@ -140,8 +140,8 @@ public sealed class ProductLinesCatalogTests } } - // 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. + // FactionProductData slots 7 and 8 are additive under the same schema id: a bin and NotOffered round-trip, a record written + // before slot 8 existed (an array one element shorter) is on the market, and one written before slot 7 also reads no bin. [Fact] public void BinIsSlotSevenAndACatalogWithoutItReadsNoBin() { @@ -150,12 +150,21 @@ public sealed class ProductLinesCatalogTests MessagePackSerializer.Serialize(new FactionProductData { Name = "Binned", Bin = new ProductBin { Floor = .67f } }, options), options); Assert.Equal(.67f, binned.Bin.Floor); + Assert.True(MessagePackSerializer.Deserialize( + MessagePackSerializer.Serialize(new FactionProductData { Name = "Unsold", NotOffered = true }, options), options).NotOffered); + 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(); + Assert.Equal(0x99, bytes[0]); + Assert.Equal(0xC0, bytes[^2]); + Assert.Equal(0xC2, bytes[^1]); + var beforeNotOffered = new byte[] { 0x98 }.Concat(bytes.Skip(1).Take(bytes.Length - 2)).ToArray(); + var offered = MessagePackSerializer.Deserialize(beforeNotOffered, options); + Assert.Equal("Old", offered.Name); + Assert.False(offered.NotOffered); + var before = new byte[] { 0x97 }.Concat(bytes.Skip(1).Take(bytes.Length - 3)).ToArray(); var old = MessagePackSerializer.Deserialize(before, options); Assert.Equal("Old", old.Name); Assert.Null(old.Bin); + Assert.False(old.NotOffered); } } From aea0ce823d5877511ca60c613d3709881bc7cd26 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:37:59 +0200 Subject: [PATCH 09/15] release map: AH-P1 checks line no longer lists the deleted nose-ward rule Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 88267df37..848ab38f5 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11238,7 +11238,7 @@ PIDs are in `S\out\pids.log`, the first 3404 and the last 20348. It then writes - **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. + 0.0000 m. It has five mount anchors, and every weapon mount has its muzzle (gun facing is HullData's, ruling gun-facing-is-hulldata). - 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 From 749bdb09f5b683f9c3049b0c8a002b71297f7933 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:58:09 +0200 Subject: [PATCH 10/15] ShipProductTests: the product grammar refuses a misnamed flag or a dangling description, and the on-market line is the off-market one without its clause Kills the four Stryker survivors of the --not-offered parse. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipProductTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 77242916f..27e84629f 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -274,6 +274,9 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM", "--remove"); RefusedAndUntouched("product", path, "ACM", "--not-offered"); RefusedAndUntouched("product", path, "ACM", "--name", "--not-offered"); + RefusedAndUntouched("product", path, "ACM", "--title", "X"); + RefusedAndUntouched("product", path, "ACM", "--name", "X", "--description"); + RefusedAndUntouched("product", path, "ACM", "--name", "X", "--note", "Y"); var (code, _, error) = Run("product", path, "ACM", "--name", "Acme Skiff", "--description", "Reliable."); Assert.True(code == 0, error); @@ -305,6 +308,7 @@ public sealed class ShipProductTests : IDisposable var (renamedCode, renamedOutput, _) = Run("product", path, "Acme", "--name", "Renamed"); Assert.Equal(0, renamedCode); Assert.DoesNotContain("not offered", renamedOutput); + Assert.Equal(offOutput.Replace(", not offered", ""), renamedOutput); var renamed = Assert.Single(ShipAuthoringStore.Load(path).Products); Assert.Equal("Renamed", renamed.Name); Assert.False(renamed.NotOffered); From f2cd0368908e1cadce3cee101b965bb1976f14d7 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 22:00:55 +0200 Subject: [PATCH 11/15] ProductChoiceTests: rig bins honour the not-offered list; an off-market bin leaves its line the whole weight, and an off-market line's bin is never drawn or stocked Co-Authored-By: Claude Opus 5.5 --- .../ProductChoiceTests.cs | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index d049e8c05..975772cec 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -111,11 +111,14 @@ public sealed class ProductChoiceTests : IDisposable // 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++) + { + var name = i == 0 ? "lamp-a bin" : "lamp-a bin " + (i + 1); 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, + Name = name, Design = new CultRecordRef(lamp.Key), Manufacturer = alpha, NotOffered = notOffered.Contains(name), Bin = new ProductBin { Line = new CultRecordRef(new CultRecordKey("k" + Array.IndexOf(lampOrder, "lamp-a"))), Floor = binFloors[i] } }, new CultRecordKey("bin" + i))); + } seed.FlushAsync().Wait(); } @@ -398,6 +401,44 @@ public sealed class ProductChoiceTests : IDisposable Assert.True(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-z"))); } + // An off-market bin leaves the share arithmetic (ruling bin-share-by-floor): its line keeps the whole of its maker's preference, + // so taking a bin off the market never shrinks the maker's presence for the design. + [Fact] + public void AnOffMarketBinLeavesItsLineTheWholeWeight() + { + var alone = Build("lamp-z", "lamp-a"); + var off = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" }, "lamp-a bin"); + var preference = alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-a")); + Assert.Equal(preference, off.Outfitter(off.OutEven, 1, 0).ProductWeight(off.Product("lamp-a")), 3); + + var lamp = off.Cache.GetByName("Lamp"); + var draws = Enumerable.Range(1, 600).Select(i => off.Outfitter(off.OutEven, Seed(i)).ChooseProduct(lamp).Name).ToArray(); + Assert.DoesNotContain("lamp-a bin", draws); + var aloneLamp = alone.Cache.GetByName("Lamp"); + var aloneDraws = Enumerable.Range(1, 600).Count(i => alone.Outfitter(alone.OutEven, Seed(i)).ChooseProduct(aloneLamp).Name == "lamp-a"); + Assert.InRange(draws.Count(name => name == "lamp-a"), aloneDraws - 30, aloneDraws + 30); + } + + // Ruling bin-inherits-line-offered: a bin is offered only if both it and its line are. A bin of an off-market line is never chosen, + // never drawn by generation, and never stocked, though the bin itself carries no NotOffered. + [Fact] + public void AnOffMarketLinesBinIsNeverDrawnOrSold() + { + var rig = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" }, "lamp-a"); + Assert.False(rig.Product("lamp-a bin").NotOffered); + var lamp = rig.Cache.GetByName("Lamp"); + for (var i = 1; i <= 64; i++) + { + // OutA reaches only Alpha, whose line and so its bin are off the market. + Assert.Null(rig.Outfitter(rig.OutA, Seed(i)).ChooseProduct(lamp)); + Assert.Equal("lamp-z", rig.Outfitter(null, Seed(i)).ChooseProduct(lamp).Name); + Assert.Equal("lamp-z", rig.Outfitter(rig.OutA, Seed(i)).RandomProduct(0, design => design == lamp, required: true).product.Name); + foreach (var faction in new[] { rig.OutA, rig.OutEven, rig.OutNone, null }) + Assert.DoesNotContain(rig.Outfitter(faction, Seed(i)).RandomProducts(16, 1, null, true), entry => entry.product.Name.StartsWith("lamp-a")); + } + Assert.False(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-a bin"))); + } + // The any-manufacturer fallback of a required draw is closed to off-market products: when every product of the design is // not offered the draw yields nothing, for a faction that reaches the maker, one that reaches nobody, and no faction. [Fact] From f7cdb766cdc1bc22521055f9614a6d192230306f Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 22:01:41 +0200 Subject: [PATCH 12/15] LoadoutGenerator: IsOffered reads a bin's line, and BinShares counts only offered bins, so an off-market bin leaves its line the whole weight Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/LoadoutGenerator.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 671faf678..b75f0d30a 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -198,7 +198,7 @@ public class LoadoutGenerator entry.design.Price > 0 && entry.product.Manufacturer.IsSet() && // A not-offered product is never a candidate, so the any-manufacturer fallback below cannot reach it either. - IsOffered(entry.product) && + IsOffered(ItemManager.ItemData, entry.product) && (!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) && (filter?.Invoke(entry.design) ?? true)) .ToArray(); @@ -221,7 +221,7 @@ public class LoadoutGenerator // hardpoints never put gear on a shelf. An off-market hull gives no gear a home either: only its pinned Loadout flies it. // The one definition of the hulls HasHome considers. public static HullData[] SoldHulls(CultCache items) => - items.GetAll().Where(IsOffered).Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); + items.GetAll().Where(product => IsOffered(items, product)).Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); public static bool HasHome(EquippableItemData design, HullData[] hulls) => design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull || @@ -236,12 +236,14 @@ public class LoadoutGenerator // does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available). // A not-offered product is available to nobody, a stage with no faction included. public bool IsAvailable(FactionProductData product) => - IsOffered(product) && ( + IsOffered(ItemManager.ItemData, product) && ( Galaxy == null || Faction == null || ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer)); - // Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered. - public static bool IsOffered(FactionProductData product) => !product.NotOffered; + // Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered. A bin is + // offered only if both it and its line are (ruling bin-inherits-line-offered); the catalog read guarantees a bin's line exists. + public static bool IsOffered(CultCache items, FactionProductData product) => + !product.NotOffered && (product.Bin == null || !items.Get(product.Bin.Line).NotOffered); // 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 @@ -256,9 +258,10 @@ public class LoadoutGenerator // 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. + // Each line's key to the sum of its offered bins' pass fractions: read from the catalog once per weighting pass, never kept. An + // off-market bin takes no share, so its line keeps the whole weight. private Dictionary BinShares() => ItemManager.ItemData.GetAll() - .Where(product => product.Bin != null) + .Where(product => product.Bin != null && IsOffered(ItemManager.ItemData, product)) .GroupBy(product => product.Bin.Line.Key.Value) .ToDictionary(group => group.Key, group => group.Sum(bin => PassFraction(bin.Bin.Floor))); From a46c0536cc3e86d69f31335784847c7c2be1dffe Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 23:25:26 +0200 Subject: [PATCH 13/15] release map: ProductWeight's line share counts only offered bins Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 848ab38f5..3176c3163 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11034,7 +11034,9 @@ presence for the design does not change when it bins a line. - Lot (unchanged schema): a binned lot records the binned product, and its fills and assembly sit at or above the floor of the line's distributions. - ProductWeight: the maker's preference times a share. A bin's share is its pass fraction `1 - Phi(Bin.Floor)`. A line's - share is 1 minus the sum of the pass fractions of its bins, floored at 0. Any other product's share is 1. Two + share is 1 minus the sum of the pass fractions of its offered bins, floored at 0, so an off-market bin takes nothing + from its line (cut product-not-offered; a bin of an off-market line is itself off-market, ruling + bin-inherits-line-offered). Any other product's share is 1. Two unbinned lines of one design and maker each weigh the full preference, as before. ProductWeight is still the one weight of RandomProducts and ChooseProduct. From 2fe4129777f7b58d2950b3c81a5727f1b1dc7695 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 23:40:26 +0200 Subject: [PATCH 14/15] Zhestokost kit: land the hull-bench kit; zh-ammo-tender fixture rebuilt from Oboz A with one defensive mount factions/zhestokost.scad is the bench draft byte for byte (battered masses, typed mates, five zones with cream marking), its lang comment naming the maker element. The fixture is bench Oboz A plus Gun.D on T[2]'s deck centreline aft of its hatches; the smoke's palette, shadow colour and turned-mount pair follow. Spec: aetheria-release:cut_spec:cut-zh-kit-landing.r1 Co-Authored-By: Claude Opus 5.5 --- .../aetheria_ships/factions/zhestokost.scad | 243 ++++++++++++++---- .../fixtures/hulls/zh-ammo-tender/hull.scad | 113 +++++--- tools/blender/tests/smoke_hull_source.py | 7 +- 3 files changed, 273 insertions(+), 90 deletions(-) diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index 9c4e65bb9..752559e71 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -1,62 +1,209 @@ -// 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. +// Zhestokost form language: the one owner of this faction's part kit, proportions, symmetry rule and palette. +// Operator ruling zhestokost-language-lore-text: "The core design pillars were simply industrial and brutalist ... +// Zhestokost is 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." Lore (Faction Flavor > Zhestokost > Visual +// Identity): low broad stepped masses, thick armour round recessed machinery, apparent weight from deep seams and +// substantial supports rather than clutter; charcoal, iron grey, dull oxide red, small cream serials and safety marks. +// +// Brutalist is not "boxes of uniform weight". The vocabulary here is poured and cast concrete-and-iron: +// * MASSES are battered (walls slope in to the deck and in to the belly from a vertical cast belt at the waist), so +// every mass reads as poured, with a lit deck, a lit upper wall, a dark belt line and a shadowed belly. +// * HIERARCHY: one ship = a narrow CORE plus a few heavy repeated MODULES wider than the core, so the plan is +// castellated (wide-narrow-wide), never a plain rectangle. Modules step up in TIERS. +// * SHADOW GAPS: modules stand apart across deep reveals; the reveal floor is the recess zone, so the gap reads black +// from above even at 48 px. +// * Fittings are few, thick and functional: hatches with heavy hinge bars, collars ringed with lugs, a tow eye. +// +// How a Zhestokost hull is built (a hull composes these modules; nothing here places itself): +// 1. A MASS is a list of sections nose (-Y) to stern (+Y): [y, width, height, base z, deck inset, belly inset] +// (zh_s). Make lists with zh_plan (a core or hull), zh_block (a module), zh_tier (a stepped tier on any mass); +// draw them with zh_mass. Keep the lists: every other part mates to them. +// 2. Every other part is placed ON a mass by a typed mate: zh_on_top, zh_on_wall, zh_on_belt, zh_on_belly, +// zh_on_stern, zh_on_nose. In the mate frame local +Z is the outward normal and the part is sunk ZH_SINK into +// the skin, so nothing floats. Top/belly frames keep local +Y aft; wall/belt frames keep +Y aft with local +X +// pointing down the wall on either side (starboard mates are mirrors of port); stern/nose frames have local +Y down. +// 3. Zone routing: every zh_ module draws only the zone the global `part` names, so a hull writes its assembly once. +// Anchor meshes (zh_rad_vanes, zh_emitter) are not routed: call them inside the hull's rad:/emit: parts under +// the same mate that placed their housing. +// Scale: chamfers are cast-heavy (ZH_CH); fittings have standard sizes (ZH_HATCH, ZH_COLLAR_R, ZH_LUG) shared by +// every hull so the family reads. 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); + +ZH_WL = 0.30; // cast belt: from this fraction of section height ... +ZH_WU = 0.50; // ... to this one; above it the wall batters in to the deck, below it in to the belly +ZH_CH = 0.20; // edge chamfer for fittings +ZH_SINK = 0.05; // mates sink parts this far into the skin +ZH_HATCH = 2.6; // standard magazine hatch (m, square) +ZH_COLLAR_R = 1.05; // standard transfer collar radius +ZH_LUG = 1.0; // standard tow lug scale + +module zh_z(z) { if (part == z) children(); } + +// ==== geometry core: sections, surface points, mates ================================================================= +// A section. bt: deck inset (how far the upper wall batters in), bb: belly inset. Defaults keep the faction's batter. +function zh_s(y, w, h, z0=0, bt=undef, bb=undef) = + [y, w, h, z0, is_undef(bt) ? h*0.26 : bt, is_undef(bb) ? h*0.22 : bb]; +function zh_poly(s) = let(w = s[1]/2, h = s[2], z = s[3], t = s[4], b = s[5]) + [[-(w-b), z], [w-b, z], [w, z+h*ZH_WL], [w, z+h*ZH_WU], [w-t, z+h], [-(w-t), z+h], [-w, z+h*ZH_WU], [-w, z+h*ZH_WL]]; +module _zh_slice(s) translate([0, s[0], 0]) rotate([90, 0, 0]) linear_extrude(0.002, center=true) polygon(zh_poly(s)); + +function _zh_i(secs, y) = let(ix = [for (k = [0:len(secs)-2]) if (secs[k][0] <= y) k]) len(ix) ? ix[len(ix)-1] : 0; +// the mass's section at station y (linear between stations, as the mesh is) +function zh_sec(secs, y) = let(i = _zh_i(secs, y), a = secs[i], b = secs[i+1], + f = max(0, min(1, (y - a[0]) / (b[0] - a[0])))) a + (b - a) * f; +function zh_y0(secs) = secs[0][0]; +function zh_y1(secs) = secs[len(secs)-1][0]; + +// Surface points. u: -1 starboard .. 1 port across the deck or belly; v: 0..1 up a wall or belt; side: 1 port, -1 stbd. +function zh_top(secs, y, u=0) = let(s = zh_sec(secs, y)) [u*(s[1]/2 - s[4]), y, s[3] + s[2]]; +function zh_belly(secs, y, u=0) = let(s = zh_sec(secs, y)) [u*(s[1]/2 - s[5]), y, s[3]]; +function zh_belt(secs, y, side=1, v=0.5) = let(s = zh_sec(secs, y)) [side*s[1]/2, y, s[3] + s[2]*(ZH_WL + (ZH_WU-ZH_WL)*v)]; +function zh_wall(secs, y, side=1, v=0.5) = let(s = zh_sec(secs, y)) [side*(s[1]/2 - s[4]*v), y, s[3] + s[2]*(ZH_WU + (1-ZH_WU)*v)]; +function zh_wall_tilt(secs, y) = let(s = zh_sec(secs, y)) atan2(s[4], s[2]*(1-ZH_WU)); // degrees off vertical +function zh_stern_pt(secs, x=0, v=0.5) = let(s = secs[len(secs)-1]) [x, s[0], s[3] + s[2]*v]; +function zh_nose_pt(secs, x=0, v=0.5) = let(s = secs[0]) [x, s[0], s[3] + s[2]*v]; + +module zh_on_top(secs, y, u=0, sink=ZH_SINK) translate(zh_top(secs, y, u) - [0, 0, sink]) children(); +module zh_on_belly(secs, y, u=0, sink=ZH_SINK) translate(zh_belly(secs, y, u) + [0, 0, sink]) rotate([0, 180, 0]) children(); +module _zh_port(side) { if (side < 0) mirror([1, 0, 0]) children(); else children(); } +module zh_on_belt(secs, y, side=1, v=0.5, sink=ZH_SINK) + _zh_port(side) translate(zh_belt(secs, y, 1, v) - [sink, 0, 0]) rotate([0, 90, 0]) children(); +module zh_on_wall(secs, y, side=1, v=0.5, sink=ZH_SINK) { + t = zh_wall_tilt(secs, y); + _zh_port(side) translate(zh_wall(secs, y, 1, v) - [cos(t), 0, sin(t)]*sink) rotate([0, 90 - t, 0]) children(); } -// 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); +module zh_on_stern(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_stern_pt(secs, x, v) - [0, sink, 0]) rotate([-90, 0, 0]) children(); +module zh_on_nose(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_nose_pt(secs, x, v) + [0, sink, 0]) rotate([90, 0, 0]) rotate([0, 0, 180]) children(); +// world point of a local offset under a mate (for anchors): top, belly, stern frames +function zh_top_at(secs, y, u, o, sink=ZH_SINK) = zh_top(secs, y, u) - [0, 0, sink] + o; +function zh_belly_at(secs, y, u, o, sink=ZH_SINK) = zh_belly(secs, y, u) + [0, 0, sink] + [-o[0], o[1], -o[2]]; +function zh_stern_at(secs, x, v, o, sink=ZH_SINK) = zh_stern_pt(secs, x, v) - [0, sink, 0] + [o[0], o[2], -o[1]]; + +// ==== PRIMARY: masses that make the silhouette ======================================================================== +// Any section list as one faceted poured mass. +module zh_mass(secs, zone="primary") zh_z(zone) for (i = [0:len(secs)-2]) hull() { _zh_slice(secs[i]); _zh_slice(secs[i+1]); } + +// A core or whole hull: bow chamfered in plan and dropped (bow, as a fraction of L), stern a flat face with a cast lip. +// bowW / bowH: nose width and height as fractions of W and H. +function zh_plan(L, W, H, z0=0, y0=0, bow=0.2, bowW=0.55, bowH=0.7, bt=undef, bb=undef) = let(n = y0 - L/2, s = y0 + L/2) + [zh_s(n, W*bowW, H*bowH, z0 + H*(1-bowH)*0.55, bt, bb), zh_s(n + L*bow, W, H, z0, bt, bb), + zh_s(s - 0.35, W, H, z0, bt, bb), zh_s(s, W - 0.5, H - 0.3, z0 + 0.15, bt, bb)]; + +// A heavy repeated module (magazine, reactor block, barracks): chamfered in plan at both ends by ch. +function zh_block(y, l, w, h, z0=0, bt=undef, bb=undef, ch=0.5) = + let(t = is_undef(bt) ? h*0.24 : bt, b = is_undef(bb) ? h*0.2 : bb) + [zh_s(y - l/2, w - 2*ch, h - ch, z0 + ch*0.5, max(0.05, t - ch*0.5), max(0.05, b - ch*0.5)), + zh_s(y - l/2 + ch, w, h, z0, t, b), zh_s(y + l/2 - ch, w, h, z0, t, b), + zh_s(y + l/2, w - 2*ch, h - ch, z0 + ch*0.5, max(0.05, t - ch*0.5), max(0.05, b - ch*0.5))]; + +// A stepped tier poured on top of any mass: inset from the deck edge, ends pulled in by yin, h tall. +function zh_tier(secs, inset=0.6, h=0.7, yin=0.6, bt=undef) = let(n = len(secs)) + [for (i = [0:n-1]) let(s = secs[i], y = s[0] + (i == 0 ? yin : i == n-1 ? -yin : 0), d = zh_sec(secs, y)) + zh_s(y, d[1] - 2*d[4] - 2*inset, h, d[3] + d[2] - 0.02, is_undef(bt) ? h*0.4 : bt, 0)]; + +// ==== SECONDARY: forms that give a hull its individuality ============================================================= +// Low armoured cab (top frame): battered block, a deep cast brow over a black vision slit. Nose is -Y. +module zh_cab(w=3.6, l=3.0, h=1.5, zone="secondary") { + zh_mass(zh_block(0, l, w, h, 0, h*0.32, 0, 0.4), zone); + zh_z(zone) translate([0, -l/2 + 0.05, h*0.74]) cbox([w*0.84, 0.9, 0.34], 0.08); // brow + zh_z("recess") translate([0, -l/2 + 0.32, h*0.5]) cube([w*0.66, 0.6, 0.26], center=true); // slit, in the brow's shadow } -// 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); + +// Squat drive pod (belly frame: local +Z is down, away from the hull): battered block, nozzle mouth on its aft face. +module zh_pod(w=2.0, l=3.2, h=1.4, zone="secondary") { + zh_mass(zh_block(0, l, w, h, 0, h*0.18, h*0.22, 0.35), zone); + zh_z(zone) translate([0, l/2 + 0.08, h*0.5]) difference() { // cast nozzle frame + cbox([w*0.86, 0.36, h*0.78], 0.08); cube([w*0.56, 1, h*0.5], center=true); } + zh_z("recess") translate([0, l/2 - 0.02, h*0.5]) cube([w*0.58, 0.3, h*0.52], 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); +function zh_pod_mouth(w=2.0, l=3.2, h=1.4) = [0, l/2 + 0.14, h*0.5]; +// thruster-emitter mesh at the pod's (or any) nozzle mouth: call under the same mate, translated to zh_pod_mouth +module zh_emitter(w, h) cube([w, 0.05, h], center=true); + +// Transfer collar (belt or wall frame): thick octagonal ring proud of the skin, black bore, guide lugs in oxide. +module zh_collar(r=ZH_COLLAR_R, proud=0.55, lugs=4) { + zh_z("secondary") difference() { + cylinder(r=r, h=proud + ZH_SINK, $fn=8); translate([0, 0, -0.1]) cylinder(r=r*0.6, h=proud + 1, $fn=8); } + zh_z("recess") cylinder(r=r*0.62, h=0.08 + ZH_SINK, $fn=8); + zh_z("accent") for (i = [0:lugs-1]) rotate(i*360/lugs) translate([r + 0.16, 0, (proud + 0.25)/2]) + cube([0.5, 0.6, proud + 0.25 + ZH_SINK], center=true); +} + +// Magazine hatch (any frame, +Z out): oxide slab, two heavy hinge bars and four dogs in charcoal, cream stripe at aft edge. +// dogs=false for a small or secondary hatch (saves ~100 triangles). +module zh_hatch(w=ZH_HATCH, l=ZH_HATCH, t=0.22, dogs=true) { + zh_z("accent") translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.06); + zh_z("secondary") { + for (x = [-1, 1]) translate([x*w*0.27, 0, t + 0.12]) cbox([0.5, l + 0.6, 0.34], 0.07); + if (dogs) for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 + 0.12), sy*l*0.3, t*0.6]) cube([0.36, 0.44, 0.4], center=true); + } + translate([0, l/2 - 0.42, t]) zh_stripes(w*0.36, 0.5, dogs ? 3 : 2); +} + +// Armour plate (any frame): replaceable slab with four bolt bosses; leaves a seam to its neighbour. +module zh_plate(w, l, t=0.3, zone="secondary") zh_z(zone) { + translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.07); + for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 - 0.25), sy*(l/2 - 0.25), t + 0.05]) cube([0.22, 0.22, 0.12], center=true); +} + +// Radiator trough (top frame): cast frame round a black floor, split by a spine wall; vanes stand in it (zh_rad_vanes). +module zh_rad_trough(w=3.2, l=2.4, d=0.45, zone="secondary") { + zh_z(zone) translate([0, 0, d/2]) difference() { + cbox([w + 0.7, l + 0.7, d + ZH_SINK], 0.1); cube([w, l, d*3], center=true); } + zh_z(zone) translate([0, 0, d*0.6]) cube([0.3, l, d*1.2 + ZH_SINK], center=true); + zh_z("recess") translate([0, 0, 0.04]) cube([w, l, 0.08 + ZH_SINK], center=true); +} +// radiator-mesh for one half of a trough (side 1 port, -1 starboard): transverse vanes rising out of it +module zh_rad_vanes(w=3.2, l=2.4, side=1, n=5, h=1.0) { + for (i = [0:n-1]) translate([side*(w/4 + 0.08), -l/2 + 0.2 + i*(l - 0.4)/(n-1), h/2]) cube([w/2 - 0.36, 0.14, h], center=true); +} +function zh_rad_centre(w=3.2, side=1, h=1.0) = [side*(w/4 + 0.08), 0, h/2]; + +// Tow lug (stern frame: local +Z aft, local +Y down): a cast block, a neck, a heavy oxide eye with a vertical pin hole. +module zh_tow_lug(s=ZH_LUG, zone="secondary") { + zh_z(zone) { translate([0, 0, 0.4*s]) cbox([1.8*s, 1.3*s, 0.8*s + ZH_SINK], 0.16*s); + translate([0, 0, 1.05*s]) cbox([1.0*s, 0.8*s, 0.6*s], 0.1*s); } + zh_z("accent") translate([0, 0, 1.75*s]) rotate([90, 0, 0]) difference() { + cylinder(r=0.72*s, h=0.55*s, center=true, $fn=8); cylinder(r=0.3*s, h=2*s, center=true, $fn=8); } +} + +// Ballistic mount (top frame): octagonal ring, battered mantlet block, n thick barrels with box brakes, pointing -Y. +module zh_gun(n=2, cal=0.22, len=3.0, base=1.8, zone="secondary") { + zh_z(zone) cylinder(r=base*0.56, h=0.35 + ZH_SINK, $fn=8); + zh_mass([for (s = zh_block(0, base*0.95, base, base*0.5, 0.33, base*0.14, 0, 0.22)) s], zone); + zh_z(zone) for (i = [0:n-1]) translate([(i - (n-1)/2)*cal*3.2, -base*0.4, 0.33 + base*0.25]) { + 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); - } +function zh_gun_muzzle(n=2, cal=0.22, len=3.0, base=1.8) = [0, -base*0.4 - len - cal*1.75, 0.33 + base*0.25]; + +// ==== TERTIARY: detail =============================================================================================== +// Shadow-gap floor (top frame): the black bottom of a reveal between modules. +module zh_reveal(w, l) zh_z("recess") translate([0, 0, 0.03]) cube([w, l, 0.06 + ZH_SINK], center=true); +// Cream safety chevrons (any frame, +Z out): n diagonal bars clipped to w x l. +module zh_stripes(w, l, n=3) zh_z("marking") intersection() { + translate([0, 0, 0.04]) cube([w, l, 0.1], center=true); + for (i = [0:n-1]) translate([(i - (n-1)/2)*w/n, 0, 0.04]) rotate([0, 0, 45]) cube([w/(n*2.2), l*3, 0.12], center=true); } +// Cream block serial (any frame, +Z out; reads along local Y): stencil bars of a unit number. +module zh_serial(h=0.7, n=4) zh_z("marking") for (i = [0:n-1]) + translate([0, (i - (n-1)/2)*h*0.75, 0.03]) cube([h*(i % 2 == 0 ? 1 : 0.6), h*0.42, 0.08], 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 +// ==== palette ======================================================================================================== +// Value carries the 48 px read on dark space, within the lore palette (operator: solved within the palette): iron grey +// masses are the LIGHT value, charcoal fittings the mid-dark, reveals near black, oxide red the one hue, and the lore's +// cream serials/safety marks are the brightest small value. Five zones become livery mask channels. +ZH_ZONES = [["primary", [0.47, 0.48, 0.50]], // iron grey: poured masses (core, modules, tiers) + ["secondary", [0.21, 0.21, 0.23]], // charcoal: cab, pods, collars, hinge bars, troughs, plates, lugs + ["accent", [0.56, 0.20, 0.12]], // dull oxide red: hatches, collar guide lugs, tow eye + ["recess", [0.05, 0.05, 0.06]], // shadow gaps, bores, slits, nozzle mouths + ["marking", [0.86, 0.80, 0.64]]]; // cream: serials and safety chevrons ZH_GLOW = [1.0, 0.62, 0.25]; -ZH_RADIATOR = [0.50, 0.51, 0.52]; +ZH_RADIATOR = [0.56, 0.57, 0.58]; // [faction, zones, body zone, glow, radiator, [height/width range, width/length range], symmetry rule, maker] // maker: the Zhestokost Faction record key in GameData/Aetheria.cc, the Manufacturer of a product whose hull speaks this language -ZH_LANG = ["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97], +// Low and broad: a body never taller than ~0.7 of its width; wide-for-length, as a tracked vehicle rather than a dart. +ZH_LANG = ["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0.22, 0.7], [0.32, 0.8]], ["bilateral", 0.96], "16bad4f9-7fb5-41c5-8da0-dc76709eb7d3"]; if (part == "lang") echo(lang = ZH_LANG); diff --git a/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad b/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad index dcd20ec00..b4214d0fc 100644 --- a/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad +++ b/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad @@ -1,5 +1,6 @@ -// Zhestokost ammunition tender "Podvoz". An agent-built hull source: composes the Zhestokost language only. -// part = lang | brief | anchors | primary | secondary | accent | recess | emit: | rad: +// Zhestokost ammunition tender "Oboz" (baggage train): smoke fixture, from the hull bench Oboz A. Built only from the +// Zhestokost kit. +// part = lang | brief | anchors | primary | secondary | accent | recess | marking | 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"; @@ -7,43 +8,77 @@ 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"]; +HULL_LENGTH = [20, 28]; +HULL_TRIANGLES = [600, 7000]; +HULL_READS = ["the hull is the magazine: three huge stepped magazine blocks in a row on the upper half, oxide hatches with heavy hinge bars", + "two thick round transfer collars set flush in each flank at hull height, ringed by guide lugs", + "heavy towing lug jutting from the stern plate; short row of open radiator vanes on the spine behind the cab", + "low, broad, heavy; narrow cockpit slit in a low armoured block at the bow; four squat drive pods low at the belly corners", + "one defensive ballistic mount on the last magazine deck, not a warship battery"]; 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, ""]); +// Tow block (stern frame: local +Z aft, local +Y down): the kit lug's cast base and oxide eye with no neck, so the eye +// is one heavy block jutting straight off the stern plate, pin hole vertical. +module oz_tow_block(w=2.6, h=1.5, d=1.5) { + zh_z("secondary") translate([0, 0, 0.25]) cbox([w + 0.8, h + 0.6, 0.5 + ZH_SINK], 0.12); + zh_z("accent") translate([0, 0, 0.5 + d/2 - 0.05]) difference() { + cbox([w, h, d], 0.25); rotate([90, 0, 0]) cylinder(r=0.42, h=h*2, center=true, $fn=8); } +} + +// ---- masses: one full-width charcoal belt the whole length; three iron magazine blocks seated on it, stepping up ---- +// bow to stern across deep seams. The belt carries the collars, pods, cab and tow lug; the blocks carry the hatches. +C = zh_plan(23.6, 12.4, 3.0, z0=-1.8, y0=0.8, bow=0.1, bowW=0.62, bowH=0.75, bt=0.35, bb=0.7); +MY = [-3.9, 2.7, 9.3]; // magazine stations; 0.6 m seams between them +MH = [3.0, 3.6, 4.2]; // the stepped magazine train: each block taller than the one ahead +MZ = 1.0; // block base, sunk 0.2 m into the belt deck +B = [for (i = [0:2]) zh_block(MY[i], 6.0, 11.8, MH[i], MZ, MH[i]*0.32, 0.3, 0.5)]; +T = [for (b = B) zh_tier(b, 0.4, 0.7, 0.6)]; +SEAMS = [(MY[0] + MY[1]) / 2, (MY[1] + MY[2]) / 2]; +CAB_Y = -9.5; RAD_Y = -7.9; RAD_W = 3.4; RAD_L = 0.9; +POD = [2.6, 3.4, 1.6]; POD_U = 0.82; +PODS = [MY[0] - 1.4, MY[2] + 1.5]; // fore pair under the first block; aft pair mouths at the stern +COLLAR_R = 1.35; +GUN_Y = 11.0; GUN = [1, 0.2, 2.4, 1.3]; // defensive mount: T[2] deck centreline, aft of its hatches (they end at 10.55) + +zh_mass(C, "secondary"); +for (b = B) zh_mass(b); +for (t = T) zh_mass(t); + +// magazine hatches: two on each tier deck, one on each flank wall; unit serials on the block belts +for (i = [0:2]) { + for (u = [-0.5, 0.5]) zh_on_top(T[i], MY[i], u) zh_hatch(2.5, 2.5); + for (s = [-1, 1]) { + zh_on_wall(B[i], MY[i] + 0.6, s, 0.4) zh_hatch(2.4, 2.4, dogs=false); + zh_on_belt(B[i], MY[i] - 1.9, s, 0.5) zh_serial(0.5, 3); + } +} +// seams between the blocks: black floors on the belt deck; a transfer collar in each belt flank under each seam +for (y = SEAMS) { + zh_on_top(C, y, 0) zh_reveal(11.0, 0.6); + for (s = [-1, 1]) zh_on_belt(C, y, s, 0.5) rotate([0, 0, 45]) zh_collar(COLLAR_R, 0.6); +} +// bow: low lit armoured cab with its slit, chevrons on the nose deck; spine radiator trough between cab and magazines +zh_on_top(C, CAB_Y, 0) zh_cab(6.4, 2.4, 1.2); +zh_on_top(C, -10.6, 0) zh_stripes(2.6, 0.4, 4); +zh_on_top(C, RAD_Y, 0) zh_rad_trough(RAD_W, RAD_L, 0.45); +// stern: iron armour plate and a heavy tow lug through it +zh_on_stern(C, 0, 0.55) zh_plate(6.4, 2.2, 0.3); +zh_on_stern(C, 0, 0.5, sink=-0.25) oz_tow_block(); +// four squat drive pods at the belly corners of the belt +for (y = PODS, u = [-POD_U, POD_U]) zh_on_belly(C, y, u) zh_pod(POD[0], POD[1], POD[2]); +// one defensive ballistic mount on the last magazine deck, between and aft of its hatches, barrels to the nose +zh_on_top(T[2], GUN_Y, 0) zh_gun(GUN[0], GUN[1], GUN[2], GUN[3]); + +// ---- anchor meshes ---- +if (part == "rad:Ra.L") zh_on_top(C, RAD_Y, 0) zh_rad_vanes(RAD_W, RAD_L, 1, 4, 1.0); +if (part == "rad:Ra.R") zh_on_top(C, RAD_Y, 0) zh_rad_vanes(RAD_W, RAD_L, -1, 4, 1.0); +if (part == "emit:Th.L") zh_on_belly(C, PODS[1], POD_U) translate(zh_pod_mouth(POD[0], POD[1], POD[2])) zh_emitter(1.1, 0.65); +if (part == "emit:Th.R") zh_on_belly(C, PODS[1], -POD_U) translate(zh_pod_mouth(POD[0], POD[1], POD[2])) zh_emitter(1.1, 0.65); +if (part == "anchors") { + echo(anchor=["Gun.D", "weapon-mount", zh_top_at(T[2], GUN_Y, 0, [0, 0, 0.33]), 0, ""]); + echo(anchor=["Gun.D.muzzle", "weapon-muzzle", zh_top_at(T[2], GUN_Y, 0, zh_gun_muzzle(GUN[0], GUN[1], GUN[2], GUN[3])), 0, "Gun.D"]); + echo(anchor=["Th.L", "thruster-emitter", zh_belly_at(C, PODS[1], POD_U, zh_pod_mouth(POD[0], POD[1], POD[2])), 180, ""]); + echo(anchor=["Th.R", "thruster-emitter", zh_belly_at(C, PODS[1], -POD_U, zh_pod_mouth(POD[0], POD[1], POD[2])), 180, ""]); + echo(anchor=["Ra.L", "radiator-mesh", zh_top_at(C, RAD_Y, 0, zh_rad_centre(RAD_W, 1, 1.0)), 0, ""]); + echo(anchor=["Ra.R", "radiator-mesh", zh_top_at(C, RAD_Y, 0, zh_rad_centre(RAD_W, -1, 1.0)), 0, ""]); } diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index c544339ad..8bb9b9c48 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -71,7 +71,8 @@ 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)}, + "primary", {"primary": (0.47, 0.48, 0.50), "secondary": (0.21, 0.21, 0.23), "accent": (0.56, 0.20, 0.12), "recess": (0.05, 0.05, 0.06), + "marking": (0.86, 0.80, 0.64)}, "Zhestokost", "Pirates"), ("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))), @@ -86,7 +87,7 @@ HULLS = ( # language file names, read by the production reader (language_maker), so a language edited alone cannot leave it stale ATTACKS = { "zh-ammo-tender": ("ZH_LANG", '["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0, 9], [0, 9]], ["asymmetric", 0.0], "%MAKER%"]', - "zhestokost.scad", "[0.42, 0.43, 0.45]"), + "zhestokost.scad", "[0.47, 0.48, 0.50]"), "pi-fighter": ("PI_LANG", '["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["bilateral", 0.0], "%MAKER%"]', "pirate.scad", "[0.48, 0.27, 0.22]"), } @@ -542,7 +543,7 @@ def check_poses(collection, root, runner): 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, ""]') + changed = original.replace('[0, 0, 0.33]), 0, ""]', '[0, 0, 0.33]), 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) From ad05486388d469daecb1fa6018f3d5a7516c9316 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 23:41:15 +0200 Subject: [PATCH 15/15] Zhestokost kit: zh_z keys a donor's zones 'zhestokost:' when HULL_DONOR is zhestokost zh_key in ewanhart.scad's form, not AU's either-name match: a Pirate hull's own zone named like a Zhestokost zone no longer receives Zhestokost geometry. Closes the gap in follow-up zhestokost-kit-donor-routing. Spec: aetheria-release:cut_spec:cut-zh-kit-landing.r1 Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/factions/zhestokost.scad | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index 752559e71..e077351ee 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -23,6 +23,7 @@ // the skin, so nothing floats. Top/belly frames keep local +Y aft; wall/belt frames keep +Y aft with local +X // pointing down the wall on either side (starboard mates are mirrors of port); stern/nose frames have local +Y down. // 3. Zone routing: every zh_ module draws only the zone the global `part` names, so a hull writes its assembly once. +// A hull on a Zhestokost donor (HULL_DONOR == "zhestokost") names a donor zone 'zhestokost:'. // Anchor meshes (zh_rad_vanes, zh_emitter) are not routed: call them inside the hull's rad:/emit: parts under // the same mate that placed their housing. // Scale: chamfers are cast-heavy (ZH_CH); fittings have standard sizes (ZH_HATCH, ZH_COLLAR_R, ZH_LUG) shared by @@ -37,7 +38,8 @@ ZH_HATCH = 2.6; // standard magazine hatch (m, square) ZH_COLLAR_R = 1.05; // standard transfer collar radius ZH_LUG = 1.0; // standard tow lug scale -module zh_z(z) { if (part == z) children(); } +function zh_key(z) = (!is_undef(HULL_DONOR) && HULL_DONOR == "zhestokost") ? str("zhestokost:", z) : z; +module zh_z(z) { if (part == zh_key(z)) children(); } // ==== geometry core: sections, surface points, mates ================================================================= // A section. bt: deck inset (how far the upper wall batters in), bb: belly inset. Defaults keep the faction's batter.