diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 02c4b64a8..446bb6d89 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -162,7 +162,17 @@ keys. Each mod's stable `ShipAuthoring.Id` yields separate deterministic keys for its copied `HullData` (`mod-hull:`) and its runtime authoring record (`mod-ship:`). 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::` (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 ` sets the hull's + price and `ship-authoring product --name + [--description ]` 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 diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index 452a70464..d47b418af 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -33,6 +33,7 @@ public sealed class ShipSchemaPinTests { Assert.Equal(typeof(ShipAuthoring).GetCustomAttribute().SchemaName, Text("SCHEMA")); Assert.Equal(typeof(HullData).GetCustomAttribute().SchemaName, Text("HULL_SCHEMA")); + Assert.Equal(typeof(FactionProductData).GetCustomAttribute().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), diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index df76cdb6f..84f1747ee 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -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) diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index fb9b9994e..b0806544c 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -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."""