Pin the three language maker keys to the catalog; pin product_makers' exact comparison; fix a stale derivation line

ShipSchemaPinTests reads each factions/*.scad maker literal against its Faction
record key in GameData/Aetheria.cc. test_ship_cc pins that Manufacturer keys are
not case folded and that a bare string product body is refused, not read as a
maker. hardpoint-sizes-prior-art.md no longer describes the deleted derivation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-19 01:09:12 +02:00
commit a2284391ba
3 changed files with 28 additions and 2 deletions

View file

@ -35,7 +35,7 @@ Basis: GameCult/Aetheria origin/master c000d5ca (detached worktree, since remove
- Visual package record `ShipAuthoring` (`aetheria.ship_authoring`): ModelAsset (GLB), Anchors, SchematicLines, Joints: Assets/Scripts/ServerShared/ShipAuthoring.cs:8-66.
- `ShipAnchor` fields: Id (the hardpoint's `Transform` string), Role, ModelNodeId, ParentId, Order, Joint: ShipAuthoring.cs:39-55. Roles vocabulary: map-icon, hull-collider, shield, tractor, thruster-emitter, weapon-mount, weapon-muzzle, radiator-mesh: ShipAuthoring.cs:71-76. No size, class, or envelope field exists on an anchor.
- Which hardpoints get a visual anchor: `MountRole`: Thruster -> thruster-emitter, Radiator -> radiator-mesh, Energy/Ballistic/Launcher -> weapon-mount; every other type (Reactor, Shield, Sensors, ControlModule, Tool, Thermal, WarpDrive) is internal and "no anchor may take its id": ShipAuthoring.cs:78-83, 319-327. Each weapon hardpoint needs at least one weapon-muzzle anchor parented to it: ShipAuthoring.cs:341-343.
- Hardpoint footprint on the grid and the mount are separate: hardpoint cells are validated inside the hull and non-overlapping (ShipAuthoring.cs:300-318); the anchor is a model node bound by id. The Blender add-on derives the hardpoint `Position` from the mount object's origin cell (`mounted_position(cell_at(origin...))`, tools/blender/aetheria_ships/__init__.py:249-256) and adds a default muzzle half a metre along the facing (`MUZZLE_OFFSET = (0,-0.5,0)`, __init__.py:203). Docs: docs/moddable-ship-authoring.md ("Gameplay cell occupancy remains the authored HullData.Shape ... strokes do not decide armor or mount footprints"; Forbidden writers: Blender object names, prefabs, generated rows may not decide hardpoint type or footprint).
- Hardpoint footprint on the grid and the mount are separate: hardpoint cells are validated inside the hull and non-overlapping (ShipAuthoring.cs:300-318); the anchor is a model node bound by id. The Blender add-on keeps the two spaces apart: Add Weapon creates the layout row at `HardpointData`'s default `Position` and the author sets it in the layout panel; Mirror Mount mirrors the row within the schematic and the object within the model; Package moves no row (tools/blender/aetheria_ships/__init__.py, `_add_mount`, AETHERIA_OT_mirror_mount). Add Weapon also adds a default muzzle half a metre along the facing (`MUZZLE_OFFSET = (0,-0.5,0)`, __init__.py:208). Docs: docs/moddable-ship-authoring.md ("Gameplay cell occupancy remains the authored HullData.Shape ... strokes do not decide armor or mount footprints"; Forbidden writers: Blender object names, prefabs, generated rows may not decide hardpoint type or footprint).
- Add-on mount entry: "Add a weapon mount and its first muzzle at the 3D cursor, with a layout row" (__init__.py:~310); the layout row carries kind, mount id, rotation, footprint string like `##/##` (docs/moddable-ship-authoring.md). The add-on only creates weapon mounts via that operator; thrusters/radiators are mesh objects tagged by role (docs).
- Rig: `ShipJoint` {Id, Parent, Axes, Min, Max}: ShipAuthoring.cs:27-37; `ValidateRig` and `RigChains` (ShipAuthoring.cs:344, 370-471). `ShipModCatalog.BindRig` checks the record's joints and each mount's Joint equal the GLB's nearest-ancestor-joint; a thruster or radiator mesh may not sit under a joint (follow-up gimballed-thrusters): Assets/Scripts/ServerShared/ShipModCatalog.cs:209-250.
- Unity binding: `ShipModVisual.LoadAsync` loads the GLB via glTFast and builds `Anchors` Id -> Transform: Assets/Scripts/Gameplay/ShipModVisual.cs:30-65. Legacy prefab path: `EntityInstance` has `RadiatorHardpoint[]`, `ThrusterHardpoint[]`, `WeaponHardpoint[]` arrays matched by transform name to `HardpointData.Transform` (EntityInstance.cs:23-25, 251-265); `WeaponHardpoint` is only `Transform[] FiringPoint`; `ThrusterHardpoint` is an invisible `MeshRenderer Emitter` that shapes exhaust particles (ThrusterHardpoint.cs); `RadiatorHardpoint` is a `MeshRenderer Mesh`.

View file

@ -105,6 +105,27 @@ public sealed class ShipSchemaPinTests
}
}
// A faction language file echoes its Faction's record key as the last element of its lang (hull_source.Language.maker); Package
// compares it to each product's Manufacturer. The catalog owns the key, so a regenerated record fails here, not at Package.
[Theory]
[InlineData("zhestokost.scad", "Zhestokost")]
[InlineData("pirate.scad", "Pirates")]
[InlineData("rnd.scad", "Rossum & Douglas")]
public void FactionLanguageFilesNameTheirFactionsRecordKeyInTheCatalog(string file, string faction)
{
var scad = File.ReadAllText(Locate("tools", "blender", "aetheria_ships", "factions", file));
var literal = Regex.Match(scad, @"^[A-Z]+_LANG = \[[^;]*""([^""]+)""\];", RegexOptions.Multiline);
?$", RegexOptions.Multiline);
Assert.True(literal.Success, $"{file} has no *_LANG with a maker as its last element");
var db = AetherDb.Open(root: AetherDb.FindRoot());
try
{
var record = Assert.Single(db.Cache.GetAll<Faction>().Where(f => f.Name == faction));
Assert.Equal(db.Cache.RefOf(record).Key.ToString(), literal.Groups[1].Value);
}
finally { db.Cache.Dispose(); }
}
private static bool Accepts(string id)
{
try { ShipAuthoringStore.RequireShipId(id); return true; }

View file

@ -235,11 +235,16 @@ class ProductMakerTests(ShipFileCase):
"maker-slot", "roles-slot"])
self.assertEqual(["maker-slot"], ship_cc.product_makers(self.path, PACKAGES))
def test_manufacturer_keys_are_compared_as_written_not_case_folded(self):
self.push_product("mod-product:mod.skiff:faction:a", [None, "A", None, None, "Acme-Key"])
self.push_product("mod-product:mod.skiff:faction:b", [None, "B", None, None, "acme-key"])
self.assertEqual(["Acme-Key", "acme-key"], ship_cc.product_makers(self.path, PACKAGES))
def test_a_product_with_no_manufacturer_is_refused_naming_no_input(self):
canary = "canary-product-name"
for body in ([None, canary, "Reliable."], [None, canary, None, None], [None, canary, None, None, None],
[None, canary, None, None, ""],
[None, canary, None, None, 7]):
[None, canary, None, None, 7], "a bare string body is no list of slots, whatever its fifth character"):
with self.subTest(body=body):
self.setUp()
self.push_product("mod-product:mod.skiff:faction:" + canary, body)