From f121b464635d3e53c7b7036fa2cd8bdd1b6c6ffb Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:44:15 +0200 Subject: [PATCH] Package's art exemption keys on Build's maker record, not hull.scad; case-variant Manufacturer and attack maker keys pinned in smoke_hull_source Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 19 +++++---- tools/blender/tests/smoke_hull_source.py | 53 ++++++++++++++++++++---- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index b47860f1d..9d9a57cbf 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -1625,10 +1625,11 @@ def _build_hull_source(context, collection): def _art_takes_over(collection): - """Whether the body is the operator's: a hull.scad and a Ship Root exist, the collection holds render meshes that - Build did not make as they stand, and no zone mesh of Build's is left.""" + """Whether the body is the operator's: Build's maker record and a Ship Root exist, the collection holds render meshes + that Build did not make as they stand, and no zone mesh of Build's is left. The record alone says the hull was + Build-made; hull.scad is not consulted.""" root = _ship_root(collection) - if _hull_source_file(collection) is None or root is None: + if HULL_MAKER not in collection or root is None: return False untouched, _ = _hull_built(collection) zones = [obj for obj in untouched if obj.type == "MESH" and "aetheria.role" not in obj] @@ -1636,12 +1637,14 @@ def _art_takes_over(collection): def _retire_hull_source(collection): - """Deletes the package's hull.scad when the body is the operator's (_art_takes_over). The anchors and collider she - kept are hers from then on. A hull never built, or built and packaged as it stands, keeps its source. Returns whether - the file was deleted.""" + """Deletes Build's records, and the package's hull.scad if it is still there, when the body is the operator's + (_art_takes_over). The anchors and collider she kept are hers from then on. A hull never built, or built and + packaged as it stands, keeps its source. Returns whether art took over.""" if not _art_takes_over(collection): return False - _hull_source_file(collection).unlink() + source = _hull_source_file(collection) + if source is not None: + source.unlink() for record in (HULL_BUILT, HULL_MAKER): if record in collection: del collection[record] @@ -1738,7 +1741,7 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): return {"CANCELLED"} scene.aetheria_package_report = f"Packaged {len(anchors)} anchors; the validator accepted the ship" if _retire_hull_source(collection): - scene.aetheria_package_report += "; deleted hull.scad: this package ships your art" + scene.aetheria_package_report += "; retired Build's hull records: this package ships your art" self.report({"INFO"}, scene.aetheria_package_report) return {"FINISHED"} diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 6385f4fae..fcd24407e 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -23,8 +23,9 @@ the hull.scad into the package and runs the panel's operators: or a generated zone or mount that was edited in place. Package accepts the built ship (the C# validator), writes ship.glb with a node per anchor, and leaves hull.scad; a Build after Package still works. - The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package deletes hull.scad in - that one act and says so, and Build then refuses. + The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package retires Build's + records, and deletes hull.scad if it is still there (the second hull has had it removed by hand), in that one act and + says so, and Build then refuses. Build's maker record alone decides that a hull is Build-made and under the guard. A hull that was never built keeps its hull.scad through the same deletion rule, and Package refuses it (no maker was judged). Package refuses an agent-built hull before it writes anything (ship.glb is not made) when the package holds no product, @@ -35,6 +36,7 @@ the hull.scad into the package and runs the panel's operators: Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw. """ +import dataclasses import json import math import os @@ -47,6 +49,7 @@ from pathlib import Path import addon_utils import bmesh import bpy +import msgpack REPO = Path(__file__).resolve().parents[3] PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory") @@ -75,17 +78,23 @@ HULLS = ( # per fixture: the variable the hull's language is in, a value that redefines it (every rule loosened), the language file a -# copy of which beside hull.scad would shadow the include, and a colour in it +# copy of which beside hull.scad would shadow the include, and a colour in it. %MAKER% is the maker key the faction's own +# language file names, read by the production reader (language_maker), so a language edited alone cannot leave it stale ATTACKS = { - "zh-ammo-tender": ("ZH_LANG", '["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0, 9], [0, 9]], ["asymmetric", 0.0], "16bad4f9-7fb5-41c5-8da0-dc76709eb7d3"]', + "zh-ammo-tender": ("ZH_LANG", '["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0, 9], [0, 9]], ["asymmetric", 0.0], "%MAKER%"]', "zhestokost.scad", "[0.42, 0.43, 0.45]"), - "pi-fighter": ("PI_LANG", '["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["bilateral", 0.0], "1d8a168def994a76a1ce7cb18f59c038"]', + "pi-fighter": ("PI_LANG", '["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["bilateral", 0.0], "%MAKER%"]', "pirate.scad", "[0.48, 0.27, 0.22]"), } EXECUTABLE = None # the OpenSCAD the add-on preference names, set by main hull_source = None # aetheria_ships.hull_source, set by main +def language_maker(faction): + """The maker key the faction's own language file names, as Build reads it.""" + return hull_source.read_languages(hull_source.language_library(EXECUTABLE)(faction))[0].maker + + def require(condition, message): if not condition: raise SystemExit(message) @@ -184,6 +193,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett rows_before = layout_rows(scene) source = package / "hull.scad" lang_var, lang_value, shadowed, shadow_colour = ATTACKS[fixture] + lang_value = lang_value.replace("%MAKER%", language_maker(shadowed[:-len(".scad")])) runner = hull_source.openscad_runner(EXECUTABLE, source) require(source.is_file(), "The fixture hull.scad was not copied") require("Build From Hull Source" == bpy.ops.aetheria.build_hull_source.get_rna_type().name, "Build is not registered") @@ -301,6 +311,32 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett root.location.x = off_centre require(aetherdb(ship_cc, "product", ship_cc, other, "--remove") == 0, "AetherDb could not remove the other maker's product") + # Record keys compare exactly: a Manufacturer that differs from the language's maker only in case names no Faction. + # AetherDb cannot write one, but a hand-edited ship.cc or a cultcache-py push can, and the guard alone refuses it. + def manufacturer_as(change): + ship_module = sys.modules["aetheria_ships.ship_cc"] + store = ship_module.read(ship_cc, PACKAGES).store + seen = [] + for envelope in store.pull_all(): + if envelope.type == ship_module.PRODUCT_SCHEMA: + values = msgpack.unpackb(envelope.payload, raw=False) + seen.append(values[ship_module.PRODUCT_MANUFACTURER_SLOT]) + values[ship_module.PRODUCT_MANUFACTURER_SLOT] = change(seen[-1]) + store.push(dataclasses.replace(envelope, payload=msgpack.packb(values, use_bin_type=True))) + return seen + + written = manufacturer_as(str.upper) + require(written and all(key.upper() != key for key in written), "The package holds no product with a cased key to rewrite") + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"CANCELLED"} and "not by the maker of the hull's faction language" in report, + f"Package took a product whose Manufacturer differs from the maker in case: {report!r} {message!r}") + require(not (package / "ship.glb").exists(), "Package wrote ship.glb for a case-variant Manufacturer") + for echoed in (ship_id, str(GAME), maker, collection["aetheria.hull_maker"], collection["aetheria.hull_maker"].upper()): + require(echoed not in report, f"The refusal echoes an input ({echoed})") + manufacturer_as(lambda _: written.pop(0)) + # Package: the validator accepts the built ship and the GLB holds a node per anchor. mount = bpy.data.objects[roles["weapon-mount"][0]] straight = mount.rotation_euler.z @@ -413,10 +449,13 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett art.parent = root require(any(o.get("aetheria.role") for o in generated(collection).values()), "The scene kept no anchor of Build's") require(aetherdb(ship_cc, "product", ship_cc, maker, "--remove") == 0, "AetherDb could not remove the product") # her art needs none + if number % 2 == 0: # the second hull: she (or git) removed hull.scad herself first; Build's record outlives it + source.unlink() + require(collection.get("aetheria.hull_maker"), "Build's maker record is gone before Package") select(box) result, message = run(bpy.ops.aetheria.package_ship) report = scene.aetheria_package_report - require(result == {"FINISHED"} and "deleted hull.scad" in report, f"Package did not retire the hull source: {report!r} {message!r}") + require(result == {"FINISHED"} and "retired Build's hull records" in report, f"Package did not retire the hull source: {report!r} {message!r}") require(not source.exists(), "hull.scad is still there after the author's art was packaged") require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection, "The record of Build's objects or its maker outlived hull.scad") @@ -424,7 +463,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett select(box) result, message = run(bpy.ops.aetheria.build_hull_source) require(result == {"CANCELLED"} and bpy.data.objects.get("author hull") is art, f"Build ran after takeover: {message!r}") - print(f"PASS {number}.9 her art with Build's anchors kept retires hull.scad, with a report; Build then refuses") + print(f"PASS {number}.9 her art with Build's anchors kept retires Build's records and any hull.scad, with a report; Build then refuses") return collection