Merge remote-tracking branch 'origin/master' into eureka/aetheria-gravity-laned-belts

This commit is contained in:
soul 2026-10-22 02:03:21 +02:00
commit bbb1dc717e
9 changed files with 301 additions and 27 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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