Merge remote-tracking branch 'origin/master' into eureka/aetheria-arena-command-seam-agents

This commit is contained in:
soul 2026-10-22 02:12:19 +02:00
commit b519fbd3d0
19 changed files with 778 additions and 85 deletions

BIN
Assets/Resources/Settings.asset (Stored with Git LFS)

Binary file not shown.

View file

@ -134,6 +134,12 @@ public class ZoneGenerationSettings
public float TrojanHalfWidth = .06f;
public ExponentialCurve TrojanCount = new ExponentialCurve { Exponent = 1f, Multiplier = .04f, Constant = 8f };
// Laned belts (rulings lanes-two-sided-visual-margin, lane-narrow): the chance a belt candidate that kept its body and has
// room on both sides carries a laned belt, and the lane's half-width in the host's body radii (1.5 by ruling). LaneProbability
// is 0 here: a settings object built in code draws no lane.
public float LaneProbability = 0f;
public float LaneBodyRadii = 1.5f;
public float ResourceDensityMinimum = .1f;
public float ResourceDensityMaximum = 1.5f;

View file

@ -221,7 +221,7 @@ public class Zone
foreach (var belt in AsteroidBelts)
{
var rail = Planets[belt.Key].Orbit.Key;
beltExtent[rail] = belt.Value.Extent(Orbits[rail].Data.Distance);
beltExtent[rail] = AsteroidBelt.Extent(belt.Value.Data, Orbits[rail].Data.Distance);
}
var wellNodes = new List<WellNode>();
foreach (var orbit in Orbits)
@ -903,8 +903,10 @@ public class AsteroidBelt
}
// What the belt's rail adds to its parent's L_F (WellTree): an annulus reaches its outermost rock, whose period reads the
// field there; a swarm riding its rail reaches only the rail, whose period every rock shares.
public float Extent(float railDistance) => Data.RidesRail ? railDistance : Math.Max(Radius, railDistance);
// field there; a swarm riding its rail reaches only the rail, whose period every rock shares. The one belt extent rule:
// Zone, ZoneGenerator's clearance tree and the station tests all read it.
public static float Extent(AsteroidBeltData data, float railDistance) =>
data.RidesRail ? railDistance : Math.Max(data.Asteroids.Max(a => a.Distance), railDistance);
// Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used.
// The only place a chunk pose is computed; no per-tick copy survives it. Cut 2: size is no longer read from

View file

@ -118,6 +118,7 @@ public static class ZoneGenerator
}
pack.Orbits = planets.Select(planet => cache.RefOf(orbitMap[planet])).ToList();
var beltOf = new Dictionary<GeneratorPlanet, AsteroidBeltData>();
pack.Planets = planets.Where(p=>!p.Empty).Select(planet =>
{
BodyType bodyType = planet.Belt ? BodyType.Asteroid :
@ -149,6 +150,7 @@ public static class ZoneGenerator
planetData.Orbit = cache.RefOf(orbitMap[planet]);
if (planetData is AsteroidBeltData beltData)
{
beltOf[planet] = beltData;
beltData.Asteroids =
Enumerable.Range(0, (int) zoneSettings.AsteroidCount.Evaluate(beltData.Mass * orbitMap[planet].Distance))
.Select(_ => new Asteroid
@ -217,12 +219,15 @@ public static class ZoneGenerator
// Trojan swarms (rulings trojans-first, trojans-ride-host-period-with-stations): a belt on a copy of its host's rail, its
// rocks round both L4 and L5. Drawn from a stream of its own, so no other draw in the zone moves; not a GeneratorPlanet,
// so it takes no slot and no station candidacy sees it.
// so it takes no slot and no station candidacy sees it. A standing binary leaves a bodyless node (Empty) that keeps its
// pair's mass and a parent, so the Empty guard is what keeps a swarm off a rail no body is on.
var swarmRandom = new Random(seed ^ TrojanStream);
var swarmHosts = new HashSet<GeneratorPlanet>();
foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Empty && !p.Belt && p.Distance > 0
&& p.Mass > zoneSettings.PlanetMass && p.Mass <= zoneSettings.SunMass))
{
if (swarmRandom.NextFloat() >= zoneSettings.TrojanProbability) continue;
swarmHosts.Add(host);
var hostOrbit = orbitMap[host];
var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase };
pack.Orbits.Add(cache.Upsert(rail));
@ -238,11 +243,42 @@ public static class ZoneGenerator
pack.Planets.Add(cache.Upsert(swarmBody));
}
// Laned belts (rulings belt-shapes-direction, lanes-two-sided-visual-margin): a belt candidate that kept its body carries
// an annulus on a copy of its rail that fills its slot less a lane round the body, rocks on both sides. The lane is sized
// against the body the player sees (LaneBodyRadii body radii a side), and only a body whose slot holds a band at least
// that wide on both sides gets one. One belt shape per parent, and none on a swarm host. Drawn from a stream of its own,
// so no other draw in the zone moves; its rocks sit inside the host's slot, below the next sibling's spacing, so they
// raise no L_F and no support: the lane moves no well, period or height.
var laneRandom = new Random(seed ^ LaneStream);
var shaped = new HashSet<GeneratorPlanet>(planets.Where(p => p.Belt).Select(p => p.Parent));
foreach (var host in planets.Where(p => p.BeltCandidate && !p.Belt))
{
if (laneRandom.NextFloat() >= zoneSettings.LaneProbability) continue;
var lane = zoneSettings.LaneBodyRadii * planetSettings.BodyRadius.Evaluate(host.Mass);
if (swarmHosts.Contains(host) || shaped.Contains(host.Parent) || !LaneHost(host, lane)) continue;
shaped.Add(host.Parent);
var hostOrbit = orbitMap[host];
var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase };
pack.Orbits.Add(cache.Upsert(rail));
var laned = new AsteroidBeltData
{
Orbit = cache.RefOf(rail),
Asteroids = LaneRocks(zoneSettings, ref laneRandom, host.Mass, rail.Distance, lane, host.SlotInner, host.SlotOuter)
};
BodyData lanedBody = laned;
var lanedKey = cache.Upsert(lanedBody).Key;
laned.Name = lanedKey.Value.Substring(0, 8);
FieldKinds.Ensure(lanedKey, laned, cache);
pack.Planets.Add(cache.Upsert(lanedBody));
}
// The generator's own well tree, for clearance only (the Zone builds its own): what a rail must stay out of is the body's
// support, or its Roche margin if more.
// support, or its Roche margin if more. A belt's extent is the rule Zone reads (AsteroidBelt.Extent). Swarm and lane rails
// are left out: a swarm's extent is its own rail, a copy of its host's, and a lane's outer rock sits inside its host's slot,
// below the next sibling's spacing, so neither raises an L_F or a support.
var wellNodes = planets.Select(p => new WellNode(cache.RefOf(orbitMap[p]).Key, p.Parent == null ? default : cache.RefOf(orbitMap[p.Parent]).Key,
p.Distance, p.Phase, p.Empty || p.Belt ? 0f : p.Mass,
p.Belt ? p.Distance + p.BeltHalfWidth : p.Distance, p.FixedPosition)).ToList();
p.Belt ? AsteroidBelt.Extent(beltOf[p], p.Distance) : p.Distance, p.FixedPosition)).ToList();
var wells = WellTree.Build(wellNodes, planetSettings, pack.Radius);
float Extent(GeneratorPlanet p)
{
@ -595,8 +631,11 @@ public static class ZoneGenerator
: root.AllPlanets().Where(p => p != root && p.Mass < settings.BeltMassCeiling && !binaries.Contains(p) && p.Children.Count == 0);
foreach(var planet in beltCandidates.Reverse())
{
planet.BeltCandidate = true;
if (random.NextFloat() < settings.BeltProbability && !planet.Parent.Children.Any(p=>p.Belt))
planet.Belt = true;
}
var totalMass = root.AllPlanets().Sum(p => p.Mass);
var rootMass = root.Mass;
@ -630,6 +669,41 @@ public static class ZoneGenerator
};
return rocks;
}
private const uint LaneStream = 0x85EBCA6Bu;
// Whether a placed body can carry a lane of half-width `lane`: its slot holds a band at least that wide on both sides
// (ruling lanes-two-sided-visual-margin), and it is not its parent's outermost child (ruling lane-not-outermost). Place
// stretches the chain to the window, so an outermost child's outer side is the window, not a sibling's spacing; a lane's outer
// band there would reach past every sibling's extent and raise its parent's support, and lanes move nothing else in the zone.
// An only child is its parent's outermost.
public static bool LaneHost(GeneratorPlanet host, float lane) =>
host.Parent.Children.Any(c => c.Distance > host.Distance)
&& host.SlotInner <= host.Distance - 2 * lane && host.SlotOuter >= host.Distance + 2 * lane;
// A laned belt's rocks: the count an annulus of the host's mass at `distance` would draw, over the bands [inner, distance - lane]
// and [distance + lane, outer], each rock in a band by its area and drawn across it as the annulus draws across its width.
public static Asteroid[] LaneRocks(ZoneGenerationSettings settings, ref Random random, float mass, float distance, float lane, float inner, float outer)
{
float Sq(float x) => x * x;
var innerArea = Sq(distance - lane) - Sq(inner);
var share = innerArea / (innerArea + Sq(outer) - Sq(distance + lane));
var rocks = new Asteroid[(int) settings.AsteroidCount.Evaluate(mass * distance)];
for (var i = 0; i < rocks.Length; i++)
{
var innerBand = random.NextFloat() < share;
var a = innerBand ? inner : distance + lane;
var b = innerBand ? distance - lane : outer;
rocks[i] = new Asteroid
{
Distance = (a + b) / 2 + random.NextFloat() * (random.NextFloat() - .5f) * (b - a),
Phase = random.NextFloat(),
Size = random.NextFloat(),
RotationSpeed = settings.AsteroidRotationSpeed.Evaluate(random.NextFloat())
};
}
return rocks;
}
public const float CuspMargin = 1.5f;
public const float SiblingSpacing = .5f;
@ -688,15 +762,18 @@ public static class ZoneGenerator
var g = children.Count > 1 ? pow(upper / positions[positions.Count - 1], 1f / (children.Count - 1)) : 1f;
for (var k = 0; k < children.Count; k++)
children[k].Distance = positions[k] * pow(g, k);
// A belt is an interval inside its slot: inner edge outside the lower bound and the previous sibling's spacing, outer edge
// inside the next sibling's spacing and the window; the authored width is the widest it may be.
// Every child's slot: from the lower bound and the previous sibling's spacing out to the next sibling's spacing and the
// window. Place is the only writer of a slot; a belt fills its own, and the lane pass reads a body's. A belt is an
// interval inside its slot, the authored width the widest it may be.
var outer = 0f;
for (var k = 0; k < children.Count; k++)
{
var c = children[k];
c.SlotInner = Math.Max(lo, outer * q);
c.SlotOuter = k + 1 < children.Count ? Math.Min(upper, children[k + 1].Distance / q) : upper;
if (!c.Belt) { outer = c.Distance; continue; }
var inner = Math.Max(lo, outer * q);
var outerLimit = k + 1 < children.Count ? Math.Min(upper, children[k + 1].Distance / q) : upper;
var inner = c.SlotInner;
var outerLimit = c.SlotOuter;
c.Distance = (inner + outerLimit) / 2;
var half = Math.Max(0f, Math.Min(c.Settings.AsteroidBeltWidth.Evaluate(c.Distance), outerLimit - inner) / 2);
c.BeltHalfWidth = half;
@ -762,6 +839,11 @@ public class GeneratorPlanet
public float BeltHalfWidth;
public bool Empty = false;
public bool Belt = false;
// The interval Place gives a child between its previous sibling's spacing and the next sibling's spacing or the window; zero
// on a ring member, which has no slot.
public float SlotInner, SlotOuter;
// A childless light body, not a binary or root rosette member, that could have become a belt (set by GenerateEntities).
public bool BeltCandidate;
// A member of a rosette's ring, which Place puts on one orbit round its bodyless parent.
public bool Ring = false;
public List<GeneratorPlanet> Children = new List<GeneratorPlanet>(); // Planets orbiting this one are referred to as children

View file

@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
// The belt shapes a test reads back from a built zone. A swarm's or a lane's rail is a copy of its host's rail (same Parent,
// Distance and Phase), never a slot Place laid out, so the tests that walk placed orbits leave copied rails out through this one
// helper.
public static class BeltShapes
{
public static bool CopiedRail(Zone zone, CultRecordKey orbit)
{
if (!zone.Planets.Values.Any(b => b is AsteroidBeltData && b.Orbit.Key.Equals(orbit))) return false;
var rail = zone.Orbits[orbit].Data;
return zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key].Data)
.Any(o => o.Parent.Key.Equals(rail.Parent.Key) && o.Distance == rail.Distance && o.Phase == rail.Phase);
}
// Every laned belt with its rail and its host: a belt on a copied rail that does not ride the rail (a swarm does), whose host
// is the body orbit the rail copies.
public static IEnumerable<(Orbit Host, Orbit Rail, AsteroidBelt Belt)> Lanes(Zone zone)
{
foreach (var (key, belt) in zone.AsteroidBelts.Where(b => !b.Value.Data.RidesRail))
{
var railKey = zone.Planets[key].Orbit.Key;
if (!CopiedRail(zone, railKey)) continue;
var rail = zone.Orbits[railKey];
var host = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key])
.Single(o => o.Data.Parent.Key.Equals(rail.Data.Parent.Key) && o.Data.Distance == rail.Data.Distance && o.Data.Phase == rail.Data.Phase);
yield return (host, rail, belt);
}
}
}

View file

@ -19,6 +19,8 @@ public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReach
private readonly ZoneWorlds _worlds = new ZoneWorlds();
public readonly List<(uint Seed, Zone Zone)> All = new List<(uint, Zone)>();
public ZoneGenerationSettings Settings;
// Every belt the generator wrote, and those it wrote with no field kind, counted before Zone's own FieldKinds.Ensure runs.
public int Belts, UnkindedAtGeneration;
// Seeds 7, 11 and 12 held rings inside their heavy member's Roche margin (ring-roche-final-heavy), and seeds 10, 13, 21 and 30
// held a bodyless ring member whose own bodies sat at their node's centre (bodyless-node-places-children); seeds 1-6 show neither.
@ -50,6 +52,11 @@ public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReach
tune?.Invoke(Settings);
var pack = ZoneGenerator.GenerateZone(items, Settings, planetSettings, galaxy, galaxyZone,
galaxyZone == galaxy.Entrance && galaxy.IsPrelude, false);
foreach (var belt in pack.Planets.Select(cache.Get).OfType<AsteroidBeltData>())
{
Belts++;
if (!belt.Kind.IsSet()) UnkindedAtGeneration++;
}
All.Add((seed, new Zone(items, planetSettings, pack, galaxyZone, galaxy)));
}
}
@ -79,16 +86,17 @@ public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReach
private static Dictionary<CultRecordKey, float> BodyMass(Zone zone) =>
zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).GroupBy(b => b.Orbit.Key).ToDictionary(g => g.Key, g => g.First().Mass);
// The generated orbits: every body's and its ancestors', which leaves out stations, turrets, Lagrange copies and swarm rails (a copy of the host's rail, like a Lagrange copy).
private static HashSet<CultRecordKey> Placed(Zone zone)
// The generated orbits: every body's and its ancestors', which leaves out stations, turrets, Lagrange copies and the copied
// rails swarms and lanes ride (a copy of the host's rail, like a Lagrange copy; BeltShapes.CopiedRail).
internal static HashSet<CultRecordKey> Placed(Zone zone)
{
var placed = new HashSet<CultRecordKey>();
foreach (var body in zone.Planets.Values.Where(b => !(b is AsteroidBeltData { RidesRail: true })))
foreach (var body in zone.Planets.Values.Where(b => !BeltShapes.CopiedRail(zone, b.Orbit.Key)))
for (var key = body.Orbit.Key; placed.Add(key) && zone.Orbits[key].Data.Parent.IsSet();) key = zone.Orbits[key].Data.Parent.Key;
return placed;
}
private static List<CultRecordKey> ChildrenOf(Zone zone, CultRecordKey parent, HashSet<CultRecordKey> placed) =>
internal static List<CultRecordKey> ChildrenOf(Zone zone, CultRecordKey parent, HashSet<CultRecordKey> placed) =>
placed.Where(k => zone.Orbits[k].Data.Parent.IsSet() && zone.Orbits[k].Data.Parent.Key.Equals(parent))
.OrderBy(k => zone.Orbits[k].Data.Distance).ToList();
@ -332,7 +340,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReach
foreach (var (seed, zone) in _zones.All)
{
var mass = BodyMass(zone);
foreach (var (beltKey, belt) in zone.AsteroidBelts.Where(b => !b.Value.Data.RidesRail))
foreach (var (beltKey, belt) in zone.AsteroidBelts.Where(b => !BeltShapes.CopiedRail(zone, zone.Planets[b.Key].Orbit.Key)))
{
belts++;
var orbit = zone.Planets[beltKey].Orbit.Key;
@ -363,7 +371,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReach
// authored width (ruling belt-fills-its-slot): it sits at the slot's midpoint where the test can spell both edges, and
// none collapses to a line (finding belts-collapse-to-a-line). Rocks are scattered inside the belt's half width, so their
// spread is at most the belt's width; a belt beside another belt has an edge only the generator knows, and is checked
// against the weaker bound.
// against the weaker bound (two adjacent belts' spacing is pinned by AdjacentBeltsKeepTheirSpacing).
[Fact]
public void BeltsFillTheirSlots()
{
@ -374,7 +382,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReach
{
var mass = BodyMass(zone);
var placed = Placed(zone);
var beltAt = zone.AsteroidBelts.Where(b => !b.Value.Data.RidesRail).ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids);
var beltAt = zone.AsteroidBelts.Where(b => !BeltShapes.CopiedRail(zone, zone.Planets[b.Key].Orbit.Key)).ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids);
foreach (var (orbit, rocks) in beltAt)
{
var parent = zone.Orbits[orbit].Data.Parent.Key;
@ -424,7 +432,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReach
{ Exponent = authored.Exponent, Multiplier = authored.Multiplier * .02f, Constant = authored.Constant * .02f });
int belts = 0, filled = 0;
foreach (var (seed, zone) in narrow.All)
foreach (var (key, belt) in zone.AsteroidBelts.Where(b => !b.Value.Data.RidesRail))
foreach (var (key, belt) in zone.AsteroidBelts.Where(b => !BeltShapes.CopiedRail(zone, zone.Planets[b.Key].Orbit.Key)))
{
var rocks = belt.Data.Asteroids;
if (rocks.Length < 2) continue;
@ -438,14 +446,41 @@ public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReach
Assert.True(filled >= 10, $"only {filled} of {belts} belts fill the authored width");
}
private static GeneratorPlanet Body(GeneratorPlanet parent, float mass, float phase = 0, bool ring = false)
internal static GeneratorPlanet Body(GeneratorPlanet parent, float mass, float phase = 0, bool ring = false)
{
var node = new GeneratorPlanet { Parent = parent, Mass = mass, Phase = phase, Ring = ring };
parent.Children.Add(node);
return node;
}
private static PlanetSettings AuthoredPlanetSettings() => AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read<PlanetSettings>("PlanetSettings");
internal static PlanetSettings AuthoredPlanetSettings() => AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read<PlanetSettings>("PlanetSettings");
// Two belts side by side under one parent (production never draws them; follow-up belt-after-belt-edge-pin): the second
// belt's slot starts a sibling spacing outside the first belt's outer edge, not its rail, so their rock intervals keep a spacing.
[Fact]
public void AdjacentBeltsKeepTheirSpacing()
{
var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read<ZoneGenerationSettings>("ZoneSettings");
// Wider than any slot, so each belt fills its slot and the second's inner edge is the slot's inner bound.
settings.AsteroidBeltWidth = new ExponentialCurve { Exponent = 1f, Multiplier = 0f, Constant = 1e7f };
var root = new GeneratorPlanet { Mass = 1e5f };
Body(root, 100f);
var first = Body(root, 0f);
var second = Body(root, 0f);
Body(root, 100f, .5f);
foreach (var belt in new[] { first, second }) { belt.Belt = true; belt.Settings = settings; }
ZoneGenerator.Place(root, AuthoredPlanetSettings(), 1e6f, float.PositiveInfinity);
Assert.True(root.Children.Contains(first) && root.Children.Contains(second), "a belt folded");
Assert.True(first.Distance < second.Distance, "the belts swapped order");
Assert.True(first.BeltHalfWidth > 0 && second.BeltHalfWidth > 0, "a belt collapsed to a line");
var q = 1 / (1 - GravityField.Reach / 2);
var firstOuter = first.Distance + first.BeltHalfWidth;
var secondInner = second.Distance - second.BeltHalfWidth;
_out.WriteLine($"first {first.Distance} +- {first.BeltHalfWidth}, second {second.Distance} +- {second.BeltHalfWidth}");
Assert.True(firstOuter * q <= secondInner * (1 + 1e-4f), $"the second belt's inner edge {secondInner} is within a spacing of the first's outer edge {firstOuter}");
// The slot Place records for the second belt starts at that edge (Place is the only writer of a slot).
Assert.True(Math.Abs(second.SlotInner - firstOuter * q) <= 1e-4f * second.SlotInner, $"the second belt's slot starts at {second.SlotInner}, not {firstOuter * q}");
}
// A rosette's ring round a root with no reach limit of its own sits at half the root's 0.75 x radius window, 3/8 of the radius.
[Fact]

View file

@ -0,0 +1,220 @@
using System;
using System.Linq;
using GameCult.Caching;
using Xunit;
using Xunit.Abstractions;
// Laned belts (rulings belt-shapes-direction, lanes-two-sided-visual-margin, lane-narrow, lane-not-outermost; map B60): a belt
// candidate that kept its body and is not its parent's outermost child carries an annulus on a copy of its rail that fills the
// body's slot less a lane LaneBodyRadii (1.5) body radii a side, with rocks on both sides; it is drawn from a stream of its own and
// sits inside the slot, so it moves nothing else in the zone. Seeds 1-6, authored settings, against the same seeds drawing no lane.
public sealed class LanedBeltTests : IClassFixture<LanedBeltTests.Samples>
{
private const uint First = 1, Last = 6;
private const float Tolerance = 1e-3f;
public sealed class Samples : IDisposable
{
public readonly ChildrenWithinReachTests.Zones Authored = new ChildrenWithinReachTests.Zones(First, Last, null);
public readonly ChildrenWithinReachTests.Zones Laneless = new ChildrenWithinReachTests.Zones(First, Last, z => z.LaneProbability = 0);
// Every candidate tried for a lane and every eligible host given a swarm, seeds 1-30: the authored sample holds no lane
// candidate that is a swarm host or shares a parent with another eligible host, so only this one reaches the swarm-host and
// one-shape-per-parent filters (finding lane-filters-unexercised).
public readonly ChildrenWithinReachTests.Zones Tuned = new ChildrenWithinReachTests.Zones(1, 30, z => { z.LaneProbability = 1; z.TrojanProbability = 1; });
public void Dispose()
{
Authored.Dispose();
Laneless.Dispose();
Tuned.Dispose();
}
}
private readonly Samples _samples;
private readonly ITestOutputHelper _out;
public LanedBeltTests(Samples samples, ITestOutputHelper output)
{
_samples = samples;
_out = output;
}
private static CultRecordKey KeyOf(Zone zone, Orbit orbit) => zone.Orbits.First(o => o.Value == orbit).Key;
private static BodyData BodyAt(Zone zone, Orbit orbit) =>
zone.Planets.Values.Single(b => !(b is AsteroidBeltData) && zone.Orbits[b.Orbit.Key] == orbit);
// The lane's half-width, spelled from the settings: LaneBodyRadii of the host's body radius.
private float HalfWidth(Zone zone, float hostMass) => _samples.Authored.Settings.LaneBodyRadii * zone.Settings.BodyRadius.Evaluate(hostMass);
[Fact]
public void ALaneMovesNothingElseInTheZone()
{
var a = _samples.Laneless.All;
var b = _samples.Authored.All;
Assert.Equal(a.Count, b.Count);
var lanes = 0;
for (var n = 0; n < a.Count; n++)
{
Assert.Equal(a[n].Seed, b[n].Seed);
Assert.Empty(BeltShapes.Lanes(a[n].Zone));
var zone = b[n].Zone;
var laned = BeltShapes.Lanes(zone).ToList();
lanes += laned.Count;
bool SkipOrbit(CultRecordKey k) => laned.Any(l => l.Rail == zone.Orbits[k]);
bool SkipBody(BodyData body) => laned.Any(l => l.Belt.Data == body);
Assert.Equal(ZoneFingerprint.Of(new[] { a[n].Zone }, _ => false, _ => false, true), ZoneFingerprint.Of(new[] { zone }, SkipOrbit, SkipBody, true));
}
_out.WriteLine($"zones {b.Count}, lanes {lanes}");
Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}");
}
[Fact]
public void ALaneIsEmptyAndBothSidesHoldRocks()
{
var s = _samples.Authored.Settings;
var q = 1 / (1 - GravityField.Reach / 2);
var lanes = 0;
foreach (var (seed, zone) in _samples.Authored.All)
{
var placed = ChildrenWithinReachTests.Placed(zone);
foreach (var (host, _, belt) in BeltShapes.Lanes(zone))
{
lanes++;
Assert.False(belt.Data.RidesRail);
Assert.Equal(0f, belt.Data.Mass);
var hostMass = BodyAt(zone, host).Mass;
var d = host.Data.Distance;
var e = HalfWidth(zone, hostMass);
Assert.Equal((int) s.AsteroidCount.Evaluate(hostMass * d), belt.Data.Asteroids.Length);
// The host's slot, spelled from its siblings, its parent's Roche margin and its parent's window.
var parent = host.Data.Parent.Key;
var sibling = ChildrenWithinReachTests.ChildrenOf(zone, parent, placed);
var at = sibling.IndexOf(KeyOf(zone, host));
Assert.True(at >= 0 && at < sibling.Count - 1, $"seed {seed}: a lane host is its parent's outermost child");
var parentBody = zone.Planets.Values.FirstOrDefault(p => !(p is AsteroidBeltData) && p.Orbit.Key.Equals(parent));
var roche = parentBody == null ? 0f : 1.5f * zone.Settings.RocheLimit.Evaluate(parentBody.Mass);
var innerBound = Math.Max(roche, at > 0 ? zone.Orbits[sibling[at - 1]].Data.Distance * q : 0f);
var grand = zone.Orbits[parent].Data.Parent;
var window = grand.IsSet()
? GravityField.Gamma * GravityField.Reach * zone.Orbits[parent].Data.Distance / Math.Max(GravityField.Beta, 1 + GravityField.Reach)
: float.PositiveInfinity;
var outerBound = Math.Min(window, zone.Orbits[sibling[at + 1]].Data.Distance / q);
var rocks = belt.Data.Asteroids.Select(r => r.Distance).ToList();
var inner = rocks.Where(r => r < d).ToList();
var outer = rocks.Where(r => r > d).ToList();
_out.WriteLine($"seed {seed}: lane at {d} half-width {e}, slot [{innerBound}, {outerBound}], rocks {inner.Count} inside, {outer.Count} outside");
Assert.True(rocks.All(r => Math.Abs(r - d) >= e - 1e-5f * d), $"seed {seed}: a rock sits {rocks.Min(r => Math.Abs(r - d))} from a host at {d}, inside a lane of {e}");
Assert.True(inner.Count > 0 && outer.Count > 0, $"seed {seed}: a lane at {d} holds {inner.Count} rocks inside and {outer.Count} outside");
Assert.True(d - e - innerBound >= e * (1 - Tolerance), $"seed {seed}: the inner band [{innerBound}, {d - e}] is narrower than the lane's half-width {e}");
Assert.True(outerBound - d - e >= e * (1 - Tolerance), $"seed {seed}: the outer band [{d + e}, {outerBound}] is narrower than the lane's half-width {e}");
Assert.True(inner.Min() >= innerBound * (1 - Tolerance), $"seed {seed}: a rock at {inner.Min()} reaches inside the slot's inner bound {innerBound}");
Assert.True(outer.Max() <= outerBound * (1 + Tolerance), $"seed {seed}: a rock at {outer.Max()} reaches past the slot's outer bound {outerBound}");
}
}
Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}");
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ALaneHostIsALightBodyWithNoOtherShape(bool tuned)
{
var sample = tuned ? _samples.Tuned : _samples.Authored;
var s = sample.Settings;
int lanes = 0, lightChildlessSwarmHosts = 0;
foreach (var (seed, zone) in sample.All)
{
// A swarm host light enough and childless enough to be a lane host: the case the swarm-host filter refuses.
foreach (var rail in zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail).Select(b => zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data))
{
var hostBody = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).FirstOrDefault(b => zone.Orbits[b.Orbit.Key].Data is var o
&& o.Parent.Key.Equals(rail.Parent.Key) && o.Distance == rail.Distance && o.Phase == rail.Phase);
if (hostBody != null && hostBody.Mass < s.BeltMassCeiling
&& !zone.Orbits.Values.Any(o => o.Data.Parent.IsSet() && o.Data.Parent.Key.Equals(hostBody.Orbit.Key)))
lightChildlessSwarmHosts++;
}
var laned = BeltShapes.Lanes(zone).ToList();
foreach (var (host, _, _) in laned)
{
lanes++;
var body = BodyAt(zone, host);
Assert.True(body.GetType() == typeof(PlanetData) && body.Mass < s.BeltMassCeiling, $"seed {seed}: a lane host is a {body.GetType().Name} of mass {body.Mass}");
var key = KeyOf(zone, host);
Assert.DoesNotContain(zone.Orbits.Values, o => o.Data.Parent.IsSet() && o.Data.Parent.Key.Equals(key));
// A swarm's rail is a copy of its host's too: no lane host carries one.
Assert.DoesNotContain(zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail).Select(b => zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data),
r => r.Parent.Key.Equals(host.Data.Parent.Key) && r.Distance == host.Data.Distance && r.Phase == host.Data.Phase);
}
// One belt shape per parent: an annulus (a belt on a placed slot) or a lane, never both, never two lanes.
var annuli = zone.AsteroidBelts.Keys.Select(k => zone.Planets[k].Orbit.Key).Where(o => !BeltShapes.CopiedRail(zone, o))
.Select(o => zone.Orbits[o].Data.Parent.Key);
var doubled = annuli.Concat(laned.Select(l => l.Host.Data.Parent.Key)).GroupBy(p => p).Count(g => g.Count() > 1);
Assert.True(doubled == 0, $"seed {seed}: {doubled} parents carry two belt shapes");
}
_out.WriteLine($"zones {sample.All.Count}, lanes {lanes}, light childless swarm hosts {lightChildlessSwarmHosts}");
Assert.True(lanes > 0, "no lane in the sample");
if (tuned) Assert.True(lightChildlessSwarmHosts > 100, $"only {lightChildlessSwarmHosts} light childless swarm hosts: the swarm-host filter is not reached");
}
// LaneRocks puts each rock in a band by the band's area (ruling lanes-two-sided-visual-margin, map B60), not half and half and
// not by width (finding lane-split-unpinned). A lopsided slot, inner band [10, 18] (area 224) and outer [22, 24] (area 92):
// area share 0.709, width share 0.8, half 0.5. Over 4000 rocks one sigma of the share is 0.0072, so the area share sits within
// 4 sigma and the other two lie past 12.
[Fact]
public void ALaneSplitsItsRocksByBandArea()
{
const float inner = 10f, distance = 20f, lane = 2f, outer = 24f;
var settings = _samples.Authored.Settings;
var p = (18.0 * 18 - 10 * 10) / (18.0 * 18 - 10 * 10 + 24 * 24 - 22 * 22);
int n = 0, k = 0;
for (uint draw = 1; n < 4000; draw++)
{
var random = new CultMath.Random(draw);
var rocks = ZoneGenerator.LaneRocks(settings, ref random, 1f, distance, lane, inner, outer);
Assert.NotEmpty(rocks);
foreach (var rock in rocks)
{
Assert.True(rock.Distance >= inner && rock.Distance <= distance - lane || rock.Distance >= distance + lane && rock.Distance <= outer,
$"a rock at {rock.Distance} sits outside both bands");
n++;
if (rock.Distance < distance) k++;
}
}
var z = (k - n * p) / Math.Sqrt(n * p * (1 - p));
_out.WriteLine($"rocks {n}, inner {k}, area share {p:F3}, measured {(double) k / n:F3}, z {z:F2}");
Assert.True(Math.Abs(z) < 4, $"{k} of {n} rocks in the inner band, {(double) k / n:F3} against an area share of {p:F3} (z {z:F2})");
}
// An only child is its parent's outermost (ruling lane-not-outermost): Place stretches nothing for it, so its slot runs out to
// the window and has room for a lane on both sides, yet its lane's outer band would sit past every sibling's extent and raise
// the parent's support. The host filter refuses it on that ground alone.
[Fact]
public void AnOnlyChildHostsNoLane()
{
var root = new GeneratorPlanet { Mass = 1e5f };
var child = ChildrenWithinReachTests.Body(root, 10f);
var planetSettings = ChildrenWithinReachTests.AuthoredPlanetSettings();
ZoneGenerator.Place(root, planetSettings, 1e6f, 1e5f);
var lane = _samples.Authored.Settings.LaneBodyRadii * planetSettings.BodyRadius.Evaluate(child.Mass);
_out.WriteLine($"only child at {child.Distance}, slot [{child.SlotInner}, {child.SlotOuter}], lane half-width {lane}");
Assert.True(child.SlotInner <= child.Distance - 2 * lane && child.SlotOuter >= child.Distance + 2 * lane,
$"the only child's slot [{child.SlotInner}, {child.SlotOuter}] has no room for a lane of {lane} at {child.Distance}");
Assert.False(ZoneGenerator.LaneHost(child, lane));
}
// A belt's field kind is chosen at generation and saved with it (Mining Cut 3, Q16 B): every belt the generator writes,
// annulus, swarm or lane, carries one before any Zone reads it (finding ensure-unpinned).
[Fact]
public void EveryBeltIsKindedAtGeneration()
{
var zones = _samples.Authored;
_out.WriteLine($"belts {zones.Belts}, unkinded at generation {zones.UnkindedAtGeneration}");
Assert.True(zones.All.Any(z => z.Zone.AsteroidBelts.Values.Any(b => b.Data.RidesRail)), "no swarm in the sample");
Assert.True(zones.All.Any(z => BeltShapes.Lanes(z.Zone).Any()), "no lane in the sample");
Assert.True(zones.Belts > 20, $"only {zones.Belts} belts generated");
Assert.Equal(0, zones.UnkindedAtGeneration);
}
}

View file

@ -22,6 +22,7 @@ public sealed class ShipSchemaPinTests
[InlineData("SCHEMATIC_LINES_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.SchematicLines))]
[InlineData("HULL_SHAPE_SLOT", typeof(HullData), nameof(HullData.Shape))]
[InlineData("HULL_HARDPOINTS_SLOT", typeof(HullData), nameof(HullData.Hardpoints))]
[InlineData("HULL_TYPE_SLOT", typeof(HullData), nameof(HullData.HullType))]
[InlineData("PRODUCT_MANUFACTURER_SLOT", typeof(FactionProductData), nameof(FactionProductData.Manufacturer))]
public void PythonSlotConstantsAreTheKeysOfTheMembersTheyNameInTheirComments(string constant, Type owner, string member)
{
@ -136,6 +137,7 @@ public sealed class ShipSchemaPinTests
{
Assert.Equal(EnumNames<HardpointType>(), Strings("HARDPOINT_TYPE_NAMES"));
Assert.Equal(EnumNames<ItemRotation>(), Strings("ROTATION_NAMES"));
Assert.Equal(EnumNames<HullType>(), Strings("HULL_TYPE_NAMES"));
}
[Fact]

View file

@ -81,10 +81,6 @@ public sealed class StationPlacementTests : IDisposable
private static float Turns(float turns) => turns - (float) Math.Floor(turns);
// A Trojan swarm's rail is a copy of its host's, not a station's source.
private static bool SwarmRail(Zone zone, CultRecordKey orbit) =>
zone.Planets.Values.Any(b => b is AsteroidBeltData { RidesRail: true } && b.Orbit.Key.Equals(orbit));
[Fact]
public void AStationSitsAtItsPlanetsL4OrL5()
{
@ -100,7 +96,7 @@ public sealed class StationPlacementTests : IDisposable
foreach (var station in zone.Entities.OfType<OrbitalEntity>().Where(e => TypeOf(built.Items, e) == HullType.Station))
{
var orbit = zone.Orbits[station.OrbitData];
var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList();
var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !BeltShapes.CopiedRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList();
if (sources.Count == 0)
{
// The tutorial entrance's one exception: a station on its own orbit round a root node, beyond the node's ring
@ -160,7 +156,7 @@ public sealed class StationPlacementTests : IDisposable
foreach (var station in Orbitals(built).Where(e => TypeOf(built.Items, e) == HullType.Station))
{
var orbit = zone.Orbits[station.OrbitData];
var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList();
var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !BeltShapes.CopiedRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList();
if (sources.Count == 0) continue; // the tutorial entrance's own orbit round a root
stations++;
Assert.Contains(bodyOrbits, key => key.Equals(sources[0].Key));
@ -177,7 +173,7 @@ public sealed class StationPlacementTests : IDisposable
var placed = new HashSet<CultRecordKey>();
foreach (var body in zone.Planets.Values)
for (var key = body.Orbit.Key; placed.Add(key) && zone.Orbits[key].Data.Parent.IsSet();) key = zone.Orbits[key].Data.Parent.Key;
var beltExtent = zone.AsteroidBelts.ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Extent(zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data.Distance));
var beltExtent = zone.AsteroidBelts.ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => AsteroidBelt.Extent(b.Value.Data, zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data.Distance));
var nodes = placed.Select(k =>
{
var d = zone.Orbits[k].Data;
@ -357,7 +353,7 @@ public sealed class StationPlacementTests : IDisposable
foreach (var station in Orbitals(built).Where(e => TypeOf(built.Items, e) == HullType.Station))
{
var orbit = zone.Orbits[station.OrbitData];
var strays = zone.Orbits.Count(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit));
var strays = zone.Orbits.Count(o => !ridden.Contains(o.Key) && !BeltShapes.CopiedRail(zone, o.Key) && SameRing(o.Value, orbit));
Assert.True(strays == 1, $"seed {built.Seed}: a station's ring holds {strays} orbits no entity rides, not just its source's");
stations++;
}

View file

@ -38,6 +38,37 @@ public class TrojanSwarmTests
private static float Turn(float2 p) => frac((float) (Math.Atan2(p.y, p.x) / (2 * Math.PI)));
private static float Wrap(float t) => t - (float) Math.Round(t);
// A standing binary leaves a bodyless node with a parent and its pair's mass, heavy enough to pass the host mass filter; a
// swarm round it would ride a rail no body is on (finding swarm-on-bodyless-node). StationPlacementTests' binary tuning with a
// swarm drawn for every eligible host: every swarm's rail copies a body's orbit.
[Fact]
public void NoSwarmRidesABinarysBodylessNode()
{
using var zones = new ChildrenWithinReachTests.Zones(First, Last, z =>
{
z.SatelliteCreationProbability = 1;
z.BinaryCreationProbability = 1;
z.ZoneRadius.Minimum *= 3; z.ZoneRadius.Maximum *= 3;
z.ZoneMass.Minimum *= .1f; z.ZoneMass.Maximum *= .1f;
z.TrojanProbability = 1;
});
int binaries = 0, swarms = 0, bodyless = 0;
foreach (var (seed, zone) in zones.All)
{
var bodyOrbits = zone.Planets.Values.Select(b => b.Orbit.Key).ToHashSet();
binaries += zone.Orbits.Count(o => o.Value.Data.Parent.IsSet() && !bodyOrbits.Contains(o.Key) && zone.Wells.Reach(o.Key) > 0f);
var bodies = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key].Data).ToList();
foreach (var rail in zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail).Select(b => zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data))
{
swarms++;
if (!bodies.Any(o => o.Parent.Key.Equals(rail.Parent.Key) && o.Distance == rail.Distance && o.Phase == rail.Phase)) bodyless++;
}
}
_out.WriteLine($"zones {zones.All.Count}, bodyless nodes {binaries}, swarms {swarms}, on a bodyless node {bodyless}");
Assert.True(binaries > 5 && swarms > 10, $"{binaries} bodyless nodes and {swarms} swarms: the seeds do not exercise the rule");
Assert.Equal(0, bodyless);
}
[Fact]
public void ASwarmKeepsItsShapeOverAnHour()
{
@ -97,23 +128,25 @@ public class TrojanSwarmTests
Assert.True(onSwarmHost > 0, "no station or turret sits on a swarm host's rail");
}
// The digests were computed at 67517f40, before this cut, over the zones of each seed with the authored settings: a zone
// with no swarm is the base zone, and a zone with swarms is the base zone plus them.
// The widened digests (ZoneFingerprint with PopulatedRadius) were computed on Yggdrasil over the generator of cut laned-belts
// C1 (one belt extent rule) with swarms off, over the zones of each seed; both samples draw no lane, so the base is the zone
// with neither shape: a zone with no swarm is the base zone, and a zone with
// swarms is the base zone plus them. The per-zone comparison leaves PopulatedRadius out, since a swarm's outer rock may grow it.
private static readonly Dictionary<uint, string> BaseDigest = new Dictionary<uint, string>
{
[1] = "02a2bef42b5d4aeaf1e7004d0ab26620ee570ced95115abf98226ab4e0db23eb",
[2] = "de231f392e5724ecdce7e746957633bef2724252147ecbb5830db047b635aead",
[3] = "5c33fe9f748d6b0b34a7bec923216c7f9bbdfdf8f326c59b77fde9ec96ca2bcb",
[4] = "585284386657e4714ad325cb053a78853c5bcaf7eb1a02cbd4826b0e86ad3593",
[5] = "4a1aeecad09c61ff7daa43eda40a9c0109dc4b6833439b8400d8c72d939950c8",
[6] = "7fd3c5844144b73faf05cd6a53fe92274e4455f38da4aaca4624ea13d96a12c3",
[1] = "1873400d50d11b27913a67daca460f0829629aa21dacabac2cfc97a66537a700",
[2] = "5872680ee91c2019b345925f54c2113303cc2817ee9ea28d26ba839e43d1944e",
[3] = "e92cc32010f01b7962a2a58b6ea91c9c39ee3d915301164f176d0ebad218837c",
[4] = "61db9bdae2ca8704a7946debb4fe63ae3e649112fcda19d293ba2fddcaf45674",
[5] = "69ca46ebfae646b3a09faf9d636f3c706e5f6b7146f111a0ea06196332ae58b9",
[6] = "307dc4b38ff341670a5b5179ab977b6c7c59ba682d529d80dcaf9c4852c21fe2",
};
[Fact]
public void ASwarmMovesNothingElseInTheZone()
{
using var swarmless = new ChildrenWithinReachTests.Zones(First, Last, z => z.TrojanProbability = 0);
using var live = new ChildrenWithinReachTests.Zones(First, Last, null);
using var swarmless = new ChildrenWithinReachTests.Zones(First, Last, z => { z.TrojanProbability = 0; z.LaneProbability = 0; });
using var live = new ChildrenWithinReachTests.Zones(First, Last, z => z.LaneProbability = 0);
Assert.Equal(swarmless.All.Count, live.All.Count);
int swarms = 0, grew = 0;
foreach (var seed in BaseDigest.Keys)
@ -121,11 +154,11 @@ public class TrojanSwarmTests
var a = swarmless.All.Where(x => x.Seed == seed).Select(x => x.Zone).ToList();
var b = live.All.Where(x => x.Seed == seed).Select(x => x.Zone).ToList();
Assert.Empty(a.SelectMany(z => z.AsteroidBelts.Where(x => x.Value.Data.RidesRail)));
Assert.Equal(BaseDigest[seed], ZoneFingerprint.Of(a, _ => false, _ => false));
string Swarmed(Zone z) => ZoneFingerprint.Of(new[] { z }, SwarmRails(z).Contains, IsSwarm);
Assert.Equal(BaseDigest[seed], ZoneFingerprint.Of(a, _ => false, _ => false, true));
string Swarmed(Zone z) => ZoneFingerprint.Of(new[] { z }, SwarmRails(z).Contains, IsSwarm, false);
for (var n = 0; n < a.Count; n++)
{
Assert.Equal(ZoneFingerprint.Of(new[] { a[n] }, _ => false, _ => false), Swarmed(b[n]));
Assert.Equal(ZoneFingerprint.Of(new[] { a[n] }, _ => false, _ => false, false), Swarmed(b[n]));
swarms += SwarmRails(b[n]).Count;
Assert.True(b[n].PopulatedRadius >= a[n].PopulatedRadius, $"seed {seed}: a swarm shrank the populated radius");
if (b[n].PopulatedRadius > a[n].PopulatedRadius) grew++;
@ -148,11 +181,11 @@ public class TrojanSwarmTests
var belt = MessagePackSerializer.Deserialize<AsteroidBeltData>(old, options);
Assert.False(belt.RidesRail);
Assert.Equal(3f, belt.Asteroids[0].Distance);
Assert.Equal(7f, new AsteroidBelt(belt).Extent(7f));
Assert.Equal(3f, new AsteroidBelt(belt).Extent(2f));
Assert.Equal(7f, AsteroidBelt.Extent(belt, 7f));
Assert.Equal(3f, AsteroidBelt.Extent(belt, 2f));
var swarm = MessagePackSerializer.Deserialize<AsteroidBeltData>(MessagePackSerializer.Serialize(
new AsteroidBeltData { Asteroids = belt.Asteroids, RidesRail = true }, options), options);
Assert.True(swarm.RidesRail);
Assert.Equal(2f, new AsteroidBelt(swarm).Extent(2f));
Assert.Equal(2f, AsteroidBelt.Extent(swarm, 2f));
}
}

View file

@ -9,29 +9,42 @@ using static CultMath.math;
using float2 = CultMath.float2;
// A digest of what a zone's generation and build decide, so a test can pin it to a value computed at an earlier commit:
// every orbit's (Distance, Phase, Period, Reach, Support), every body's (type, Mass, rail Distance, belt rocks), the entity
// count and GetHeight at three points round each non-belt body. Cache keys and names are left out; they are minted, not decided.
// every orbit's (Distance, Phase, Period, Reach, Support); every body's (type, Mass, rail Distance) and each belt rock's
// (Distance, Phase, Size, RotationSpeed, Period); every orbital entity's (type, position, rail Distance and Phase) and any other
// entity's type alone (a ship packed at the origin is scattered from the clock-seeded item stream, Zone.cs:276); the pack radius;
// GetHeight on a 9x9 grid over the pack at t 0 and 777.7; and, when asked, PopulatedRadius. Cache keys and names are left
// out; they are minted, not decided.
public static class ZoneFingerprint
{
public static string Of(IEnumerable<Zone> zones, Func<CultRecordKey, bool> skipOrbit, Func<BodyData, bool> skipBody)
public static string Of(IEnumerable<Zone> zones, Func<CultRecordKey, bool> skipOrbit, Func<BodyData, bool> skipBody, bool populatedRadius)
{
var text = new StringBuilder();
foreach (var zone in zones) text.Append(Describe(zone, skipOrbit, skipBody)).Append('\n');
foreach (var zone in zones) text.Append(Describe(zone, skipOrbit, skipBody, populatedRadius)).Append('\n');
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text.ToString()))).ToLowerInvariant();
}
private static string Describe(Zone z, Func<CultRecordKey, bool> skipOrbit, Func<BodyData, bool> skipBody)
private static string Describe(Zone z, Func<CultRecordKey, bool> skipOrbit, Func<BodyData, bool> skipBody, bool populatedRadius)
{
string F(float v) => v.ToString("R", System.Globalization.CultureInfo.InvariantCulture);
string F(double v) => v.ToString("R", System.Globalization.CultureInfo.InvariantCulture);
var orbits = z.Orbits.Where(o => !skipOrbit(o.Key))
.Select(o => $"{F(o.Value.Data.Distance)},{F(o.Value.Data.Phase)},{F(o.Value.Period)},{F(z.Wells.Reach(o.Key))},{F(z.Wells.Support(o.Key))}")
.OrderBy(x => x, StringComparer.Ordinal);
var bodies = z.Planets.Values.Where(b => !skipBody(b))
.Select(b => $"{b.GetType().Name},{F(b.Mass)},{F(z.Orbits[b.Orbit.Key].Data.Distance)}," +
(b is AsteroidBeltData belt ? string.Join("|", belt.Asteroids.Select(r => $"{F(r.Distance)}/{F(r.Phase)}/{F(r.Size)}")) : ""))
var bodies = z.Planets.Where(b => !skipBody(b.Value))
.Select(b => $"{b.Value.GetType().Name},{F(b.Value.Mass)},{F(z.Orbits[b.Value.Orbit.Key].Data.Distance)}," +
(b.Value is AsteroidBeltData belt ? string.Join("|", belt.Asteroids.Select((r, i) =>
$"{F(r.Distance)}/{F(r.Phase)}/{F(r.Size)}/{F(r.RotationSpeed)}/{F(z.AsteroidBelts[b.Key].Periods[i])}")) : ""))
.OrderBy(x => x, StringComparer.Ordinal);
var heights = z.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => z.Orbits[b.Orbit.Key].Position)
.SelectMany(p => new[] { float2(0, 0), float2(37, 0), float2(0, -211) }.Select(o => F(z.GetHeight(p + o))));
return string.Join(";", orbits) + "#" + string.Join(";", bodies) + "#" + z.Entities.Count() + "#" + string.Join(",", heights);
var entities = z.Entities.Select(e => e is OrbitalEntity o
? $"{e.GetType().Name},{F(e.Position.x)},{F(e.Position.y)},{F(e.Position.z)},{F(z.Orbits[o.OrbitData].Data.Distance)},{F(z.Orbits[o.OrbitData].Data.Phase)}"
: e.GetType().Name)
.OrderBy(x => x, StringComparer.Ordinal);
var r = z.Pack.Radius;
var heights = new List<string>();
foreach (var t in new[] { 0.0, 777.7 })
for (var i = -4; i <= 4; i++)
for (var j = -4; j <= 4; j++)
heights.Add(F(z.GetHeight(float2(i * r / 4f, j * r / 4f), t)));
return string.Join(";", orbits) + "#" + string.Join(";", bodies) + "#" + string.Join(";", entities) + "#" + F(r) + "#" +
string.Join(",", heights) + (populatedRadius ? "#" + F(z.PopulatedRadius) : "");
}
}

View file

@ -24,7 +24,7 @@ from mathutils import Matrix, Vector
from . import hull_grid, hull_source
from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint,
encode_hardpoint, layout_revision, read, read_layout, replace_layout, replace_lines,
encode_hardpoint, layout_revision, read, read_hull_type, read_layout, replace_layout, replace_lines,
product_makers, replace_visual, valid_ship_id)
MODEL_ASSET = "ship.glb"
@ -1557,8 +1557,10 @@ def _hull_mesh(name, mesh, material, offset=(0.0, 0.0, 0.0), yaw=0.0):
def _build_hull_source(context, collection):
"""Replaces the objects Build made before with the hull source's zone meshes, anchors and collider under Ship Root.
Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language, or the
collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout row."""
Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language (whose
proportions judge a hull whose ship.cc HullData.HullType is Ship, and no other), its zones overflow the layout grid,
or the collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout
row."""
source = _hull_source_file(collection)
if source is None:
raise ValueError("The bound package holds no hull.scad")
@ -1573,7 +1575,9 @@ def _build_hull_source(context, collection):
"changed; Build From Hull Source replaces only what it made and nobody touched")
addon = context.preferences.addons.get(__package__)
executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD
hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable))
hull_type = read_hull_type(bpy.path.abspath(collection["aetheria.ship_cc"]), _brokkr_cultlib(context))
hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable),
hull_type)
broken = hull.failed()
if broken:
raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken))

View file

@ -32,6 +32,27 @@ def _inside(triangle, px, py):
return not ((d1 < 0 or d2 < 0 or d3 < 0) and (d1 > 0 or d2 > 0 or d3 > 0))
def _with_area(triangles_xy):
triangles = [tri for tri in triangles_xy if
(tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) != (tri[1][1] - tri[0][1]) * (tri[2][0] - tri[0][0])]
if not triangles:
raise ValueError("The hull has no triangle with area to rasterise")
return triangles
def _size(triangles, cell_size):
xs = [p[0] for tri in triangles for p in tri]
ys = [p[1] for tri in triangles for p in tri]
return _cells_across(max(xs) - min(xs), cell_size), _cells_across(max(ys) - min(ys), cell_size), xs, ys
def grid_size(triangles_xy, cell_size=CELL_SIZE):
"""(width, height) in cells of the grid rasterise would lay over these triangles, without refusing a grid over
MAX_CELLS: the caller judges the bound. Raises ValueError when no triangle has area."""
width, height, _, _ = _size(_with_area(triangles_xy), cell_size)
return width, height
def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES):
"""Occupied cells of a top-down silhouette given as triangles of (x, y) points in the Ship Root frame.
@ -40,14 +61,8 @@ def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES):
inside several triangles counts once, and each triangle is tested only at the samples inside its bounding box.
Returns (width, height, cells, grid_origin): cells are column-major, cells[x * height + y], and grid_origin is cell
(0, 0)'s centre."""
triangles = [tri for tri in triangles_xy if
(tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) != (tri[1][1] - tri[0][1]) * (tri[2][0] - tri[0][0])]
if not triangles:
raise ValueError("The hull has no triangle with area to rasterise")
xs = [p[0] for tri in triangles for p in tri]
ys = [p[1] for tri in triangles for p in tri]
width = _cells_across(max(xs) - min(xs), cell_size)
height = _cells_across(max(ys) - min(ys), cell_size)
triangles = _with_area(triangles_xy)
width, height, xs, ys = _size(triangles, cell_size)
for axis, count in (("wide", width), ("long", height)):
if count > MAX_CELLS:
raise ValueError(f"The hull needs {count} cells {axis}; a ship grid holds at most {MAX_CELLS}")

View file

@ -14,11 +14,15 @@ with it (a hull that redefines or shadows an included language fails a check):
<zone> the zone's geometry, OFF; a donor's zone is named "<donor>:<zone>"
emit:<id>, rad:<id> the mesh of a thruster-emitter or radiator-mesh anchor
Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() returns a HullBuild; the caller refuses on any failed
check. Messages and check names are fixed text: nothing a hull source says is echoed back.
Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship",
"Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag.
A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS
cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the
caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back.
"""
import hashlib
import importlib.util
import json
import math
import os
@ -29,6 +33,16 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional
if __package__:
from . import hull_grid, ship_cc
else: # loaded by path (tests, the bench): load the siblings the same way
def _sibling(name):
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f"{name}.py"))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
hull_grid, ship_cc = _sibling("hull_grid"), _sibling("ship_cc")
FACTIONS_DIR = Path(__file__).with_name("factions")
HULL_FILE = "hull.scad"
OPENSCAD_TIMEOUT = 180 # seconds, per part
@ -113,6 +127,7 @@ class HullBuild:
glow: tuple
radiator: tuple
body_zone: str # the zone key the collider is made from
hull_type: str # HullData.HullType of the bound package ("Ship", "Station", "Turret"), as the caller read it
checks: list = field(default_factory=list)
def failed(self):
@ -274,10 +289,14 @@ def _language(library, faction):
return language
def build(runner, library):
def build(runner, library, hull_type):
"""Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with
(the ECHO text of that faction's own language file). Raises HullSourceError when the source cannot be read at all;
otherwise returns a HullBuild whose failed() lists the checks it breaks."""
(the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller
reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when
hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a
HullBuild whose failed() lists the checks it breaks."""
if not (type(hull_type) is str and hull_type in ship_cc.HULL_TYPE_NAMES):
raise HullSourceError("The hull type is not a HullData.HullType name (Ship, Station or Turret)")
_, text = runner("brief")
brief = read_brief(text)
_, text = runner("lang")
@ -298,7 +317,7 @@ def build(runner, library):
judge = donor or own
body_zone = f"{donor.faction}:{donor.body}" if donor else own.body
result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes,
own.glow, own.radiator, body_zone)
own.glow, own.radiator, body_zone, hull_type)
by_faction = lambda language: language.faction
result.checks = [Check("the hull includes its faction's language files and changes none of them",
sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")]
@ -314,8 +333,9 @@ def _checks(hull, own, judge):
checks.append(Check("every emitter and radiator anchor has a mesh", not missing, f"{len(missing)} without"))
checks.append(Check("a donor is named exactly when the language has no proportions of its own",
bool(brief.donor) == (own.proportion is None), ""))
# A language's proportions describe its ships; a station or turret is not held to them, and no check is listed.
body = hull.zones[hull.body_zone] if hull.body_zone in hull.zones else Mesh((), ())
if body.triangles and judge.proportion:
if hull.hull_type == "Ship" and body.triangles and judge.proportion:
low, high = body.bounds()
width, length, height = (high[0] - low[0]), (high[1] - low[1]), (high[2] - low[2])
ratios = (height / width if width else float("inf"), width / length if length else float("inf"))
@ -323,7 +343,7 @@ def _checks(hull, own, judge):
checks.append(Check("the body's proportions lie inside the language's ranges",
hw_lo <= ratios[0] <= hw_hi and wl_lo <= ratios[1] <= wl_hi,
f"h/w {ratios[0]:.2f}, w/l {ratios[1]:.2f}"))
else:
elif hull.hull_type == "Ship":
checks.append(Check("the body's proportions lie inside the language's ranges", False, "no body to judge"))
every = [m for m in list(hull.zones.values()) + list(hull.anchor_meshes.values()) if m.triangles]
if every:
@ -338,6 +358,14 @@ def _checks(hull, own, judge):
else:
checks.append(Check("the top view obeys the language's symmetry rule", False, "no geometry"))
checks.append(Check("the length lies inside the brief's range", False, "no geometry"))
# Rasterise reads the zone meshes only, so the grid bound is judged over them, not over anchor meshes.
try:
width, height = hull_grid.grid_size([t for m in hull.zones.values() for t in _triangles_xy(m)])
checks.append(Check("the top view fits the layout grid",
width <= hull_grid.MAX_CELLS and height <= hull_grid.MAX_CELLS,
f"{width} x {height} cells, at most {hull_grid.MAX_CELLS} a side"))
except ValueError:
checks.append(Check("the top view fits the layout grid", False, "no geometry"))
triangles = sum(len(m.triangles) for m in every)
checks.append(Check("the triangle count lies inside the brief's budget",
brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}"))

View file

@ -16,6 +16,14 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, NamedTuple
if __package__:
from . import hull_grid
else: # loaded by path (tests, hull_source loaded by path): load the sibling the same way
import importlib.util
_grid_spec = importlib.util.spec_from_file_location("hull_grid", Path(__file__).with_name("hull_grid.py"))
hull_grid = importlib.util.module_from_spec(_grid_spec)
_grid_spec.loader.exec_module(hull_grid)
# The cultcache-py this module's raw envelope writes were tested against. cultcache_py has no __version__ and is loaded
# from source, so the guard reads [project].version of the checkout the imported module itself came from.
TESTED_CULTCACHE_PY = "0.3.0"
@ -34,15 +42,17 @@ ANCHORS_SLOT = 3 # ShipAuthoring.Anchors
SCHEMATIC_LINES_SLOT = 4 # ShipAuthoring.SchematicLines
HULL_SHAPE_SLOT = 5 # ItemData.Shape
HULL_HARDPOINTS_SLOT = 23 # HullData.Hardpoints
HULL_TYPE_SLOT = 25 # HullData.HullType
# HardpointData member names in slot order; a hardpoint row is one value per name, and any slot past the last
# name belongs to a later schema and is carried through untouched.
HARDPOINT_MEMBERS = ("Type", "Position", "Shape", "Transform", "Rotation", "Armor", "FiringArc")
# ShipAnchor member names in slot order; as for hardpoints, slots past the last name are carried through by anchor Id.
ANCHOR_MEMBERS = ("Id", "Role", "ModelNodeId", "ParentId", "Order")
# Enum member names in value order (HardpointType, ItemRotation).
# Enum member names in value order (HardpointType, ItemRotation, HullType).
HARDPOINT_TYPE_NAMES = ("Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Reactor", "Radiator",
"Shield", "Sensors", "Energy", "Ballistic", "Launcher", "ControlModule")
ROTATION_NAMES = ("None", "CounterClockwise", "Reversed", "Clockwise")
HULL_TYPE_NAMES = ("Ship", "Station", "Turret")
# ShipAuthoringStore.RequireShipId's rule, mirrored so New Ship refuses before it touches anything; ShipSchemaPinTests
@ -208,6 +218,15 @@ def read_layout(path: str, cultlib_packages: str):
return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT])
def read_hull_type(path: str, cultlib_packages: str) -> str:
"""The hull's HullData.HullType as its enum member name ("Ship", "Station" or "Turret")."""
hull = _hull_body(read(path, cultlib_packages))
value = hull[HULL_TYPE_SLOT] if len(hull) > HULL_TYPE_SLOT else None
if type(value) is not int or not 0 <= value < len(HULL_TYPE_NAMES):
raise ValueError("The ship file's hull has no hull type this add-on knows")
return HULL_TYPE_NAMES[value]
def layout_revision(shape: list[Any], hardpoints: list[list[Any]]) -> str:
"""The revision of a layout: a hash of its shape ([width, height, cells]) and hardpoint rows as the file stores
them. replace_layout returns it, Load records it, and Package compares the layout buffer's against the file's."""
@ -224,8 +243,9 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_
if layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT]) != expected_revision:
raise ValueError("The .cc layout changed since Load; reload before saving")
width, height, cells = shape
if not (1 <= width <= 32 and 1 <= height <= 32 and len(cells) == width * height):
raise ValueError("Hull grid must be 1..32 cells wide and high with one value per cell")
limit = hull_grid.MAX_CELLS
if not (1 <= width <= limit and 1 <= height <= limit and len(cells) == width * height):
raise ValueError(f"Hull grid must be 1..{limit} cells wide and high with one value per cell")
if not all(type(cell) is bool for cell in cells):
raise ValueError("Hull grid cells must be booleans")
if not any(cells):

View file

@ -38,6 +38,8 @@ the hull.scad into the package and runs the panel's operators:
changes nothing in the scene. Her art needs no product.
Build and Package refuse copies that share an aetheria.id, selecting the original and the copies, and delete nothing.
Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw.
The ship.cc HullData.HullType decides whether the language's proportions judge the hull: a body far wider than the
language's ships is refused as Ship and built as Station, and a Station wider than the layout grid is refused.
"""
import dataclasses
@ -698,6 +700,66 @@ def agent_guards(scene):
return collection
def hull_type_gate(scene):
"""The hull's HullData.HullType in ship.cc decides whether the language's proportions judge it; every hull type must
fit the layout grid. A body far wider than a Zhestokost ship is refused as Ship, built as Station, and a Station
wider than 32 cells is refused on the grid."""
fixture, _, extent, length, rows, *_ = HULLS[0]
ship_id = "smoke.hull.role"
collection, box, package = make_ship(scene, fixture, ship_id, extent, length, rows)
ship_module = sys.modules["aetheria_ships.ship_cc"]
path = str(package / "ship.cc")
source = package / "hull.scad"
original = source.read_text()
slab = '\nif (part == "primary") translate([-%d, -2, 0]) cube([%d, 4, 1]);\n'
source.write_text(original + slab % (15, 30))
judged = []
real = hull_source.build
def recorded(*args):
judged.append(real(*args))
return judged[-1]
sys.modules["aetheria_ships"].hull_source.build = recorded
try:
require(ship_module.read_hull_type(path, PACKAGES) == "Ship", "New Ship did not write a Ship hull")
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"CANCELLED"} and "the body's proportions lie inside the language's ranges" in message,
f"A Ship far wider than its language allows was built: {message!r}")
print("PASS 5.1 a body wider than the language's ships is refused as Ship, on proportion")
store = ship_module.read(path, PACKAGES).store
for envelope in store.pull_all():
if envelope.type == ship_module.HULL_SCHEMA:
values = msgpack.unpackb(envelope.payload, raw=False)
values[ship_module.HULL_TYPE_SLOT] = 1
store.push(dataclasses.replace(envelope, payload=msgpack.packb(values, use_bin_type=True)))
require(ship_module.read_hull_type(path, PACKAGES) == "Station", "The pushed HullType did not read back as Station")
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
names = [c.name for c in judged[-1].checks]
require(result == {"FINISHED"} and judged[-1].hull_type == "Station"
and "the body's proportions lie inside the language's ranges" not in names
and "the top view fits the layout grid" in names,
f"The same body as Station was refused or judged on proportion: {message!r} {names}")
print("PASS 5.2 the same body as Station is built, with no proportion check and a grid check")
built = set(generated(collection))
source.write_text(original + slab % (35, 70))
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"CANCELLED"} and "the top view fits the layout grid" in message
and "the body's proportions lie inside the language's ranges" not in message
and set(generated(collection)) == built,
f"A Station 70 m wide was not refused on the layout grid, or the refusal changed the scene: {message!r}")
print("PASS 5.3 a Station 70 m wide is refused on the layout grid, not on proportion")
finally:
sys.modules["aetheria_ships"].hull_source.build = real
print("HULL_TYPE_GATE_OK", flush=True)
return collection
def main():
require(bpy.app.version[:2] == (5, 2), f"This smoke is for Blender 5.2; this is {bpy.app.version_string}")
bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True)
@ -733,6 +795,7 @@ def main():
previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette, maker, other)
clear(previous)
clear(agent_guards(scene))
clear(hull_type_gate(scene))
never_built(scene)
@ -750,7 +813,7 @@ else:
print("SMOKE_HULL_SOURCE_OK", flush=True)
code = 0
finally:
for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"), (None, "smoke.hull.guards")):
for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"), (None, "smoke.hull.guards"), (None, "smoke.hull.role")):
shutil.rmtree(MODS / ship_id, ignore_errors=True)
if created:
shutil.rmtree(MODS, ignore_errors=True)

View file

@ -136,6 +136,16 @@ class RasteriseTests(unittest.TestCase):
hull_grid.rasterise(rect(0, 0, 66, 2))
self.assertEqual(str(caught.exception), "The hull needs 33 cells wide; a ship grid holds at most 32")
def test_grid_size_is_rasterises_size_and_does_not_refuse_an_oversized_grid(self):
self.assertEqual(hull_grid.grid_size(rect(0, 0, 2, 64)), hull_grid.rasterise(rect(0, 0, 2, 64))[:2])
self.assertEqual(hull_grid.grid_size(rect(-3, -1, 3, 4) + CORNER), hull_grid.rasterise(rect(-3, -1, 3, 4) + CORNER)[:2])
self.assertEqual(hull_grid.grid_size(rect(0, 0, 2, 66)), (1, 33))
self.assertEqual(hull_grid.grid_size(rect(0, 0, 66, 2)), (33, 1))
self.assertEqual(hull_grid.grid_size(rect(0, 0, 4, 4), cell_size=1.0), (4, 4))
with self.assertRaises(ValueError) as caught:
hull_grid.grid_size([((0, 0), (1, 1), (2, 2))])
self.assertEqual(str(caught.exception), "The hull has no triangle with area to rasterise")
def test_a_silhouette_with_no_area_is_refused(self):
for triangles in ([], [((0, 0), (1, 1), (2, 2))]):
with self.assertRaises(ValueError) as caught:

View file

@ -57,6 +57,7 @@ class Fixture:
def __init__(self):
self.library = {"zhestokost": language()}
self.hull_lang = None
self.hull_type = "Ship" # HullData.HullType, which the caller reads from the package
# 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()),
@ -77,7 +78,7 @@ class Fixture:
return self.library[faction]
def build(self):
return hs.build(self.runner(), self.read_library)
return hs.build(self.runner(), self.read_library, self.hull_type)
def failed(self):
return [c.name for c in self.build().failed()]
@ -128,6 +129,103 @@ class ReadingTests(unittest.TestCase):
hs.read_languages(language(body="keel"))
PROPORTION = "the body's proportions lie inside the language's ranges"
GRID = "the top view fits the layout grid"
def names(built):
return [c.name for c in built.checks]
class HullTypeTests(unittest.TestCase):
"""A language's proportions judge Ship hulls only; every other rule, and the layout grid, judges every hull type."""
@staticmethod
def fixture(hull_type, **parts):
fixture = Fixture()
fixture.hull_type = hull_type
fixture.parts.update({k: (v, "") for k, v in parts.items()})
return fixture
def test_a_station_or_turret_is_not_held_to_the_languages_proportions_and_lists_no_proportion_check(self):
wide = box(-12, 12, -5, 5, 0, 2) # h/w 0.08, w/l 2.4: outside both of Zhestokost's ranges
for hull_type in ("Station", "Turret"):
with self.subTest(hull_type):
built = self.fixture(hull_type, primary=wide).build()
self.assertEqual([c.name for c in built.failed()], [])
self.assertNotIn(PROPORTION, names(built))
self.assertEqual(built.hull_type, hull_type)
self.assertEqual(self.fixture("Ship", primary=wide).failed(), [PROPORTION])
def test_a_station_with_no_body_lists_no_proportion_check_but_a_ship_fails_it(self):
self.assertNotIn(PROPORTION, names(self.fixture("Station", primary="").build()))
ship = self.fixture("Ship", primary="").build()
self.assertEqual([c.detail for c in ship.checks if c.name == PROPORTION], ["no body to judge"])
def test_a_ship_lists_the_proportion_check_when_it_passes(self):
built = self.fixture("Ship").build()
self.assertEqual([c.ok for c in built.checks if c.name == PROPORTION], [True])
def test_a_station_is_still_judged_on_symmetry_length_and_budget(self):
self.assertEqual(self.fixture("Station", secondary=box(2, 6, -1, 1, 2, 3)).failed(),
["the top view obeys the language's bilateral rule"])
self.assertEqual(self.fixture("Station", primary=box(-3, 3, -10, 10, 0, 2)).failed(),
["the length lies inside the brief's range"])
station = self.fixture("Station")
station.parts["brief"] = ("", brief(triangles=(10, 20)))
self.assertEqual(station.failed(), ["the triangle count lies inside the brief's budget"])
def test_every_hull_type_must_fit_the_layout_grid_at_thirty_two_cells_a_side(self):
for hull_type in ("Ship", "Station", "Turret"):
with self.subTest(hull_type):
fits = self.fixture(hull_type, secondary=box(-32, 32, -1, 1, 2, 3)).build()
self.assertEqual([(c.ok, c.detail) for c in fits.checks if c.name == GRID],
[(True, "32 x 5 cells, at most 32 a side")])
wide = self.fixture(hull_type, secondary=box(-33, 33, -1, 1, 2, 3))
self.assertEqual(wide.failed(), [GRID])
self.assertEqual([c.detail for c in wide.build().checks if c.name == GRID],
["33 x 5 cells, at most 32 a side"])
long = self.fixture("Station", secondary=box(-1, 1, -32, 32, 2, 3))
long.parts["brief"] = ("", brief(length=(8, 70)))
self.assertEqual(long.failed(), [])
self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 32 cells, at most 32 a side"])
long = self.fixture("Station", secondary=box(-1, 1, -33, 33, 2, 3))
long.parts["brief"] = ("", brief(length=(8, 70)))
self.assertEqual(long.failed(), [GRID])
self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 33 cells, at most 32 a side"])
def test_the_grid_reads_zone_meshes_only_not_anchor_meshes(self):
built = self.fixture("Station", **{"emit:Th.A": box(-35, 35, 5, 5.1, 0.5, 1.5)}).build()
self.assertEqual([c.name for c in built.failed()], [])
self.assertEqual([c.detail for c in built.checks if c.name == GRID], ["3 x 5 cells, at most 32 a side"])
def test_a_hull_with_no_zone_geometry_fails_the_grid_check(self):
built = self.fixture("Station", primary="", secondary="").build()
self.assertEqual([(c.ok, c.detail) for c in built.checks if c.name == GRID], [(False, "no geometry")])
def test_build_has_no_default_hull_type(self):
fixture = Fixture()
with self.assertRaises(TypeError):
hs.build(fixture.runner(), fixture.read_library)
def test_build_refuses_a_hull_type_that_is_not_a_hulltype_name_before_reading_the_source(self):
for hull_type in ("ship", "SHIP", "Ship ", "", None, "Stations", 0, SECRET):
with self.subTest(hull_type=hull_type):
fixture, read = Fixture(), []
runner = fixture.runner()
with self.assertRaises(hs.HullSourceError) as caught:
hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type)
self.assertEqual(read, [])
self.assertNotIn(SECRET, str(caught.exception))
def test_build_accepts_each_hulltype_name_and_only_a_ship_lists_the_proportion_check(self):
for hull_type in ("Ship", "Station", "Turret"):
with self.subTest(hull_type):
built = self.fixture(hull_type).build()
self.assertEqual([c.name for c in built.failed()], [])
self.assertEqual(PROPORTION in names(built), hull_type == "Ship")
class MirrorOverlapTests(unittest.TestCase):
@staticmethod
def top(text):
@ -381,7 +479,7 @@ class LanguageOwnershipTests(unittest.TestCase):
def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self):
asked = []
fixture = DonorTests.pirate()
hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction))
hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship")
self.assertEqual(sorted(asked), ["pirate", "rnd"])

View file

@ -15,6 +15,7 @@ import shutil
import sys
import tempfile
import unittest
from unittest import mock
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
@ -44,8 +45,9 @@ def hardpoint_row(mount="thruster.port", tail=("future-hardpoint-slot",)):
def hull_body():
hull = [None] * (ship_cc.HULL_HARDPOINTS_SLOT + 1)
hull = [None] * (ship_cc.HULL_TYPE_SLOT + 1)
hull[1] = "Skiff"
hull[ship_cc.HULL_TYPE_SLOT] = 0
hull[ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, False, True, False])]
hull[ship_cc.HULL_HARDPOINTS_SLOT] = [hardpoint_row()]
hull.append("future-hull-slot")
@ -128,6 +130,28 @@ class ReadTests(ShipFileCase):
self.assertEqual("Skiff", file.hull.body[1])
self.assertEqual(ship_cc.HULL_SCHEMA, file.hull.envelope.type)
def test_reads_the_hulls_type_by_its_enum_name(self):
for value, name in ((0, "Ship"), (1, "Station"), (2, "Turret")):
with self.subTest(name):
hull = hull_body()
hull[ship_cc.HULL_TYPE_SLOT] = value
self.write(ship_body(), hull)
self.assertEqual(name, ship_cc.read_hull_type(self.path, PACKAGES))
def test_refuses_a_hull_type_it_does_not_know_without_echoing_the_path(self):
short = hull_body()[:ship_cc.HULL_TYPE_SLOT]
for label, value in (("past the enum", 3), ("negative", -1), ("bool", True), ("missing", None),
("text", "Ship"), ("short body", short)):
with self.subTest(label):
hull = value if label == "short body" else hull_body()
if label != "short body":
hull[ship_cc.HULL_TYPE_SLOT] = value
self.write(ship_body(), hull)
with self.assertRaises(ValueError) as caught:
ship_cc.read_hull_type(self.path, PACKAGES)
self.assertEqual("The ship file's hull has no hull type this add-on knows", str(caught.exception))
self.assertNotIn(self.path, str(caught.exception))
def test_reads_a_file_whose_hull_also_carries_products(self):
self.add_products()
file = ship_cc.read(self.path, PACKAGES)
@ -520,6 +544,16 @@ class LayoutTests(ShipFileCase):
revision = self.layout()[2]
self.assertNotEqual(before, self.bytes())
def test_the_grid_limit_is_hull_grids_max_cells(self):
_, _, revision = self.layout()
before = self.bytes()
with self.assertRaisesRegex(ValueError, f"1..{ship_cc.hull_grid.MAX_CELLS} cells"):
self.edit(revision, width=33, height=1, cells=[True] * 33)
self.assertEqual(before, self.bytes())
with mock.patch.object(ship_cc.hull_grid, "MAX_CELLS", 33):
self.edit(revision, width=33, height=1, cells=[True] * 33, hardpoints=[])
self.assertEqual([33, 1, [True] * 33], self.layout()[0])
def test_a_layout_save_refreshes_the_stored_at_of_the_hull_and_leaves_the_ships(self):
stale = "2000-01-01T00:00:00+00:00"
envelopes = self.envelopes()