Merge remote-tracking branch 'origin/master' into eureka/aetheria-arena-determinism-ledger
This commit is contained in:
commit
3efc8cddd7
13 changed files with 513 additions and 134 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ""]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue