Merge remote-tracking branch 'origin/master' into eureka/aetheria-arena-determinism-ledger

This commit is contained in:
soul 2026-10-22 00:01:30 +02:00
commit 3efc8cddd7
13 changed files with 513 additions and 134 deletions

View file

@ -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

View file

@ -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(ItemManager.ItemData, 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<FactionProductData>().Select(product => items.Get(product.Design)).OfType<HullData>().Distinct().ToArray();
items.GetAll<FactionProductData>().Where(product => IsOffered(items, product)).Select(product => items.Get(product.Design)).OfType<HullData>().Distinct().ToArray();
public static bool HasHome(EquippableItemData design, HullData[] hulls) =>
design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull ||
@ -231,9 +234,16 @@ 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 stage with no faction included.
public bool IsAvailable(FactionProductData product) =>
Galaxy == null || Faction == null ||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer);
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. 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
@ -248,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<string, float> BinShares() => ItemManager.ItemData.GetAll<FactionProductData>()
.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)));

View file

@ -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`).

View file

@ -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.
@ -11238,7 +11240,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\<id>\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

View file

@ -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,20 +94,31 @@ 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<Faction> maker, string key) =>
seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData
{ Name = name, Design = new CultRecordRef<CraftedItemData>(design), Manufacturer = maker }, new CultRecordKey(key)));
{ Name = name, Design = new CultRecordRef<CraftedItemData>(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<string, CultRecordRef<Faction>> { ["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.
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<CraftedItemData>(lamp.Key), Manufacturer = alpha,
Name = name, Design = new CultRecordRef<CraftedItemData>(lamp.Key), Manufacturer = alpha, NotOffered = notOffered.Contains(name),
Bin = new ProductBin { Line = new CultRecordRef<FactionProductData>(new CultRecordKey("k" + Array.IndexOf(lampOrder, "lamp-a"))), Floor = binFloors[i] }
}, new CultRecordKey("bin" + i)));
}
seed.FlushAsync().Wait();
}
@ -367,5 +380,129 @@ public sealed class ProductChoiceTests : IDisposable
{
Assert.Equal(4, typeof(Loadout).GetField(nameof(Loadout.HullProduct)).GetCustomAttribute<KeyAttribute>().IntKey);
Assert.Equal(3, typeof(LoadoutSlot).GetField(nameof(LoadoutSlot.Product)).GetCustomAttribute<KeyAttribute>().IntKey);
Assert.Equal(8, typeof(FactionProductData).GetField(nameof(FactionProductData.NotOffered)).GetCustomAttribute<KeyAttribute>().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<GearData>("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")));
}
// 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<GearData>("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<GearData>("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<GearData>("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<EquippableItemData>(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<CraftedItemData>(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]
public void ARequiredDrawNeverFallsBackToANotOfferedProduct()
{
var rig = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-z", "lamp-a");
var lamp = rig.Cache.GetByName<GearData>("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<EquippableItemData>(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<GearData>("Lamp");
for (var i = 1; i <= 16; i++)
Assert.Equal("lamp-z", one.Outfitter(one.OutA, Seed(i)).RandomProduct<EquippableItemData>(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<GearData>("Gun");
Assert.Contains(sold.Cache.GetByName<HullData>("Barge"), LoadoutGenerator.SoldHulls(sold.Cache));
Assert.Equal("Gun by Alpha", sold.Outfitter(null, 1).RandomProduct<EquippableItemData>(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<HullData>("Barge");
var gun = off.Cache.GetByName<GearData>("Gun");
var hulls = LoadoutGenerator.SoldHulls(off.Cache);
Assert.DoesNotContain(barge, hulls);
Assert.Contains(off.Cache.GetByName<HullData>("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<EquippableItemData>(0, design => design == gun, required: true).product);
Assert.Null(generator.RandomProduct<HullData>(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<HullData>("Barge")),
HullProduct = rig.ProductRef("Barge by Alpha"),
WeaponGroups = new int[0][]
};
var failures = new List<string>();
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);
}
}

View file

@ -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<FactionProductData>(
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<FactionProductData>(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<FactionProductData>(before, options);
Assert.Equal("Old", old.Name);
Assert.Null(old.Bin);
Assert.False(old.NotOffered);
}
}

View file

@ -272,6 +272,11 @@ 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");
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);
@ -283,10 +288,30 @@ 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);
Assert.Equal(offOutput.Replace(", 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 +319,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");

View file

@ -8,7 +8,7 @@ using System.Linq;
// Uses the existing database executable; mod authoring does not earn another binary target.
public static class ShipAuthoringCommands
{
private const string Usage = "ship-authoring create <ship.cc> <stable-id> <name> [--like <shipped hull name>] | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] | --remove) | price <ship.cc> <price> | compose <shipped.cc> <derived.cc> <mods-dir>";
private const string Usage = "ship-authoring create <ship.cc> <stable-id> <name> [--like <shipped hull name>] | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] [--not-offered] | --remove) | price <ship.cc> <price> | compose <shipped.cc> <derived.cc> <mods-dir>";
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<CraftedItemData>(ShipModCatalog.HullKey(ship.Id)),
Manufacturer = new CultRecordRef<Faction>(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);

View file

@ -1,62 +1,211 @@
// 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.
// A hull on a Zhestokost donor (HULL_DONOR == "zhestokost") names a donor zone 'zhestokost:<zone>'.
// 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 <lowpoly.scad>
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
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.
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);

View file

@ -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

View file

@ -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:<id> | rad:<id>
// 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:<id> | rad:<id>
// Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard.
include <zhestokost.scad>
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, ""]);
}

View file

@ -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]"),
}
@ -215,7 +216,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":
@ -543,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)

View file

@ -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,17 @@ 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"
MUZZLE = "every weapon-mount has a muzzle and no muzzle is orphaned"
def test_a_muzzle_aft_of_its_mount_is_refused(self):
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(), [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])
self.assertEqual(fixture.failed(), [])
def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self):
fixture = Fixture()
@ -242,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()