diff --git a/docs/research/hardpoint-sizes-prior-art.md b/docs/research/hardpoint-sizes-prior-art.md index 0c0a3400f..3c7f44b76 100644 --- a/docs/research/hardpoint-sizes-prior-art.md +++ b/docs/research/hardpoint-sizes-prior-art.md @@ -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`. diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index eeb4825c0..dfda86603 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -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().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; } diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index aae9e135b..67facc162 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -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)