Merge remote-tracking branch 'origin/master' into eureka/aetheria-arena-command-seam-agents
This commit is contained in:
commit
b519fbd3d0
19 changed files with 778 additions and 85 deletions
BIN
Assets/Resources/Settings.asset
(Stored with Git LFS)
BIN
Assets/Resources/Settings.asset
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
32
tests/Aetheria.Shared.Tests/BeltShapes.cs
Normal file
32
tests/Aetheria.Shared.Tests/BeltShapes.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
220
tests/Aetheria.Shared.Tests/LanedBeltTests.cs
Normal file
220
tests/Aetheria.Shared.Tests/LanedBeltTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) : "");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue