diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index 78735dab3..46bee7272 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7a0a0d2e7b8367e7c402975e5683434068f99b8c13c848764bbac39b43886411 -size 12477 +oid sha256:7f09c54dfdb87dffd76fa92365d62c53ce333457b89ccf8ead09093ffffedc42 +size 12525 diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 980828e53..5a95f450d 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -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; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 2a7b043f4..f2276f4e4 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -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(); 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 diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 412b0e9ab..dfe89917c 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -118,6 +118,7 @@ public static class ZoneGenerator } pack.Orbits = planets.Select(planet => cache.RefOf(orbitMap[planet])).ToList(); + var beltOf = new Dictionary(); 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(); 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(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 Children = new List(); // Planets orbiting this one are referred to as children diff --git a/tests/Aetheria.Shared.Tests/BeltShapes.cs b/tests/Aetheria.Shared.Tests/BeltShapes.cs new file mode 100644 index 000000000..5322b1f0a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/BeltShapes.cs @@ -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); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index 40b2f591b..ccf3fae29 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -19,6 +19,8 @@ public sealed class ChildrenWithinReachTests : IClassFixture 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()) + { + 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 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 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 Placed(Zone zone) { var placed = new HashSet(); - 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 ChildrenOf(Zone zone, CultRecordKey parent, HashSet placed) => + internal static List ChildrenOf(Zone zone, CultRecordKey parent, HashSet 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 !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 !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 !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= 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"); + internal static PlanetSettings AuthoredPlanetSettings() => AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("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("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] diff --git a/tests/Aetheria.Shared.Tests/LanedBeltTests.cs b/tests/Aetheria.Shared.Tests/LanedBeltTests.cs new file mode 100644 index 000000000..0372d3491 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LanedBeltTests.cs @@ -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 +{ + 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); + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index 6316241ad..760595dcc 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -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(), Strings("HARDPOINT_TYPE_NAMES")); Assert.Equal(EnumNames(), Strings("ROTATION_NAMES")); + Assert.Equal(EnumNames(), Strings("HULL_TYPE_NAMES")); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs index 66caa331c..b265ead4d 100644 --- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs +++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs @@ -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().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(); 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++; } diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs index 8500ff983..3da5e77ab 100644 --- a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs +++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs @@ -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 BaseDigest = new Dictionary { - [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(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(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)); } } diff --git a/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs b/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs index 3abc9ebc1..6b40431c5 100644 --- a/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs +++ b/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs @@ -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 zones, Func skipOrbit, Func skipBody) + public static string Of(IEnumerable zones, Func skipOrbit, Func 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 skipOrbit, Func skipBody) + private static string Describe(Zone z, Func skipOrbit, Func 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(); + 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) : ""); } } diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 90d1c9ad7..17ad00afc 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -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)) diff --git a/tools/blender/aetheria_ships/hull_grid.py b/tools/blender/aetheria_ships/hull_grid.py index e408105be..3752fb5e7 100644 --- a/tools/blender/aetheria_ships/hull_grid.py +++ b/tools/blender/aetheria_ships/hull_grid.py @@ -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}") diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index 50a932e0d..fb2aa246a 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -14,11 +14,15 @@ with it (a hull that redefines or shadows an included language fails a check): the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor -Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() returns a HullBuild; the caller refuses on any failed -check. Messages and check names are fixed text: nothing a hull source says is echoed back. +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}")) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 7cc093389..079296be4 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -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): diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 8bb9b9c48..06f2db715 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -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) diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py index 06667c0f2..ecc79b9fc 100644 --- a/tools/blender/tests/test_hull_grid.py +++ b/tools/blender/tests/test_hull_grid.py @@ -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: diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 362664de7..bd534d83b 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -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"]) diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 67facc162..828352438 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -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()