ship_cc carries product envelopes through every push; PRODUCT_SCHEMA pinned; authoring doc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 03:54:31 +02:00
commit cc77c4b5f5
4 changed files with 72 additions and 5 deletions

View file

@ -162,7 +162,17 @@ keys. Each mod's stable `ShipAuthoring.Id` yields separate deterministic keys
for its copied `HullData` (`mod-hull:<id>`) and its runtime authoring record
(`mod-ship:<id>`). A mod's `ship.cc` stores its two records under those same
keys, and compose copies them verbatim, so the derived catalog holds one hull
per mod ship and `HullData.Visual` names the `mod-ship` record. The generated
per mod ship and `HullData.Visual` names the `mod-ship` record. A package may
also hold the hull's products, `FactionProductData` records stored at
`mod-product:<id>:<maker key>` (the third key beside `mod-hull` and
`mod-ship`; a key the mods own, never named by the shipped catalog). The key
makes a second product by one maker impossible. A ship is its maker's: the hull
carries the maker's name for it and one product by that maker, and a further
product is a rebrand by another faction (the Pirates' stolen-and-adapted
hulls). Compose copies the products verbatim and excludes a package, with a
named reason, whose product names a maker the shipped catalog holds no faction
for, or whose product name equals a shipped product's or another package's.
Blender never writes or edits a product; AetherDb does. The generated
catalog is disposable; the shipped catalog and each mod's source `.cc` remain
the owners. A player session opens one catalog snapshot and cannot hot-swap its
designs beneath existing entities.
@ -223,7 +233,15 @@ catalog. Continue refuses a run naming a design the catalog no longer holds
`aetheria.id`, is the object's `aetheria.id`. It then runs
`AetherDb ship-authoring validate`, which judges the GLB too: node ids,
and a mesh under every thruster and radiator anchor. The model supplies
geometry, not a second copy of hull semantics.
geometry, not a second copy of hull semantics. A ship hull needs a top
speed (a `VelocityLimitData` behavior; a station or turret does not), and
validation refuses one without.
After Package, `ship-authoring price <ship.cc> <price>` sets the hull's
price and `ship-authoring product <ship.cc> <maker> --name <name>
[--description <text>]` writes the package's product by that maker
(`--remove` deletes it). A package with a product needs a price above 0,
because generation skips price 0; a package with none stays valid and is
console-only. Compose follows.
3. A command-line validator rejects malformed cells, overlapping or dangling
hardpoints, missing model nodes, and unresolved asset references with
precise errors. It changes no live catalog or scene on failure. The

View file

@ -33,6 +33,7 @@ public sealed class ShipSchemaPinTests
{
Assert.Equal(typeof(ShipAuthoring).GetCustomAttribute<CultDocumentAttribute>().SchemaName, Text("SCHEMA"));
Assert.Equal(typeof(HullData).GetCustomAttribute<CultDocumentAttribute>().SchemaName, Text("HULL_SCHEMA"));
Assert.Equal(typeof(FactionProductData).GetCustomAttribute<CultDocumentAttribute>().SchemaName, Text("PRODUCT_SCHEMA"));
Assert.Equal(1, Integer("RETIRED_HULL_SLOT"));
// Key 1 held the embedded hull until S1. A later member must not take it, or an old file would decode as it.
Assert.DoesNotContain(typeof(ShipAuthoring).GetMembers(BindingFlags.Public | BindingFlags.Instance),

View file

@ -1,7 +1,8 @@
"""Aetheria's Blender-side view of one ship file: a typed HullData record and the ShipAuthoring visual it names.
The schemas and authority live in Aetheria's C# HullData and ShipAuthoring types. This module edits the hull's grid
and hardpoints and the visual's lines, and preserves the C# schema catalog and every slot it does not edit. Brokkr
and hardpoints and the visual's lines, and preserves the C# schema catalog and every slot it does not edit. The file
also holds the hull's products (FactionProductData records), which C# owns: every push here leaves them untouched. Brokkr
supplies the Blender host and CultLib Python dependency path.
"""
@ -16,6 +17,7 @@ from typing import Any, NamedTuple
SCHEMA = "aetheria.ship_authoring"
HULL_SCHEMA = "aetheria.hulldata"
PRODUCT_SCHEMA = "aetheria.factionproductdata"
# MessagePack slots of the C# types, which own them (ShipAuthoring, ItemData.Shape, HullData.Hardpoints,
# HardpointData). RETIRED_HULL_SLOT is the ShipAuthoring key that held the embedded hull before S1; no member owns it now. ShipSchemaPinTests in tests/Aetheria.Shared.Tests fails when any number or name below
@ -97,8 +99,10 @@ def read(path: str, cultlib_packages: str) -> ShipFile:
raise ValueError(
f"The ship file has a legacy embedded hull at retired key {RETIRED_HULL_SLOT} of the {SCHEMA} record; "
f"migrate it into its own {HULL_SCHEMA} record that names the visual, and drop key {RETIRED_HULL_SLOT}")
if len(envelopes) != 2 or len(ships) != 1 or len(hulls) != 1:
raise ValueError(f"The ship file must hold exactly one {SCHEMA} record and one {HULL_SCHEMA} record")
products = [envelope for envelope in envelopes if envelope.type == PRODUCT_SCHEMA]
if len(ships) != 1 or len(hulls) != 1 or len(ships) + len(hulls) + len(products) != len(envelopes):
raise ValueError(f"The ship file must hold exactly one {SCHEMA} record and one {HULL_SCHEMA} record, "
f"and may hold {PRODUCT_SCHEMA} records beside them, nothing else")
bodies = []
for envelope in (ships[0], hulls[0]):
body = msgpack.unpackb(envelope.payload, raw=False)

View file

@ -97,6 +97,17 @@ class ShipFileCase(unittest.TestCase):
def ship(self):
return ship_cc.read(self.path, PACKAGES).ship.body
def add_products(self):
"""Two products as C# stores them, one per maker; Python reads them as envelopes and never encodes one."""
entry = catalog("product-schema-1", ship_cc.PRODUCT_SCHEMA)
store = cultcache_py.SingleFileMessagePackBackingStore(self.path)
store.push(envelope("mod-product:mod.skiff:faction:acme", ship_cc.PRODUCT_SCHEMA, entry, [None, "Acme Skiff", "Reliable."]))
store.push(envelope("mod-product:mod.skiff:faction:rival", ship_cc.PRODUCT_SCHEMA, entry, [None, "Rival Skiff", None, "tail"]))
def products(self):
return {(e.type, e.key): e for e in cultcache_py.SingleFileMessagePackBackingStore(self.path).pull_all()
if e.type == ship_cc.PRODUCT_SCHEMA}
def hull(self):
return ship_cc.read(self.path, PACKAGES).hull.body
@ -115,6 +126,12 @@ class ReadTests(ShipFileCase):
self.assertEqual("Skiff", file.hull.body[1])
self.assertEqual(ship_cc.HULL_SCHEMA, file.hull.envelope.type)
def test_reads_a_file_whose_hull_also_carries_products(self):
self.add_products()
file = ship_cc.read(self.path, PACKAGES)
self.assertEqual("mod.skiff", file.ship.body[0])
self.assertEqual("Skiff", file.hull.body[1])
def test_refuses_a_file_with_another_record_beside_the_pair(self):
cultcache_py.SingleFileMessagePackBackingStore(self.path).push(cultcache_py.CultCacheEnvelope.create(
key="other", type="aetheria.something", payload=msgpack.packb([1], use_bin_type=True)))
@ -190,6 +207,33 @@ class ReadTests(ShipFileCase):
ship_cc.read(self.path, PACKAGES)
class ProductCarryThroughTests(ShipFileCase):
"""Products lie beside the pair, owned by C#: every push rewrites the file and leaves them byte-identical."""
def test_each_edit_leaves_both_product_envelopes_byte_identical(self):
self.add_products()
before = self.products()
self.assertEqual(2, len(before))
hull = hull_body()
revision = ship_cc.layout_revision(hull[ship_cc.HULL_SHAPE_SLOT][0], hull[ship_cc.HULL_HARDPOINTS_SLOT])
edits = (
lambda: ship_cc.replace_lines(self.path, PACKAGES, LINES),
lambda: ship_cc.replace_visual(self.path, PACKAGES, "mod.skiff", "ship.glb", [["map", "map-icon", "map", None, 0]]),
lambda: ship_cc.replace_layout(self.path, PACKAGES, "mod.skiff", revision, [2, 2, [True, True, True, False]],
[row[:len(ship_cc.HARDPOINT_MEMBERS)] for row in hull[ship_cc.HULL_HARDPOINTS_SLOT]]),
)
for index, edit in enumerate(edits):
with self.subTest(edit=index):
edit()
after = self.products()
self.assertEqual(before.keys(), after.keys())
for key in before:
self.assertEqual(before[key].payload, after[key].payload)
self.assertEqual(before[key].key, after[key].key)
# The edits really ran: the shape the layout edit wrote is in the file beside the carried products.
self.assertEqual([2, 2, [True, True, True, False]], ship_cc.read(self.path, PACKAGES).hull.body[ship_cc.HULL_SHAPE_SLOT][0])
class RigCarryThroughTests(ShipFileCase):
"""The rig (ShipAuthoring slot 5 Joints, ShipAnchor slot 5 Joint) lies past every slot this module edits, so a save keeps it."""