From 42eb5214ce0448ea4aa6c8bac700515e4b10422d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 18:14:36 +0200 Subject: [PATCH 001/113] Add parallel typed ship authoring and Blender LineArt capture --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 150 ++++++++++++++++++ .../ServerShared/ShipAuthoring.cs.meta | 2 + docs/moddable-ship-authoring.md | 121 ++++++++++++++ .../ShipAuthoringTests.cs | 119 ++++++++++++++ tools/AetherDb/Program.cs | 3 +- tools/AetherDb/ShipAuthoringCommands.cs | 62 ++++++++ tools/blender/aetheria_ships/__init__.py | 87 ++++++++++ tools/blender/aetheria_ships/ship_cc.py | 93 +++++++++++ 8 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 Assets/Scripts/ServerShared/ShipAuthoring.cs create mode 100644 Assets/Scripts/ServerShared/ShipAuthoring.cs.meta create mode 100644 docs/moddable-ship-authoring.md create mode 100644 tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs create mode 100644 tools/AetherDb/ShipAuthoringCommands.cs create mode 100644 tools/blender/aetheria_ships/__init__.py create mode 100644 tools/blender/aetheria_ships/ship_cc.py diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs new file mode 100644 index 000000000..3eeb4eeed --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; + +// A mod ship's authoring record. Hull is the existing simulation design, embedded here so a +// proof ship has one writable source; the shipped catalog contains no copy of that ship. +[CultDocument("aetheria.ship_authoring", "1"), MessagePackObject] +public sealed class ShipAuthoring +{ + [CultName, Key(0)] public string Id; + [Key(1)] public HullData Hull; + [Key(2)] public string ModelAsset; + [Key(3)] public List Anchors = new List(); + [Key(4)] public List SchematicLines = new List(); +} + +[MessagePackObject] +public sealed class ShipAnchor +{ + // Stable semantic identity. HullData.Hardpoints[].Transform names this ID for mounts. + [Key(0)] public string Id; + // One of: map-icon, hull-collider, shield, tractor, thruster-emitter, + // weapon-muzzle, radiator-mesh, or articulation. + [Key(1)] public string Role; + // Stable node ID in the compiled model, never a Blender display name. + [Key(2)] public string ModelNodeId; + // Only child roles use ParentId; e.g. a muzzle names its weapon hardpoint. + [Key(3)] public string ParentId; + [Key(4)] public int Order; +} + +// Evaluated or baked Grease Pencil stroke coordinates in Blender's right-handed Z-up +// space. The runtime renderer owns its coordinate conversion and camera projection. +[MessagePackObject] +public sealed class ShipPolyline +{ + [Key(0)] public string Layer; + [Key(1)] public string Material; + [Key(2)] public float[] Points; + [Key(3)] public float[] Radii; + [Key(4)] public float[] Opacities; + [Key(5)] public bool Cyclic; + [Key(6)] public float[] Color; +} + +public static class ShipAuthoringStore +{ + public static CultCache Open(string path, bool writable = false) + { + var cache = new CultCache(); + try + { + cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, !writable), new[] { typeof(ShipAuthoring) }); + return cache; + } + catch + { + cache.Dispose(); + throw; + } + } + + public static ShipAuthoring Read(string path) + { + using var cache = Open(path); + var records = cache.GetAll().ToArray(); + if (records.Length != 1) + throw new InvalidOperationException($"{path} must hold exactly one ship authoring record, found {records.Length}."); + Validate(records[0]); + return records[0]; + } + + public static void Validate(ShipAuthoring ship) + { + if (ship == null) throw new InvalidOperationException("Ship authoring record is null."); + if (string.IsNullOrWhiteSpace(ship.Id)) throw new InvalidOperationException("Ship ID is required."); + var hull = ship.Hull ?? throw new InvalidOperationException($"{ship.Id}: hull data is required."); + if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required."); + if (hull.Shape?.Cells == null || hull.Shape.Width < 1 || hull.Shape.Height < 1 || + !hull.Shape.Cells.Cast().Any(occupied => occupied)) + throw new InvalidOperationException($"{ship.Id}: schematic must contain at least one cell."); + if (!string.IsNullOrEmpty(hull.Prefab)) + throw new InvalidOperationException($"{ship.Id}: a mod ship cannot name a Unity prefab."); + if (string.IsNullOrWhiteSpace(ship.ModelAsset) || Path.IsPathRooted(ship.ModelAsset) || + ship.ModelAsset.Replace('\\', '/').Split('/').Any(part => part == "..")) + throw new InvalidOperationException($"{ship.Id}: model asset must be a relative package path."); + + var anchors = ship.Anchors ?? throw new InvalidOperationException($"{ship.Id}: anchors are required."); + var ids = new HashSet(StringComparer.Ordinal); + foreach (var anchor in anchors) + { + if (anchor == null || string.IsNullOrWhiteSpace(anchor.Id) || !ids.Add(anchor.Id)) + throw new InvalidOperationException($"{ship.Id}: anchor IDs must be present and unique."); + if (string.IsNullOrWhiteSpace(anchor.ModelNodeId)) + throw new InvalidOperationException($"{ship.Id}: anchor {anchor.Id} needs a model node ID."); + } + foreach (var role in new[] { "map-icon", "hull-collider", "shield", "tractor" }) + if (anchors.Count(anchor => anchor.Role == role) != 1) + throw new InvalidOperationException($"{ship.Id}: exactly one {role} anchor is required."); + + var mounts = new HashSet(StringComparer.Ordinal); + var occupiedHardpointCells = new HashSet<(int x, int y)>(); + foreach (var hardpoint in hull.Hardpoints ?? new List()) + { + if (hardpoint == null || string.IsNullOrWhiteSpace(hardpoint.Transform) || + !mounts.Add(hardpoint.Transform)) + throw new InvalidOperationException($"{ship.Id}: hardpoint IDs must be present and unique."); + if (hardpoint.Shape?.Cells == null || !hardpoint.Shape.Cells.Cast().Any(occupied => occupied)) + throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has no cells."); + for (var cellX = 0; cellX < hardpoint.Shape.Width; cellX++) + for (var cellY = 0; cellY < hardpoint.Shape.Height; cellY++) + { + if (!hardpoint.Shape.Cells[cellX, cellY]) continue; + var x = hardpoint.Position.x + cellX; + var y = hardpoint.Position.y + cellY; + if (x < 0 || y < 0 || x >= hull.Shape.Width || y >= hull.Shape.Height || !hull.Shape.Cells[x, y]) + throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} lies outside the hull schematic."); + if (!occupiedHardpointCells.Add((x, y))) + throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} overlaps another hardpoint."); + } + if (!anchors.Any(anchor => anchor.Id == hardpoint.Transform)) + throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has no model anchor."); + } + foreach (var anchor in anchors.Where(anchor => !string.IsNullOrEmpty(anchor.ParentId))) + if (!mounts.Contains(anchor.ParentId)) + throw new InvalidOperationException($"{ship.Id}: anchor {anchor.Id} has unknown hardpoint parent {anchor.ParentId}."); + + foreach (var line in ship.SchematicLines ?? new List()) + { + var points = line?.Points; + if (points == null || points.Length < 6 || points.Length % 3 != 0 || + points.Any(value => float.IsNaN(value) || float.IsInfinity(value))) + throw new InvalidOperationException($"{ship.Id}: schematic line needs finite XYZ points."); + var count = points.Length / 3; + if (line.Radii != null && line.Radii.Length != count || + line.Opacities != null && line.Opacities.Length != count) + throw new InvalidOperationException($"{ship.Id}: schematic line point attributes have the wrong length."); + if (line.Radii != null && line.Radii.Any(value => float.IsNaN(value) || float.IsInfinity(value) || value < 0) || + line.Opacities != null && line.Opacities.Any(value => float.IsNaN(value) || float.IsInfinity(value) || value < 0 || value > 1)) + throw new InvalidOperationException($"{ship.Id}: schematic line radius and opacity must be finite and in range."); + if (line.Color != null && (line.Color.Length != 4 || + line.Color.Any(value => float.IsNaN(value) || float.IsInfinity(value)))) + throw new InvalidOperationException($"{ship.Id}: schematic line color must be finite RGBA."); + } + } +} diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs.meta b/Assets/Scripts/ServerShared/ShipAuthoring.cs.meta new file mode 100644 index 000000000..b9d7af3b4 --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0da6676b1995425fa66187e8e58956e7 diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md new file mode 100644 index 000000000..e5fe66669 --- /dev/null +++ b/docs/moddable-ship-authoring.md @@ -0,0 +1,121 @@ +# Moddable ship authoring: parallel proof + +Status: design and proof lane. The existing `HullData` catalog records, Longinus and +Djinni prefabs, and their spawn path remain live until a complete mod ship passes +authoring, validation, loading, and play checks. + +## LineArt proof from Quiet.blend + +`C:\Users\Meta\Desktop\Quiet.blend` is a read-only source for this probe. At frame +1, `Quiet / Dexter Quiet Lineart` evaluates to 1,040 polylines with 3,125 +points. Blender 5.2.2 wrote those coordinates, point radii and opacities, +material/layer names, cyclic flags, and RGBA into `Build/quiet.cc` without +changing the `.blend`. C# reopened the same typed record and counted the same +lines and points. A second line replacement preserved its record key, schema +ID, hull, model asset, and anchors byte for byte. `Build/quiet.cc` is an +ignored draft probe, not an installed ship. + +The line positions remain 3D Blender world coordinates. The runtime can +lower them to a hologram or schematic view without regenerating LineArt. +LineArt evaluation depends on the source scene and camera, so a captured set +may be incomplete from another view. Gameplay cell occupancy remains the +authored `HullData.Shape` in the `.cc`; strokes do not decide armor or mount +footprints. + +`tools/blender/aetheria_ships` is a separate Blender add-on in Brokkr's +sidebar. It uses Brokkr's configured CultLib Python source and edits only the +line slot of an existing `aetheria.ship_authoring` record. To try it, make +`F:\Projects\Brokkr\surfaces\blender` and this repo's `tools\blender` visible +to Blender's add-on search path, enable `brokkr_bridge` then +`aetheria_ships`, and install `msgpack` into Blender's Python environment. +Create a draft with `dotnet run --project tools/AetherDb -- ship-authoring +create `, choose that `.cc` in the **Aetheria +Ship** panel, select a Grease Pencil object, and press **Capture Ship Lines**. +`ship-authoring inspect ` reads drafts; `validate` requires complete +hull, model, and anchor authoring. + +## Objective + +A mod author edits one typed ship record in a `.cc` file, including the hull +schematic and every hardpoint. Blender, through Aetheria tooling built on +Brokkr, edits that same record while displaying its visual source. The game +assembles an instance from the record and its referenced asset package at +runtime. A mod package can be installed without opening the Unity Editor. + +## Current mechanism + +`GameData/Aetheria.cc` holds `HullData`: the cell shape, hardpoint footprints, +stats, and a Unity Addressables prefab GUID. `ZoneRenderer.LoadEntity` loads +that prefab and expects `ShipInstance`; `EntityInstance` and `ShipInstance` +match catalog hardpoint transform strings against manually wired prefab +arrays. The new FBX naming builder automates prefab wiring but still emits a +Unity prefab and is part of this existing path. Brokkr can capture Blender +collections and custom properties; the AetheriaEve Blender authoring v1 +contract describes a future collection-to-CultMesh deploy lane, but that lane +is not yet an end-to-end ship loader in the current game. + +## Target authority map for the parallel lane + +- **Owner:** a versioned `ShipAuthoring` document in a mod-owned `.cc` file + owns the ship's authored hull, schematic, hardpoints, structural and + presentation associations, and asset references. A ship ID is stable across + display-name changes. +- **Inputs:** the authoring record; a Blender visual source identified by the + record; compiled mesh, texture, and material artifacts; references to gear + designs where the game requires them. +- **Outputs:** a validated runtime hull design and an assembled visual/physics + `ShipInstance`. The assembly and any catalog row are derived products. +- **Derived state:** Unity objects, component arrays, collision components, + particle emitters, and schematic UI. Installed equipment, damage, heat, + cargo, ownership, and position are live game state. +- **Forbidden writers:** Blender object names, Unity prefabs, generated catalog + rows, and runtime components may not independently decide the schematic, + hardpoint type or footprint, armor, drag, or mount identity. Geometry nodes + carry stable IDs; the `.cc` document gives them meaning. +- **Shared path:** Blender validation, command-line validation, editor preview, + and runtime loading read the same typed record and invoke the same semantic + validator. A bad mod is rejected before partial scene construction. +- **Cut line:** no existing ship path is removed during proof. Migration later + deletes or demotes the old ship-specific catalog/prefab authority for each + migrated hull; it does not keep two writable definitions of that hull. + +At this proof stage, `ship-authoring validate` is the sole caller of the +semantic validator. The Blender capture command writes only line data and the +`inspect` command reads incomplete drafts. Editor preview and runtime loading +still need to be built. The current gameplay catalog and prefab loader do not +read these mod records. + +## Proof gates + +1. A typed `.cc` record round-trips between C# and Blender's Python runtime, + preserving unknown future fields and record identity. +2. Blender edits schematic cells and hardpoints in that record, and validates + their association with stable visual node IDs. The model supplies geometry, + not a second copy of hull semantics. +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. +4. A packaged mod ship loads in a built Unity player without Unity Editor or + Blender installed. Runtime assembly creates the same components the + current `ShipInstance` expects and uses the existing simulation rules. +5. Spawn, equip, thrust, fire, shield, damage, save/reload, and schematic UI + work for the mod ship. Only then is a shipped hull eligible for migration. + +## Structural and build budget + +The proof reuses `HullData` simulation semantics, CultCache `.cc` persistence, +Brokkr's Blender host, and the current `ShipInstance` behavior. This pass adds +one authoring document type, one validator, and one Blender-facing editor +surface. A later pass needs one runtime construction boundary. It does not add +a daemon, a second gameplay +simulation, or another Unity prefab generator. The existing name-based FBX +builder remains available to existing content but is not an input to this +lane. Its retirement is a later cut, after migration. + +Focused checks use `Aetheria.Shared` and `tests/Aetheria.Shared.Tests` under +`netstandard2.1` / `net10.0`, Python CultCache interoperability checks, and +the Unity 6000.3.24f1 Editor compile and play probe. No workspace-wide build +or new executable target is justified for schema work. The current catalog is +12.7 MB; it is not rewritten during the proof. The worktree starts clean from +`4b594e11`; unrelated Unity scene and material edits remain in the original +checkout. diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs new file mode 100644 index 000000000..900d7f983 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using CultMath; +using Xunit; + +public sealed class ShipAuthoringTests +{ + [Fact] + public async Task StandaloneShipRoundTripsWithoutTouchingTheCatalog() + { + var root = Path.Combine(Path.GetTempPath(), "aetheria-ship-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, "skiff.cc"); + try + { + var ship = Fixture(); + ShipAuthoringStore.Validate(ship); + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + cache.Upsert(ship); + await cache.FlushAsync(); + } + var reopened = ShipAuthoringStore.Read(path); + Assert.Equal("mod.skiff", reopened.Id); + Assert.Equal("Skiff", reopened.Hull.Name); + Assert.True(reopened.Hull.Shape.Cells[1, 0]); + Assert.Equal("thruster.port", reopened.Hull.Hardpoints[0].Transform); + Assert.Equal("skiff.glb", reopened.ModelAsset); + Assert.Equal(2, reopened.SchematicLines[0].Points.Length / 3); + } + finally { Directory.Delete(root, true); } + } + + [Fact] + public void InvalidSchematicOrDanglingMountCannotBePublished() + { + var ship = Fixture(); + ship.Hull.Shape.Cells[1, 0] = false; + Assert.Contains("outside the hull schematic", Assert.Throws(() => + ShipAuthoringStore.Validate(ship)).Message); + + ship = Fixture(); + ship.Anchors.RemoveAt(ship.Anchors.Count - 1); + Assert.Contains("has no model anchor", Assert.Throws(() => + ShipAuthoringStore.Validate(ship)).Message); + + ship = Fixture(); + ship.SchematicLines[0].Points[0] = float.NaN; + Assert.Contains("finite XYZ", Assert.Throws(() => + ShipAuthoringStore.Validate(ship)).Message); + + ship = Fixture(); + ShipAuthoringStore.Validate(ship); + ship.Hull.Shape.Cells[1, 0] = false; + Assert.Contains("outside the hull schematic", Assert.Throws(() => + ShipAuthoringStore.Validate(ship)).Message); + + ship = Fixture(); + ship.Hull.Hardpoints.Add(new HardpointData + { + Type = HardpointType.Thruster, + Position = new int2(1, 0), + Shape = new Shape(), + Transform = "thruster.starboard" + }); + ship.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" }); + Assert.Contains("overlaps another hardpoint", Assert.Throws(() => + ShipAuthoringStore.Validate(ship)).Message); + } + + private static ShipAuthoring Fixture() + { + var shape = new Shape(2, 2); + shape.Cells[0, 0] = true; + shape.Cells[1, 0] = true; + return new ShipAuthoring + { + Id = "mod.skiff", + ModelAsset = "skiff.glb", + Hull = new HullData + { + Name = "Skiff", + Shape = shape, + Hardpoints = new List + { + new HardpointData + { + Type = HardpointType.Thruster, + Position = new int2(1, 0), + Shape = new Shape(), + Transform = "thruster.port" + } + } + }, + Anchors = new List + { + new ShipAnchor { Id = "map", Role = "map-icon", ModelNodeId = "map" }, + new ShipAnchor { Id = "collider", Role = "hull-collider", ModelNodeId = "collider" }, + new ShipAnchor { Id = "shield", Role = "shield", ModelNodeId = "shield" }, + new ShipAnchor { Id = "tractor", Role = "tractor", ModelNodeId = "tractor" }, + new ShipAnchor { Id = "thruster.port", Role = "thruster-emitter", ModelNodeId = "thruster-port" } + }, + SchematicLines = new List + { + new ShipPolyline + { + Layer = "Hull", + Material = "White", + Points = new[] { 0f, 0f, 0f, 1f, 0f, 0f }, + Radii = new[] { .01f, .01f }, + Opacities = new[] { 1f, 1f }, + Color = new[] { 1f, 1f, 1f, 1f } + } + } + }; + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 46aae9575..c392c3bf3 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,8 +36,9 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); + case "ship-authoring": return ShipAuthoringCommands.Run(args.Skip(1).ToArray()); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate"); return 1; } } diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs new file mode 100644 index 000000000..ce21cc6ca --- /dev/null +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +// Uses the existing database executable; mod authoring does not earn another binary target. +public static class ShipAuthoringCommands +{ + public static int Run(string[] args) + { + if (args.Length == 0) + { + Console.Error.WriteLine("ship-authoring create | inspect | validate "); + return 1; + } + try + { + if (args[0] == "inspect" && args.Length == 2) + { + using var cache = ShipAuthoringStore.Open(args[1]); + var records = cache.GetAll().ToArray(); + if (records.Length != 1) throw new InvalidOperationException($"Expected one ship, found {records.Length}."); + var ship = records[0]; + Console.WriteLine($"{ship.Id}: {ship.Hull?.Name}, {ship.Hull?.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points"); + return 0; + } + if (args[0] == "validate" && args.Length == 2) + { + var ship = ShipAuthoringStore.Read(args[1]); + Console.WriteLine($"{ship.Id}: {ship.Hull.Name}, {ship.Hull.Hardpoints.Count} hardpoints, {ship.SchematicLines.Count} lines"); + return 0; + } + if (args[0] == "create" && args.Length == 4) + { + var path = Path.GetFullPath(args[1]); + if (File.Exists(path)) throw new InvalidOperationException($"Refusing to replace {path}"); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var ship = new ShipAuthoring + { + Id = args[2], + Hull = new HullData { Name = args[3], Shape = new Shape() }, + ModelAsset = "ship.glb", + Anchors = new List() + }; + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + cache.Upsert(ship); + cache.FlushAsync().Wait(); + } + Console.WriteLine($"Created draft {path}; fill its model bindings and schematic in Blender."); + return 0; + } + Console.Error.WriteLine("ship-authoring create | inspect | validate "); + return 1; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + return 1; + } + } +} diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py new file mode 100644 index 000000000..c512e8405 --- /dev/null +++ b/tools/blender/aetheria_ships/__init__.py @@ -0,0 +1,87 @@ +"""Aetheria ship authoring controls in Brokkr's Blender sidebar.""" + +bl_info = { + "name": "Aetheria Ships for Brokkr", + "author": "GameCult", + "version": (0, 1, 0), + "blender": (4, 3, 0), + "location": "View3D > Sidebar > Brokkr > Aetheria Ship", + "description": "Write Grease Pencil ship lines into a typed Aetheria .cc ship record", + "category": "Object", +} + +import bpy + +from .ship_cc import capture_grease_pencil, replace_lines + + +def _brokkr_cultlib(context): + brokkr = context.preferences.addons.get("brokkr_bridge") + if brokkr is None: + raise RuntimeError("Enable Brokkr before using Aetheria Ships") + return brokkr.preferences.cultlib_py_src + + +class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): + bl_idname = "aetheria.capture_ship_lines" + bl_label = "Capture Ship Lines" + bl_description = "Replace only the line slot in the selected typed ship .cc record" + bl_options = {"REGISTER"} + + def execute(self, context): + try: + obj = context.active_object + if obj is None: + raise ValueError("Select the ship's Grease Pencil LineArt object") + if not context.scene.aetheria_ship_cc_path: + raise ValueError("Choose an existing ship authoring .cc file") + path = bpy.path.abspath(context.scene.aetheria_ship_cc_path) + cultlib = _brokkr_cultlib(context) + lines = capture_grease_pencil( + obj, context.evaluated_depsgraph_get(), context.scene.frame_current, + evaluated=context.scene.aetheria_capture_evaluated_lines, + ) + count = replace_lines(path, cultlib, lines) + self.report({"INFO"}, f"Captured {count} lines into {path}") + return {"FINISHED"} + except (OSError, ValueError, RuntimeError, ImportError) as exc: + self.report({"ERROR"}, str(exc)) + return {"CANCELLED"} + + +class AETHERIA_PT_ship(bpy.types.Panel): + bl_label = "Aetheria Ship" + bl_idname = "AETHERIA_PT_ship" + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_category = "Brokkr" + + def draw(self, context): + layout = self.layout + layout.prop(context.scene, "aetheria_ship_cc_path") + layout.prop(context.scene, "aetheria_capture_evaluated_lines") + obj = context.active_object + layout.label(text=f"Source: {obj.name if obj else '(none)'}") + layout.operator("aetheria.capture_ship_lines", icon="GREASEPENCIL") + + +classes = (AETHERIA_OT_capture_ship_lines, AETHERIA_PT_ship) + + +def register(): + bpy.types.Scene.aetheria_ship_cc_path = bpy.props.StringProperty( + name="Ship .cc", subtype="FILE_PATH", description="Existing typed ship authoring record" + ) + bpy.types.Scene.aetheria_capture_evaluated_lines = bpy.props.BoolProperty( + name="Evaluated LineArt", default=True, + description="Capture the visible modifier result at the current frame", + ) + for cls in classes: + bpy.utils.register_class(cls) + + +def unregister(): + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + del bpy.types.Scene.aetheria_capture_evaluated_lines + del bpy.types.Scene.aetheria_ship_cc_path diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py new file mode 100644 index 000000000..4bedd2fb4 --- /dev/null +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -0,0 +1,93 @@ +"""Aetheria's Blender-side view of one typed ShipAuthoring CultCache record. + +The schema and authority live in Aetheria's C# ShipAuthoring type. This module +preserves the C# schema catalog and all slots it does not edit. Brokkr supplies +the Blender host and CultLib Python dependency path. +""" + +from __future__ import annotations + +import sys +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +SCHEMA = "aetheria.ship_authoring" +SCHEMATIC_LINES_SLOT = 4 + + +def _libraries(cultlib_packages: str): + source = Path(cultlib_packages) / "cultcache-py" / "src" + if source.exists() and str(source) not in sys.path: + sys.path.insert(0, str(source)) + import cultcache_py # type: ignore + import msgpack # type: ignore + return cultcache_py, msgpack + + +def read(path: str, cultlib_packages: str): + cultcache, msgpack = _libraries(cultlib_packages) + store = cultcache.SingleFileMessagePackBackingStore(path) + envelopes = store.pull_all() + ships = [envelope for envelope in envelopes if envelope.type == SCHEMA] + if len(envelopes) != 1 or len(ships) != 1: + raise ValueError(f"{path} must hold exactly one {SCHEMA} record") + envelope = ships[0] + body = msgpack.unpackb(envelope.payload, raw=False) + if not isinstance(body, list) or len(body) < SCHEMATIC_LINES_SLOT + 1: + raise ValueError(f"{path} has an incompatible {SCHEMA} payload") + return store, envelope, body, msgpack + + +def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> int: + store, envelope, body, msgpack = read(path, cultlib_packages) + if not lines: + raise ValueError("No Grease Pencil strokes were captured; the ship record was not changed") + body[SCHEMATIC_LINES_SLOT] = lines + updated = replace( + envelope, + payload=msgpack.packb(body, use_bin_type=True), + stored_at=datetime.now(timezone.utc).isoformat(), + ) + store.push(updated) + return len(lines) + + +def capture_grease_pencil(obj: Any, depsgraph: Any, frame_number: int, *, evaluated: bool = True) -> list[list[Any]]: + if obj.type != "GREASEPENCIL": + raise ValueError(f"{obj.name} is not a Grease Pencil object") + source = obj.evaluated_get(depsgraph) if evaluated else obj + lines: list[list[Any]] = [] + for layer in source.data.layers: + frames = [frame for frame in layer.frames if frame.frame_number <= frame_number] + if not frames: + continue + frame = max(frames, key=lambda candidate: candidate.frame_number) + for stroke in frame.drawing.strokes: + if len(stroke.points) < 2: + continue + material = source.data.materials[stroke.material_index] + rgba = ( + tuple(material.grease_pencil.color) + if material and material.grease_pencil + else tuple(material.diffuse_color) if material else (1.0, 1.0, 1.0, 1.0) + ) + points: list[float] = [] + radii: list[float] = [] + opacities: list[float] = [] + for point in stroke.points: + position = obj.matrix_world @ point.position + points.extend(float(value) for value in position) + radii.append(float(point.radius)) + opacities.append(float(point.opacity)) + lines.append([ + layer.name, + material.name if material else "", + points, + radii, + opacities, + bool(stroke.cyclic), + [float(value) for value in rgba], + ]) + return lines From 4ba4bbaf2e1d4e2c7e84404157111959ab4c8fa7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 18:30:02 +0200 Subject: [PATCH 002/113] Compose validated mod ships into a derived catalog --- Assets/Scripts/ServerShared/AetheriaStores.cs | 2 +- Assets/Scripts/ServerShared/ShipAuthoring.cs | 6 ++ Assets/Scripts/ServerShared/ShipModCatalog.cs | 100 ++++++++++++++++++ .../ServerShared/ShipModCatalog.cs.meta | 2 + docs/moddable-ship-authoring.md | 41 ++++++- docs/ship-collection-authoring.md | 2 +- tools/AetherDb/Program.cs | 2 +- tools/AetherDb/ShipAuthoringCommands.cs | 10 +- tools/blender/.gitignore | 2 + 9 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 Assets/Scripts/ServerShared/ShipModCatalog.cs create mode 100644 Assets/Scripts/ServerShared/ShipModCatalog.cs.meta create mode 100644 tools/blender/.gitignore diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index cd2589dc0..60975c38b 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -6,7 +6,7 @@ using GameCult.Caching.MessagePack; // Aetheria's stores: one home store per type, composed here and nowhere else. public static class AetheriaStores { - public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) }; + public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) }; public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) }; public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index 3eeb4eeed..c19ab153d 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -78,6 +78,9 @@ public static class ShipAuthoringStore { if (ship == null) throw new InvalidOperationException("Ship authoring record is null."); if (string.IsNullOrWhiteSpace(ship.Id)) throw new InvalidOperationException("Ship ID is required."); + if (!(ship.Id[0] >= 'a' && ship.Id[0] <= 'z' || ship.Id[0] >= '0' && ship.Id[0] <= '9') || ship.Id.Any(c => + !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-'))) + throw new InvalidOperationException($"{ship.Id}: ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens."); var hull = ship.Hull ?? throw new InvalidOperationException($"{ship.Id}: hull data is required."); if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required."); if (hull.Shape?.Cells == null || hull.Shape.Width < 1 || hull.Shape.Height < 1 || @@ -91,12 +94,15 @@ public static class ShipAuthoringStore var anchors = ship.Anchors ?? throw new InvalidOperationException($"{ship.Id}: anchors are required."); var ids = new HashSet(StringComparer.Ordinal); + var nodeIds = new HashSet(StringComparer.Ordinal); foreach (var anchor in anchors) { if (anchor == null || string.IsNullOrWhiteSpace(anchor.Id) || !ids.Add(anchor.Id)) throw new InvalidOperationException($"{ship.Id}: anchor IDs must be present and unique."); if (string.IsNullOrWhiteSpace(anchor.ModelNodeId)) throw new InvalidOperationException($"{ship.Id}: anchor {anchor.Id} needs a model node ID."); + if (!nodeIds.Add(anchor.ModelNodeId)) + throw new InvalidOperationException($"{ship.Id}: model node {anchor.ModelNodeId} is claimed by more than one anchor."); } foreach (var role in new[] { "map-icon", "hull-collider", "shield", "tractor" }) if (anchors.Count(anchor => anchor.Role == role) != 1) diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs new file mode 100644 index 000000000..eff18b115 --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; +using Newtonsoft.Json.Linq; +using System.Text; + +// Builds the one catalog snapshot CultCache needs. Sources remain the shipped catalog and each +// mod-owned ship.cc; this output is disposable and never becomes an authoring surface. +public static class ShipModCatalog +{ + public static CultRecordKey HullKey(string id) => new CultRecordKey("mod-hull:" + id); + public static CultRecordKey AuthoringKey(string id) => new CultRecordKey("mod-ship:" + id); + + public static int Compose(string shippedCatalog, string outputCatalog, string modsRoot) + { + var source = Path.GetFullPath(shippedCatalog); + var output = Path.GetFullPath(outputCatalog); + var root = Path.GetFullPath(modsRoot); + if (!File.Exists(source)) throw new FileNotFoundException("Shipped catalog is missing", source); + if (!Directory.Exists(root)) throw new DirectoryNotFoundException($"Mod root is missing: {root}"); + if (string.Equals(source, output, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The derived catalog cannot replace the shipped catalog."); + if (output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The derived catalog cannot replace a mod source or asset."); + + var ships = new List(); + var ids = new HashSet(StringComparer.Ordinal); + foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal)) + { + var path = Path.Combine(directory, "ship.cc"); + if (!File.Exists(path)) continue; + var ship = ShipAuthoringStore.Read(path); + if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal) || + !ids.Add(ship.Id)) + throw new InvalidOperationException($"{path}: ship ID must uniquely match its package directory name."); + var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset)); + if (!modelPath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + !File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"{ship.Id}: model asset must name an existing GLB inside its package."); + var modelNodes = ReadNodeIds(modelPath); + foreach (var anchor in ship.Anchors) + if (!modelNodes.Contains(anchor.ModelNodeId)) + throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}."); + CultRecordRefs.Validate(ship.Hull); + ships.Add(ship); + } + + Directory.CreateDirectory(Path.GetDirectoryName(output)); + var temporary = output + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.Copy(source, temporary); + using (var cache = AetheriaStores.Open(temporary, catalogWritable: true)) + { + foreach (var ship in ships) + { + var hullKey = HullKey(ship.Id); + var authoringKey = AuthoringKey(ship.Id); + if (cache.AllStoredDocuments.Any(record => record.Key.Equals(hullKey) || record.Key.Equals(authoringKey))) + throw new InvalidOperationException($"{ship.Id}: a mod key collides with an existing catalog record."); + cache.UpsertAsync(typeof(HullData), ship.Hull, hullKey).GetAwaiter().GetResult(); + cache.UpsertAsync(typeof(ShipAuthoring), ship, authoringKey).GetAwaiter().GetResult(); + } + cache.FlushAsync().GetAwaiter().GetResult(); + } + if (File.Exists(output)) File.Replace(temporary, output, null); + else File.Move(temporary, output); + return ships.Count; + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + // GLB is the xenos asset boundary. Only its node extras are read here; geometry belongs to the runtime importer. + public static HashSet ReadNodeIds(string path) + { + using var stream = File.OpenRead(path); + using var reader = new BinaryReader(stream); + if (stream.Length < 20 || reader.ReadUInt32() != 0x46546C67 || reader.ReadUInt32() != 2 || + reader.ReadUInt32() != stream.Length) + throw new InvalidOperationException($"{path}: invalid GLB 2 header."); + var chunkLength = reader.ReadUInt32(); + if (reader.ReadUInt32() != 0x4E4F534A || chunkLength > stream.Length - stream.Position) + throw new InvalidOperationException($"{path}: missing GLB JSON chunk."); + var json = JObject.Parse(Encoding.UTF8.GetString(reader.ReadBytes((int)chunkLength))); + var ids = new HashSet(StringComparer.Ordinal); + foreach (var node in json["nodes"]?.OfType() ?? Enumerable.Empty()) + { + var id = (string)node["extras"]?["aetheria.id"]; + if (string.IsNullOrEmpty(id)) continue; + if (!ids.Add(id)) + throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}."); + } + return ids; + } +} diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs.meta b/Assets/Scripts/ServerShared/ShipModCatalog.cs.meta new file mode 100644 index 000000000..c81d1965d --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 708d45e8a4144a6e89bcf40e96b0975a diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index e5fe66669..b44b7c2a3 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -85,6 +85,43 @@ semantic validator. The Blender capture command writes only line data and the still need to be built. The current gameplay catalog and prefab loader do not read these mod records. +## Runtime catalog seam + +`CultCache` routes `ItemData` to one home backing store, and +`ActionGameManager.CultCache` holds that store for the entire play session. +Attaching each mod's `.cc` as another item store would violate the cache's +one-home invariant. `ship-authoring compose` instead derives one runtime catalog from +the shipped catalog and validated mod packages, preserving all shipped record +keys. Each mod's stable `ShipAuthoring.Id` yields separate deterministic keys +for its copied `HullData` and its runtime authoring record. 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. + +Each immediate child of the mods directory has an ID matching its directory +name and contains `ship.cc` plus the GLB at the record's relative `ModelAsset` +path. Every referenced anchor uses an `aetheria.id` custom property exported +into a GLB node's `extras`. Compose checks the asset, unique IDs, and every +reference before atomically replacing the derived catalog. For example: + +```text +dotnet run --project tools/AetherDb -- ship-authoring compose GameData/Aetheria.cc GameData/Aetheria.modded.cc GameData/Mods +``` + +This command does not yet make the game open `Aetheria.modded.cc`; the runtime +loader and a complete playable ship are still outstanding. In the Quiet probe, +206 shipped records became 208 derived records: one `mod-hull:quiet` and one +`mod-ship:quiet`. A missing GLB node ID was rejected without changing the +previous derived file. The probe's four anchors and zero hardpoints are only +a data-path smoke, not a playable Quiet design. + +The construction boundary will resolve a mod hull's authoring record from its +derived key, load the package's compiled GLB at runtime, and build the +`ShipInstance` references from validated stable node IDs. It must fail before +scene construction when an asset or anchor is absent. [Unity glTFast's runtime +import](https://github.com/Unity-Technologies/com.unity.cloud.gltfast/blob/main/Packages/com.unity.cloud.gltfast/Documentation~/ImportRuntime.md) +is a candidate for the GLB step; it has not been added to the project. + ## Proof gates 1. A typed `.cc` record round-trips between C# and Blender's Python runtime, @@ -94,7 +131,9 @@ read these mod records. not a second copy of hull semantics. 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. + precise errors. It changes no live catalog or scene on failure. The + composer now performs this check for a package before replacing its + derived catalog. 4. A packaged mod ship loads in a built Unity player without Unity Editor or Blender installed. Runtime assembly creates the same components the current `ShipInstance` expects and uses the existing simulation rules. diff --git a/docs/ship-collection-authoring.md b/docs/ship-collection-authoring.md index b4e85f7a1..a92551ccd 100644 --- a/docs/ship-collection-authoring.md +++ b/docs/ship-collection-authoring.md @@ -1,6 +1,6 @@ # Ship collection authoring -Status: authoring contract for new ships. Longinus and Djinni predate this contract and keep their current prefabs. +Status: FBX-to-Unity-prefab contract for ships using the current catalog path. Longinus and Djinni predate this contract and keep their current prefabs. The parallel mod-owned `.cc` path is specified in [moddable-ship-authoring.md](moddable-ship-authoring.md). ## Objective and ownership diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index c392c3bf3..1803a74f4 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -38,7 +38,7 @@ public static class Program case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); case "ship-authoring": return ShipAuthoringCommands.Run(args.Skip(1).ToArray()); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose"); return 1; } } diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index ce21cc6ca..061f58e41 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -10,11 +10,17 @@ public static class ShipAuthoringCommands { if (args.Length == 0) { - Console.Error.WriteLine("ship-authoring create | inspect | validate "); + Console.Error.WriteLine("ship-authoring create | inspect | validate | compose "); return 1; } try { + if (args[0] == "compose" && args.Length == 4) + { + var count = ShipModCatalog.Compose(args[1], args[2], args[3]); + Console.WriteLine($"Composed {count} mod ships into {Path.GetFullPath(args[2])}"); + return 0; + } if (args[0] == "inspect" && args.Length == 2) { using var cache = ShipAuthoringStore.Open(args[1]); @@ -50,7 +56,7 @@ public static class ShipAuthoringCommands Console.WriteLine($"Created draft {path}; fill its model bindings and schematic in Blender."); return 0; } - Console.Error.WriteLine("ship-authoring create | inspect | validate "); + Console.Error.WriteLine("ship-authoring create | inspect | validate | compose "); return 1; } catch (Exception ex) diff --git a/tools/blender/.gitignore b/tools/blender/.gitignore new file mode 100644 index 000000000..7a60b85e1 --- /dev/null +++ b/tools/blender/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc From 24c2964046587b395c823060029c90d15c6ae635 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 19:10:44 +0200 Subject: [PATCH 003/113] Preview typed mod ship visuals and captured LineArt in Unity --- Assets/Scripts/Editor/ShipModPreview.cs | 51 ++++++++ Assets/Scripts/Editor/ShipModPreview.cs.meta | 2 + Assets/Scripts/Gameplay/ShipModVisual.cs | 114 ++++++++++++++++++ Assets/Scripts/Gameplay/ShipModVisual.cs.meta | 2 + Assets/Scripts/ServerShared/ShipModCatalog.cs | 57 ++++++--- Assets/Scripts/UI/Menu/TradeMenu.cs | 1 - Packages/manifest.json | 1 + Packages/packages-lock.json | 13 ++ docs/moddable-ship-authoring.md | 47 +++++--- tools/blender/aetheria_ships/__init__.py | 71 +++++++++-- 10 files changed, 314 insertions(+), 45 deletions(-) create mode 100644 Assets/Scripts/Editor/ShipModPreview.cs create mode 100644 Assets/Scripts/Editor/ShipModPreview.cs.meta create mode 100644 Assets/Scripts/Gameplay/ShipModVisual.cs create mode 100644 Assets/Scripts/Gameplay/ShipModVisual.cs.meta diff --git a/Assets/Scripts/Editor/ShipModPreview.cs b/Assets/Scripts/Editor/ShipModPreview.cs new file mode 100644 index 000000000..859c0a80f --- /dev/null +++ b/Assets/Scripts/Editor/ShipModPreview.cs @@ -0,0 +1,51 @@ +using System; +using UnityEditor; +using UnityEngine; + +public static class ShipModPreview +{ + // Batch proof: -executeMethod ShipModPreview.Smoke -shipModPath + public static async void Smoke() + { + var args = Environment.GetCommandLineArgs(); + var option = Array.IndexOf(args, "-shipModPath"); + if (option < 0 || option + 1 >= args.Length) + { + Debug.LogError("-shipModPath is required"); + EditorApplication.Exit(1); + return; + } + ShipModVisual.Instance visual = null; + try + { + visual = await ShipModVisual.LoadAsync(args[option + 1], null); + if (visual.Anchors.Count == 0 || visual.LineMesh == null || visual.LineMesh.vertexCount == 0) + throw new InvalidOperationException("Mod visual did not import anchors and line points."); + Debug.Log($"SHIP_MOD_VISUAL_SMOKE anchors={visual.Anchors.Count} points={visual.LineMesh.vertexCount} segments={visual.LineMesh.GetIndexCount(0) / 2}"); + visual.Destroy(); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + visual?.Destroy(); + EditorApplication.Exit(1); + } + } + + [MenuItem("Aetheria/Preview Mod Ship Package")] + private static async void Open() + { + var path = EditorUtility.OpenFilePanel("Ship authoring record", "GameData/Mods", "cc"); + if (string.IsNullOrEmpty(path)) return; + try + { + var visual = await ShipModVisual.LoadAsync(path, null); + Undo.RegisterCreatedObjectUndo(visual.Root, "Preview mod ship"); + Selection.activeGameObject = visual.Root; + SceneView.FrameLastActiveSceneView(); + Debug.Log($"Previewed {path}: {visual.Anchors.Count} anchors, {visual.LineMesh?.vertexCount ?? 0} line points."); + } + catch (Exception exception) { Debug.LogException(exception); } + } +} diff --git a/Assets/Scripts/Editor/ShipModPreview.cs.meta b/Assets/Scripts/Editor/ShipModPreview.cs.meta new file mode 100644 index 000000000..2e8ea6b35 --- /dev/null +++ b/Assets/Scripts/Editor/ShipModPreview.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d32248a66f6454a9be20458fa43f3ef diff --git a/Assets/Scripts/Gameplay/ShipModVisual.cs b/Assets/Scripts/Gameplay/ShipModVisual.cs new file mode 100644 index 000000000..9c4c0c33b --- /dev/null +++ b/Assets/Scripts/Gameplay/ShipModVisual.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GLTFast; +using UnityEngine; +using Object = UnityEngine.Object; + +// A mod package's visual assembly. It does not decide hull cells or hardpoint roles. +public static class ShipModVisual +{ + public sealed class Instance + { + public GameObject Root; + public IReadOnlyDictionary Anchors; + public Mesh LineMesh; + public Material LineMaterial; + + public void Destroy() + { + DestroyObject(Root); + DestroyObject(LineMesh); + DestroyObject(LineMaterial); + } + } + + private sealed class Nodes : GameObjectInstantiator + { + public Nodes(IGltfReadable gltf, Transform parent) : base(gltf, parent) { } + public Transform Get(uint index) => m_Nodes.TryGetValue(index, out var node) ? node.transform : null; + } + + public static async Task LoadAsync(string shipPath, Transform parent) + { + var package = ShipModCatalog.ReadPackage(shipPath); + var ship = package.Ship; + + var root = new GameObject("mod-ship:" + ship.Id); + root.SetActive(false); + root.transform.SetParent(parent, false); + var result = new Instance { Root = root }; + try + { + // glTFast's default defer agent creates a DontDestroyOnLoad object, which is + // unavailable in edit-mode previews. Play mode keeps its frame-budgeted agent. + var gltf = new GltfImport(deferAgent: Application.isPlaying ? null : new UninterruptedDeferAgent()); + if (!await gltf.LoadFile(package.ModelPath)) + throw new InvalidOperationException($"{ship.Id}: GLB import failed."); + var nodes = new Nodes(gltf, root.transform); + if (!await gltf.InstantiateMainSceneAsync(nodes)) + throw new InvalidOperationException($"{ship.Id}: GLB scene instantiation failed."); + result.Anchors = ship.Anchors.ToDictionary(anchor => anchor.Id, anchor => + nodes.Get(package.NodeIndices[anchor.ModelNodeId]) ?? + throw new InvalidOperationException($"{ship.Id}: GLB scene omitted node {anchor.ModelNodeId}."), + StringComparer.Ordinal); + result.LineMesh = BuildLineMesh(ship.SchematicLines); + if (result.LineMesh != null) + { + var lines = new GameObject("Schematic Lines"); + lines.transform.SetParent(root.transform, false); + lines.AddComponent().sharedMesh = result.LineMesh; + var shader = Shader.Find("Sprites/Default") ?? + throw new InvalidOperationException("Sprites/Default shader is unavailable for ship lines."); + result.LineMaterial = new Material(shader) { name = "Ship Lines" }; + lines.AddComponent().sharedMaterial = result.LineMaterial; + } + root.SetActive(true); + return result; + } + catch + { + result.Destroy(); + throw; + } + } + + private static void DestroyObject(Object target) + { + if (target == null) return; + if (Application.isPlaying) Object.Destroy(target); + else Object.DestroyImmediate(target); + } + + // Blender Z-up to Unity Y-up through Blender's glTF export and glTFast's handedness conversion. + // Segment topology preserves every authored point; display width is currently one screen pixel. + public static Mesh BuildLineMesh(IReadOnlyList lines) + { + if (lines == null || lines.Count == 0) return null; + var vertices = new List(); + var colors = new List(); + var segments = new List(); + foreach (var line in lines) + { + var count = line.Points.Length / 3; + var start = vertices.Count; + for (var i = 0; i < count; i++) + { + var offset = i * 3; + vertices.Add(new Vector3(-line.Points[offset], line.Points[offset + 2], -line.Points[offset + 1])); + var rgba = line.Color; + colors.Add(new Color(rgba?[0] ?? 1, rgba?[1] ?? 1, rgba?[2] ?? 1, + (rgba?[3] ?? 1) * (line.Opacities?[i] ?? 1))); + if (i > 0) { segments.Add(start + i - 1); segments.Add(start + i); } + } + if (line.Cyclic && count > 2) { segments.Add(start + count - 1); segments.Add(start); } + } + var mesh = new Mesh { name = "Captured Ship Lines", indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 }; + mesh.SetVertices(vertices); + mesh.SetColors(colors); + mesh.SetIndices(segments, MeshTopology.Lines, 0); + mesh.RecalculateBounds(); + return mesh; + } +} diff --git a/Assets/Scripts/Gameplay/ShipModVisual.cs.meta b/Assets/Scripts/Gameplay/ShipModVisual.cs.meta new file mode 100644 index 000000000..d64c22b9b --- /dev/null +++ b/Assets/Scripts/Gameplay/ShipModVisual.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a8a4f72a22b44819a71e43e21c7db894 diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index eff18b115..512f0509e 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -10,6 +10,13 @@ using System.Text; // mod-owned ship.cc; this output is disposable and never becomes an authoring surface. public static class ShipModCatalog { + public sealed class Package + { + public ShipAuthoring Ship; + public string ModelPath; + public Dictionary NodeIndices; + } + public static CultRecordKey HullKey(string id) => new CultRecordKey("mod-hull:" + id); public static CultRecordKey AuthoringKey(string id) => new CultRecordKey("mod-ship:" + id); @@ -31,20 +38,10 @@ public static class ShipModCatalog { var path = Path.Combine(directory, "ship.cc"); if (!File.Exists(path)) continue; - var ship = ShipAuthoringStore.Read(path); - if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal) || - !ids.Add(ship.Id)) - throw new InvalidOperationException($"{path}: ship ID must uniquely match its package directory name."); - var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset)); - if (!modelPath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || - !File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"{ship.Id}: model asset must name an existing GLB inside its package."); - var modelNodes = ReadNodeIds(modelPath); - foreach (var anchor in ship.Anchors) - if (!modelNodes.Contains(anchor.ModelNodeId)) - throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}."); - CultRecordRefs.Validate(ship.Hull); - ships.Add(ship); + var package = ReadPackage(path); + if (!ids.Add(package.Ship.Id)) + throw new InvalidOperationException($"{path}: duplicate ship ID {package.Ship.Id}."); + ships.Add(package.Ship); } Directory.CreateDirectory(Path.GetDirectoryName(output)); @@ -75,8 +72,27 @@ public static class ShipModCatalog } } + public static Package ReadPackage(string shipPath) + { + var path = Path.GetFullPath(shipPath); + var directory = Path.GetDirectoryName(path); + var ship = ShipAuthoringStore.Read(path); + if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal)) + throw new InvalidOperationException($"{path}: ship ID must match its package directory name."); + var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset)); + if (!modelPath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + !File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"{ship.Id}: model asset must name an existing GLB inside its package."); + var modelNodes = ReadNodeIds(modelPath); + foreach (var anchor in ship.Anchors) + if (!modelNodes.ContainsKey(anchor.ModelNodeId)) + throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}."); + CultRecordRefs.Validate(ship.Hull); + return new Package { Ship = ship, ModelPath = modelPath, NodeIndices = modelNodes }; + } + // GLB is the xenos asset boundary. Only its node extras are read here; geometry belongs to the runtime importer. - public static HashSet ReadNodeIds(string path) + public static Dictionary ReadNodeIds(string path) { using var stream = File.OpenRead(path); using var reader = new BinaryReader(stream); @@ -87,13 +103,16 @@ public static class ShipModCatalog if (reader.ReadUInt32() != 0x4E4F534A || chunkLength > stream.Length - stream.Position) throw new InvalidOperationException($"{path}: missing GLB JSON chunk."); var json = JObject.Parse(Encoding.UTF8.GetString(reader.ReadBytes((int)chunkLength))); - var ids = new HashSet(StringComparer.Ordinal); - foreach (var node in json["nodes"]?.OfType() ?? Enumerable.Empty()) + var ids = new Dictionary(StringComparer.Ordinal); + var nodes = json["nodes"]?.OfType() ?? Enumerable.Empty(); + uint index = 0; + foreach (var node in nodes) { var id = (string)node["extras"]?["aetheria.id"]; - if (string.IsNullOrEmpty(id)) continue; - if (!ids.Add(id)) + if (string.IsNullOrEmpty(id)) { index++; continue; } + if (!ids.TryAdd(id, index)) throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}."); + index++; } return ids; } diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 9bc285b75..f5f0a2771 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -3,7 +3,6 @@ using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Runtime.Remoting.Contexts; using TMPro; using UnityEngine; using UnityEngine.UI; diff --git a/Packages/manifest.json b/Packages/manifest.json index 6e31d3b05..c166932fd 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -6,6 +6,7 @@ "com.unity.ai.navigation": "2.0.14", "com.unity.burst": "1.8.30", "com.unity.cinemachine": "2.10.7", + "com.unity.cloud.gltfast": "6.20.0", "com.unity.ide.rider": "3.0.40", "com.unity.ide.visualstudio": "2.0.26", "com.unity.inputsystem": "1.20.0", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index d58d112e2..33cb60ccf 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -68,6 +68,19 @@ }, "url": "https://packages.unity.com" }, + "com.unity.cloud.gltfast": { + "version": "6.20.0", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.burst": "1.8.30", + "com.unity.collections": "2.6.8", + "com.unity.mathematics": "1.3.3", + "com.unity.modules.jsonserialize": "1.0.0", + "com.unity.modules.unitywebrequest": "1.0.0" + }, + "url": "https://packages.unity.com" + }, "com.unity.collections": { "version": "2.6.8", "depth": 1, diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index b44b7c2a3..3ded258c7 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -30,7 +30,12 @@ to Blender's add-on search path, enable `brokkr_bridge` then `aetheria_ships`, and install `msgpack` into Blender's Python environment. Create a draft with `dotnet run --project tools/AetherDb -- ship-authoring create `, choose that `.cc` in the **Aetheria -Ship** panel, select a Grease Pencil object, and press **Capture Ship Lines**. +Ship** panel, select an object in its ship collection, and press **Bind Ship +Collection**. The collection stores `aetheria.asset_kind=ship`, the stable +`aetheria.id`, and a relative `.cc` path. Select a Grease Pencil object in +that bound collection and press **Capture Ship Lines**. Capture checks the +collection ID against the record before writing; object and collection +display names are not IDs. `ship-authoring inspect ` reads drafts; `validate` requires complete hull, model, and anchor authoring. @@ -79,11 +84,14 @@ is not yet an end-to-end ship loader in the current game. deletes or demotes the old ship-specific catalog/prefab authority for each migrated hull; it does not keep two writable definitions of that hull. -At this proof stage, `ship-authoring validate` is the sole caller of the -semantic validator. The Blender capture command writes only line data and the -`inspect` command reads incomplete drafts. Editor preview and runtime loading -still need to be built. The current gameplay catalog and prefab loader do not -read these mod records. +`ship-authoring validate`, the catalog composer, and the visual importer call +the same semantic validator. Blender capture writes only line data and +`inspect` reads incomplete drafts. Unity's **Aetheria / Preview Mod Ship +Package** imports a validated package from disk, resolves stable GLB node IDs, +and draws the captured polylines directly as 3D line segments. Point opacity +and color are retained; radius and material-specific stroke styling are not +rendered yet. The current gameplay catalog and prefab loader do not read mod +records. ## Runtime catalog seam @@ -115,12 +123,14 @@ loader and a complete playable ship are still outstanding. In the Quiet probe, previous derived file. The probe's four anchors and zero hardpoints are only a data-path smoke, not a playable Quiet design. -The construction boundary will resolve a mod hull's authoring record from its -derived key, load the package's compiled GLB at runtime, and build the -`ShipInstance` references from validated stable node IDs. It must fail before -scene construction when an asset or anchor is absent. [Unity glTFast's runtime +The visual importer uses [Unity glTFast's runtime import](https://github.com/Unity-Technologies/com.unity.cloud.gltfast/blob/main/Packages/com.unity.cloud.gltfast/Documentation~/ImportRuntime.md) -is a candidate for the GLB step; it has not been added to the project. +to read the package GLB asynchronously and map its stable node IDs to Unity +transforms. It keeps a partially loaded object inactive and destroys it on +failure. The remaining construction boundary must wire those transforms to +`ShipInstance` and preload visual prototypes before synchronous zone loads. +It must preserve `capturepreset` writes to the shipped catalog and use the +derived catalog only for gameplay reads. ## Proof gates @@ -143,13 +153,14 @@ is a candidate for the GLB step; it has not been added to the project. ## Structural and build budget The proof reuses `HullData` simulation semantics, CultCache `.cc` persistence, -Brokkr's Blender host, and the current `ShipInstance` behavior. This pass adds -one authoring document type, one validator, and one Blender-facing editor -surface. A later pass needs one runtime construction boundary. It does not add -a daemon, a second gameplay -simulation, or another Unity prefab generator. The existing name-based FBX -builder remains available to existing content but is not an input to this -lane. Its retirement is a later cut, after migration. +Brokkr's Blender host, and the current `ShipInstance` behavior. It adds one +authoring document type and validator, a Blender-facing line capture surface, +a disposable catalog composer, and one runtime GLB/line visual importer with +an Editor preview. The remaining construction work is `ShipInstance` wiring +and boot-time preloading. No daemon, second gameplay simulation, or Unity +prefab generator is added. The existing FBX builder remains available to +existing content but is not an input to this lane. Its retirement follows +migration. Focused checks use `Aetheria.Shared` and `tests/Aetheria.Shared.Tests` under `netstandard2.1` / `net10.0`, Python CultCache interoperability checks, and diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index c512e8405..da2ae8043 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -12,7 +12,7 @@ bl_info = { import bpy -from .ship_cc import capture_grease_pencil, replace_lines +from .ship_cc import capture_grease_pencil, read, replace_lines def _brokkr_cultlib(context): @@ -22,6 +22,55 @@ def _brokkr_cultlib(context): return brokkr.preferences.cultlib_py_src +def _ship_collection(context): + obj = context.active_object + if obj is None: + raise ValueError("Select an object in the ship collection") + matches = [collection for collection in bpy.data.collections + if collection.get("aetheria.asset_kind") == "ship" and obj.name in collection.all_objects] + if len(matches) != 1: + raise ValueError("Selected object must belong to exactly one bound ship collection") + return matches[0] + + +class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): + bl_idname = "aetheria.bind_ship_collection" + bl_label = "Bind Ship Collection" + bl_description = "Bind the active collection to one typed ship .cc record" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + try: + if not context.scene.aetheria_ship_cc_path: + raise ValueError("Choose an existing ship authoring .cc file") + obj = context.active_object + if obj is None: + raise ValueError("Select an object in the ship collection") + direct = list(obj.users_collection) + marked = [collection for collection in bpy.data.collections + if collection.get("aetheria.asset_kind") == "ship" and obj.name in collection.all_objects] + if len(marked) == 1: + collection = marked[0] + elif len(marked) == 0 and len(direct) == 1: + collection = direct[0] + else: + raise ValueError("Select an object in exactly one ship collection") + path = bpy.path.abspath(context.scene.aetheria_ship_cc_path) + _, _, body, _ = read(path, _brokkr_cultlib(context)) + if collection.get("aetheria.asset_kind") not in (None, "ship"): + raise ValueError(f"{collection.name} has another aetheria.asset_kind") + if collection.get("aetheria.id") not in (None, body[0]): + raise ValueError(f"{collection.name} is bound to another ship ID") + collection["aetheria.asset_kind"] = "ship" + collection["aetheria.id"] = body[0] + collection["aetheria.ship_cc"] = bpy.path.relpath(path) + self.report({"INFO"}, f"Bound {collection.name} to {body[0]}") + return {"FINISHED"} + except (OSError, ValueError, RuntimeError, ImportError) as exc: + self.report({"ERROR"}, str(exc)) + return {"CANCELLED"} + + class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): bl_idname = "aetheria.capture_ship_lines" bl_label = "Capture Ship Lines" @@ -31,12 +80,14 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): def execute(self, context): try: obj = context.active_object - if obj is None: - raise ValueError("Select the ship's Grease Pencil LineArt object") - if not context.scene.aetheria_ship_cc_path: - raise ValueError("Choose an existing ship authoring .cc file") - path = bpy.path.abspath(context.scene.aetheria_ship_cc_path) + collection = _ship_collection(context) + if not collection.get("aetheria.ship_cc"): + raise ValueError(f"{collection.name} has no bound ship .cc path") + path = bpy.path.abspath(collection["aetheria.ship_cc"]) cultlib = _brokkr_cultlib(context) + _, _, body, _ = read(path, cultlib) + if body[0] != collection["aetheria.id"]: + raise ValueError("The bound collection ID does not match its .cc ship ID") lines = capture_grease_pencil( obj, context.evaluated_depsgraph_get(), context.scene.frame_current, evaluated=context.scene.aetheria_capture_evaluated_lines, @@ -59,13 +110,19 @@ class AETHERIA_PT_ship(bpy.types.Panel): def draw(self, context): layout = self.layout layout.prop(context.scene, "aetheria_ship_cc_path") + layout.operator("aetheria.bind_ship_collection", icon="LINKED") layout.prop(context.scene, "aetheria_capture_evaluated_lines") obj = context.active_object layout.label(text=f"Source: {obj.name if obj else '(none)'}") + try: + collection = _ship_collection(context) + layout.label(text=f"Ship: {collection['aetheria.id']}") + except (ValueError, KeyError): + layout.label(text="No bound ship collection", icon="ERROR") layout.operator("aetheria.capture_ship_lines", icon="GREASEPENCIL") -classes = (AETHERIA_OT_capture_ship_lines, AETHERIA_PT_ship) +classes = (AETHERIA_OT_bind_ship_collection, AETHERIA_OT_capture_ship_lines, AETHERIA_PT_ship) def register(): From 375d6bd40e693019b26a1a2086bacc7bccd5b369 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 19:23:01 +0200 Subject: [PATCH 004/113] Edit ship schematic and hardpoints in Blender .cc authoring --- docs/moddable-ship-authoring.md | 29 ++- tools/blender/aetheria_ships/__init__.py | 213 ++++++++++++++++++++++- tools/blender/aetheria_ships/ship_cc.py | 52 ++++++ 3 files changed, 282 insertions(+), 12 deletions(-) diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 3ded258c7..85f9c79f8 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -23,8 +23,9 @@ authored `HullData.Shape` in the `.cc`; strokes do not decide armor or mount footprints. `tools/blender/aetheria_ships` is a separate Blender add-on in Brokkr's -sidebar. It uses Brokkr's configured CultLib Python source and edits only the -line slot of an existing `aetheria.ship_authoring` record. To try it, make +sidebar. It uses Brokkr's configured CultLib Python source and edits the +line, hull shape, and hardpoint slots of an existing typed +`aetheria.ship_authoring` record. To try it, make `F:\Projects\Brokkr\surfaces\blender` and this repo's `tools\blender` visible to Blender's add-on search path, enable `brokkr_bridge` then `aetheria_ships`, and install `msgpack` into Blender's Python environment. @@ -36,6 +37,16 @@ Collection**. The collection stores `aetheria.asset_kind=ship`, the stable that bound collection and press **Capture Ship Lines**. Capture checks the collection ID against the record before writing; object and collection display names are not IDs. +**Load Ship Layout** opens the same `.cc` record's hull grid and hardpoints in +the panel. Toggle occupied grid cells, resize up to 32×32, and edit each +hardpoint's type, mount ID, position, footprint, rotation, armor, and firing +arc. A footprint uses top-to-bottom rows of `#` (occupied) and `.` (empty), +separated by `/`; `##/##` is a 2×2 mount. **Save Layout to .cc** replaces only +the hull shape and hardpoint slots. It preserves hull stats, anchors, visual +assets, lines, the record key, and schema. Save rejects a changed ship ID or +layout revision; reload first if another editor changed those fields. A line +capture leaves the loaded layout revision intact. The saved layout is a draft until +its mount IDs resolve to visual anchors and C# validation passes. `ship-authoring inspect ` reads drafts; `validate` requires complete hull, model, and anchor authoring. @@ -85,8 +96,9 @@ is not yet an end-to-end ship loader in the current game. migrated hull; it does not keep two writable definitions of that hull. `ship-authoring validate`, the catalog composer, and the visual importer call -the same semantic validator. Blender capture writes only line data and -`inspect` reads incomplete drafts. Unity's **Aetheria / Preview Mod Ship +the same semantic validator. Blender edits the hull grid, hardpoints, and +captured lines in the typed source record; `inspect` reads incomplete drafts. +Unity's **Aetheria / Preview Mod Ship Package** imports a validated package from disk, resolves stable GLB node IDs, and draws the captured polylines directly as 3D line segments. Point opacity and color are retained; radius and material-specific stroke styling are not @@ -136,9 +148,10 @@ derived catalog only for gameplay reads. 1. A typed `.cc` record round-trips between C# and Blender's Python runtime, preserving unknown future fields and record identity. -2. Blender edits schematic cells and hardpoints in that record, and validates - their association with stable visual node IDs. The model supplies geometry, - not a second copy of hull semantics. +2. Blender edits schematic cells and hardpoints in that record. Stable visual + node IDs still need a collection-to-package authoring action; the C# + validator checks their association with mount IDs and GLB nodes. The model + supplies geometry, not a second copy of hull semantics. 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 @@ -154,7 +167,7 @@ derived catalog only for gameplay reads. The proof reuses `HullData` simulation semantics, CultCache `.cc` persistence, Brokkr's Blender host, and the current `ShipInstance` behavior. It adds one -authoring document type and validator, a Blender-facing line capture surface, +authoring document type and validator, Blender-facing layout and line editing, a disposable catalog composer, and one runtime GLB/line visual importer with an Editor preview. The remaining construction work is `ShipInstance` wiring and boot-time preloading. No daemon, second gameplay simulation, or Unity diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index da2ae8043..b12a7b38e 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -12,7 +12,171 @@ bl_info = { import bpy -from .ship_cc import capture_grease_pencil, read, replace_lines +from .ship_cc import capture_grease_pencil, read, read_layout, replace_layout, replace_lines + + +HARDPOINT_TYPES = tuple((str(i), name, name) for i, name in enumerate(( + "Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Reactor", "Radiator", + "Shield", "Sensors", "Energy", "Ballistic", "Launcher", "ControlModule", "AetherDrive", +))) +ROTATIONS = (("0", "None", "None"), ("1", "Counterclockwise", "Counterclockwise"), + ("2", "Reversed", "Reversed"), ("3", "Clockwise", "Clockwise")) + + +class AETHERIA_PG_cell(bpy.types.PropertyGroup): + occupied: bpy.props.BoolProperty(name="Occupied") + + +class AETHERIA_PG_hardpoint(bpy.types.PropertyGroup): + mount_id: bpy.props.StringProperty(name="Mount ID") + kind: bpy.props.EnumProperty(name="Type", items=HARDPOINT_TYPES) + x: bpy.props.IntProperty(name="X", min=0, max=31) + y: bpy.props.IntProperty(name="Y", min=0, max=31) + footprint: bpy.props.StringProperty( + name="Footprint", default="#", description="Top-to-bottom rows of # and ., separated by /" + ) + rotation: bpy.props.EnumProperty(name="Rotation", items=ROTATIONS) + armor: bpy.props.FloatProperty(name="Armor", min=0) + firing_arc: bpy.props.FloatProperty(name="Firing Arc", min=0, max=360) + + +class AETHERIA_PG_layout(bpy.types.PropertyGroup): + ship_id: bpy.props.StringProperty() + ship_cc: bpy.props.StringProperty() + revision: bpy.props.StringProperty() + width: bpy.props.IntProperty(default=1) + height: bpy.props.IntProperty(default=1) + new_width: bpy.props.IntProperty(name="Width", default=1, min=1, max=32) + new_height: bpy.props.IntProperty(name="Height", default=1, min=1, max=32) + cells: bpy.props.CollectionProperty(type=AETHERIA_PG_cell) + hardpoints: bpy.props.CollectionProperty(type=AETHERIA_PG_hardpoint) + + +def _footprint(raw): + width, height, cells = raw[0] + return "/".join("".join("#" if cells[x * height + y] else "." for x in range(width)) + for y in range(height - 1, -1, -1)) + + +def _parse_footprint(text): + rows = text.split("/") + width = len(rows[0]) + height = len(rows) + if not (1 <= width <= 32 and 1 <= height <= 32 and + all(len(row) == width and set(row) <= {"#", "."} for row in rows)): + raise ValueError("Footprint must use equal-width #/. rows separated by /") + return [[width, height, [rows[height - 1 - y][x] == "#" + for x in range(width) for y in range(height)]]] + + +def _layout_path(context): + collection = _ship_collection(context) + if not collection.get("aetheria.ship_cc"): + raise ValueError("Ship collection has no bound .cc path") + return collection, bpy.path.abspath(collection["aetheria.ship_cc"]) + + +class AETHERIA_OT_load_layout(bpy.types.Operator): + bl_idname = "aetheria.load_ship_layout" + bl_label = "Load Ship Layout" + bl_options = {"REGISTER"} + + def execute(self, context): + try: + collection, path = _layout_path(context) + shape, hardpoints, revision = read_layout(path, _brokkr_cultlib(context)) + width, height, cells = shape + state = context.scene.aetheria_layout + state.ship_id = collection["aetheria.id"] + state.ship_cc = path + state.revision = revision + state.width = state.new_width = width + state.height = state.new_height = height + state.cells.clear() + for cell in cells: + state.cells.add().occupied = bool(cell) + state.hardpoints.clear() + for raw in hardpoints: + hp = state.hardpoints.add() + hp.kind = str(raw[0]) + hp.x, hp.y = raw[1] + hp.footprint = _footprint(raw[2]) + hp.mount_id = raw[3] or "" + hp.rotation = str(raw[4]) + hp.armor = raw[5] + hp.firing_arc = raw[6] + self.report({"INFO"}, f"Loaded {width}x{height} layout and {len(hardpoints)} hardpoints") + return {"FINISHED"} + except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: + self.report({"ERROR"}, str(exc)) + return {"CANCELLED"} + + +class AETHERIA_OT_resize_layout(bpy.types.Operator): + bl_idname = "aetheria.resize_ship_layout" + bl_label = "Resize Grid" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + state = context.scene.aetheria_layout + old = [cell.occupied for cell in state.cells] + width, height = state.width, state.height + expanded = [old[x * height + y] if x < width and y < height else False + for x in range(state.new_width) for y in range(state.new_height)] + state.cells.clear() + for cell in expanded: + state.cells.add().occupied = cell + state.width, state.height = state.new_width, state.new_height + return {"FINISHED"} + + +class AETHERIA_OT_add_hardpoint(bpy.types.Operator): + bl_idname = "aetheria.add_ship_hardpoint" + bl_label = "Add Hardpoint" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + context.scene.aetheria_layout.hardpoints.add() + return {"FINISHED"} + + +class AETHERIA_OT_remove_hardpoint(bpy.types.Operator): + bl_idname = "aetheria.remove_ship_hardpoint" + bl_label = "Remove Hardpoint" + bl_options = {"REGISTER", "UNDO"} + index: bpy.props.IntProperty() + + def execute(self, context): + state = context.scene.aetheria_layout + if 0 <= self.index < len(state.hardpoints): + state.hardpoints.remove(self.index) + return {"FINISHED"} + + +class AETHERIA_OT_save_layout(bpy.types.Operator): + bl_idname = "aetheria.save_ship_layout" + bl_label = "Save Layout to .cc" + bl_options = {"REGISTER"} + + def execute(self, context): + try: + collection, path = _layout_path(context) + state = context.scene.aetheria_layout + if state.ship_id != collection["aetheria.id"] or state.ship_cc != path: + raise ValueError("Load this collection's layout before saving") + cells = [cell.occupied for cell in state.cells] + hardpoints = [[int(hp.kind), [hp.x, hp.y], _parse_footprint(hp.footprint), + hp.mount_id, int(hp.rotation), hp.armor, hp.firing_arc] + for hp in state.hardpoints] + state.revision = replace_layout( + path, _brokkr_cultlib(context), state.ship_id, state.revision, + [state.width, state.height, cells], hardpoints + ) + self.report({"INFO"}, f"Saved {len(hardpoints)} hardpoints to {path}") + return {"FINISHED"} + except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: + self.report({"ERROR"}, str(exc)) + return {"CANCELLED"} def _brokkr_cultlib(context): @@ -121,11 +285,53 @@ class AETHERIA_PT_ship(bpy.types.Panel): layout.label(text="No bound ship collection", icon="ERROR") layout.operator("aetheria.capture_ship_lines", icon="GREASEPENCIL") + layout.separator() + layout.operator("aetheria.load_ship_layout", icon="IMPORT") + state = context.scene.aetheria_layout + if not state.ship_id: + return + layout.label(text=f"Layout: {state.ship_id}") + dims = layout.row(align=True) + dims.prop(state, "new_width") + dims.prop(state, "new_height") + dims.operator("aetheria.resize_ship_layout", text="Resize") + grid = layout.box() + grid.label(text="Hull cells (top row first)") + if len(state.cells) == state.width * state.height: + for y in range(state.height - 1, -1, -1): + row = grid.row(align=True) + row.label(text=str(y)) + for x in range(state.width): + row.prop(state.cells[x * state.height + y], "occupied", text="") + layout.label(text="Hardpoints") + for index, hp in enumerate(state.hardpoints): + box = layout.box() + header = box.row(align=True) + header.prop(hp, "mount_id") + header.operator("aetheria.remove_ship_hardpoint", text="", icon="X").index = index + box.prop(hp, "kind") + position = box.row(align=True) + position.prop(hp, "x") + position.prop(hp, "y") + box.prop(hp, "footprint") + box.prop(hp, "rotation") + box.prop(hp, "armor") + box.prop(hp, "firing_arc") + layout.operator("aetheria.add_ship_hardpoint", icon="ADD") + layout.operator("aetheria.save_ship_layout", icon="FILE_TICK") -classes = (AETHERIA_OT_bind_ship_collection, AETHERIA_OT_capture_ship_lines, AETHERIA_PT_ship) + +classes = (AETHERIA_PG_cell, AETHERIA_PG_hardpoint, AETHERIA_PG_layout, + AETHERIA_OT_load_layout, AETHERIA_OT_resize_layout, + AETHERIA_OT_add_hardpoint, AETHERIA_OT_remove_hardpoint, + AETHERIA_OT_save_layout, AETHERIA_OT_bind_ship_collection, + AETHERIA_OT_capture_ship_lines, AETHERIA_PT_ship) def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.Scene.aetheria_layout = bpy.props.PointerProperty(type=AETHERIA_PG_layout) bpy.types.Scene.aetheria_ship_cc_path = bpy.props.StringProperty( name="Ship .cc", subtype="FILE_PATH", description="Existing typed ship authoring record" ) @@ -133,11 +339,10 @@ def register(): name="Evaluated LineArt", default=True, description="Capture the visible modifier result at the current frame", ) - for cls in classes: - bpy.utils.register_class(cls) def unregister(): + del bpy.types.Scene.aetheria_layout for cls in reversed(classes): bpy.utils.unregister_class(cls) del bpy.types.Scene.aetheria_capture_evaluated_lines diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 4bedd2fb4..727db49e0 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -8,6 +8,7 @@ the Blender host and CultLib Python dependency path. from __future__ import annotations import sys +import hashlib from dataclasses import replace from datetime import datetime, timezone from pathlib import Path @@ -15,6 +16,9 @@ from typing import Any SCHEMA = "aetheria.ship_authoring" SCHEMATIC_LINES_SLOT = 4 +HULL_SLOT = 1 +HULL_SHAPE_SLOT = 5 +HULL_HARDPOINTS_SLOT = 23 def _libraries(cultlib_packages: str): @@ -54,6 +58,54 @@ def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> i return len(lines) +def read_layout(path: str, cultlib_packages: str): + _, _, body, msgpack = read(path, cultlib_packages) + hull = body[HULL_SLOT] + if not isinstance(hull, list) or len(hull) <= HULL_HARDPOINTS_SLOT: + raise ValueError("Ship hull has an incompatible typed payload") + return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], _layout_revision(hull, msgpack) + + +def _layout_revision(hull, msgpack): + return hashlib.sha256(msgpack.packb( + [hull[HULL_SHAPE_SLOT], hull[HULL_HARDPOINTS_SLOT]], use_bin_type=True + )).hexdigest() + + +def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_revision: str, + shape: list[Any], hardpoints: list[list[Any]]) -> str: + store, envelope, body, msgpack = read(path, cultlib_packages) + if body[0] != expected_id: + raise ValueError("The bound ship ID changed; reload its layout") + hull = body[HULL_SLOT] + if _layout_revision(hull, msgpack) != expected_revision: + raise ValueError("The .cc layout changed since Load; reload before saving") + width, height, cells = shape + if not (1 <= width <= 32 and 1 <= height <= 32 and len(cells) == width * height): + raise ValueError("Hull grid must be 1..32 cells wide and high with one value per cell") + if not any(cells): + raise ValueError("Hull grid needs an occupied cell") + previous = {hp[3]: hp[7:] for hp in body[HULL_SLOT][HULL_HARDPOINTS_SLOT] + if isinstance(hp, list) and len(hp) > 7 and isinstance(hp[3], str)} + updated_hardpoints = [] + for hardpoint in hardpoints: + if len(hardpoint) != 7 or not isinstance(hardpoint[3], str) or not hardpoint[3]: + raise ValueError("Each hardpoint needs seven typed fields and a stable mount ID") + hp_width, hp_height, hp_cells = hardpoint[2][0] + if not (1 <= hp_width <= 32 and 1 <= hp_height <= 32 and + len(hp_cells) == hp_width * hp_height and any(hp_cells)): + raise ValueError(f"Hardpoint {hardpoint[3]} has an invalid footprint") + updated_hardpoints.append(hardpoint + previous.get(hardpoint[3], [])) + hull[HULL_SHAPE_SLOT] = [shape] + hull[HULL_HARDPOINTS_SLOT] = updated_hardpoints + store.push(replace( + envelope, + payload=msgpack.packb(body, use_bin_type=True), + stored_at=datetime.now(timezone.utc).isoformat(), + )) + return _layout_revision(hull, msgpack) + + def capture_grease_pencil(obj: Any, depsgraph: Any, frame_number: int, *, evaluated: bool = True) -> list[list[Any]]: if obj.type != "GREASEPENCIL": raise ValueError(f"{obj.name} is not a Grease Pencil object") From 568bc002562853f00d7c6218d97e661afc8a0435 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 12:26:14 +0200 Subject: [PATCH 005/113] Refresh the moddable-ships map for assimilation into Eureka (Imagination, 2026-09-29) Re-anchored to the post-fire-control-12 Body (synthetic merge tested on Yggdrasil: 379/364/381 green). Codex's map is raw material; this is the proposal. Operator questions open. Co-Authored-By: Claude Opus 5.5 --- docs/moddable-ships-cut.md | 441 +++++++++++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 docs/moddable-ships-cut.md diff --git a/docs/moddable-ships-cut.md b/docs/moddable-ships-cut.md new file mode 100644 index 000000000..cb5aab860 --- /dev/null +++ b/docs/moddable-ships-cut.md @@ -0,0 +1,441 @@ +# Aetheria Codex assimilation: refreshed cut maps (mining, moddable ships) + +Date 2026-09-29. Imagination output for Self. It re-anchors two Codex lanes to the Body as it will stand after +`codex/fire-control-12` merges to `master`. The Codex maps are raw material, not authority. Where this file and +a Codex map disagree, this file is the proposal and the Codex map is the input. + +## How this was established + +- **Branch tips read, 2026-09-29, after `git fetch`.** + - `origin/master` `9b85211f` + - `origin/codex/fire-control-12` `b66ba524` + - `origin/codex/mining` `283ce7dc` + - `origin/codex/moddable-ships` `375d6bd4` + - Nothing in `F:\Projects\Aetheria`'s working tree was read or switched. Every read is `git show` or `git grep` + against a SHA. +- **The post-fire-control Body is a synthetic merge.** `git merge-tree --write-tree origin/master + origin/codex/fire-control-12` is clean, tree `91576243`. I wrapped it in an unreferenced local commit, + `8a2db490` (no ref, no push to origin), so it can be built. The fire-control checklist (`docs/merge-to-master-checklist.md:7,107` + on `b66ba524`) predicts the same clean merge. +- **Anchors.** Each `file:line` names a real SHA, never the synthetic one: + - `b66ba524` for files only fire control touched; + - `283ce7dc` for files the mining branch touched; + - `375d6bd4` for files the ships branch touched. + + The three file sets are disjoint. I checked with `git diff --name-only` over each branch's own range, and none + of the three pairs shares a file. So each anchored line is byte-identical in the merged tree. +- **Tests ran on Yggdrasil, against synthetic merges** (section 0). CultLib `45c2f40` and CultMath `6d5e209` are + the pins in `Directory.Build.props`. The two LFS files the tests read (`GameData/Aetheria.cc`, + `Assets/Resources/Settings.asset`) were staged beside the job, because the verify mirror carries no LFS. +- **Catalog facts** come from a probe that opens the merged tree's `GameData/Aetheria.cc` through + `AetheriaStores.Open` (section 0). + +## 0. Probe results + +**Test suite on the synthetic merges**, Yggdrasil, `dotnet test tests/Aetheria.Shared.Tests`, CultLib `45c2f40`, +CultMath `6d5e209`, the `dotnet` image: + +| Tree | Commit (local, unreferenced) | Result | +|---|---|---| +| master + fire-control-12 + mining | `396d19e5` | **379 passed, 0 failed** | +| master + fire-control-12 + ships | `0036204f` | **364 passed, 0 failed** | +| master + fire-control-12 + mining + ships | `474c35c3` | **381 passed, 0 failed** | + +Fire control closed 12.4 at 362 tests. 379 = 362 + 17 mining tests, 364 = 362 + 2 ship tests, and 381 is both +together. Nothing on either branch breaks a fire-control test, and nothing in fire control breaks a branch test. +This proves the headless simulation only. No Unity compile ran. + +**Catalog probe** (`scratchpad/probe/Program.cs`: it opens `GameData/Aetheria.cc` through `AetheriaStores.Open` +in the merged tree; the catalog oid `2aa79c0a` is the same in all three merges and at `b66ba524`): + +- 124 items: 32 `GearData`, 18 `WeaponItemData`, 13 `SimpleCommodityData`, 5 `HullData`, 51 compound + commodities, 4 cargo bays, 1 docking bay. +- **Weapon damage types:** Kinetic 9, Electric 3, Optical 3, Thermal 3. There is none of Corrosive or Ionizing, + though the `DamageType` enum has all six. The mining map said Kinetic 10. +- **Penetration > 0:** only Autocannon, LRMM72 and SRMM72, each at 0.25. +- **DamageSpread > 0:** GT 3K 3.25, plight 3.3, pswarm 1.5, scorched void policy 1.5, and SRMM72 0.5. The map + listed four and missed SRMM72. +- **No weapon has `BlastRadius` or `Fuse`.** Blasts are dormant in shipped content until F12-2 authors them. +- **Simple commodities:** the 13 the map named. Every one is priced 0 **except Ammo, at 1000**. The map's + "every live simple commodity has Price 0" is off by one. +- **No live item carries a scanner or mining behaviour**, on fire control or on mining merged. +- **Pre-existing, on `b66ba524` as well as on the merges:** four `GearData` records (Refinery, Deep Ore + Extractor, Assembly Line, Shipyard) hold a **null element** in `Behaviors` (index 0 of 1). It does not come from + mining's union-26 retirement: the same four are null on fire control alone. This matters to variants C4 (A.6). +- **Shipped hull mount ids are not unique:** + + | Hull | Hardpoints | Distinct `Transform`s | Empty `Transform`s | + |---|---|---|---| + | Djinni | 22 | 20 | 3 | + | Longinus | 13 | 12 | 2 | + | LonginusX | 10 | 9 | 2 | + | Turret | 7 | 3 | 5 | + | Zenith | 5 | 1 | 5 | + + All five hulls use `Prefab`. This matters to ship migration (S5). + +## Shared precondition for both campaigns + +Both campaigns wait on one event: `codex/fire-control-12` merging into `master` with `--no-ff`, which Self does +after the operator signs off (`docs/merge-to-master-checklist.md:105-113` on `b66ba524`). Until then, neither +campaign has a base to merge onto except the fire-control branch itself. After it: + +``` +git switch codex/mining && git merge --no-ff master # in a worktree, never the operator's tree +git switch codex/moddable-ships && git merge --no-ff master +``` + +**Merge, do not rebase.** Both maps and their Soul receipts cite landed SHAs: mining cites 11 of its 13 commits, +and the ships doc cites its commits' results. A rebase rewrites every one of those SHAs, which strands the maps' +evidence. The merge has no conflicts (below), so it costs one merge commit per branch. + +--- + +# Part B. Moddable ships + +## B.1 Status header (verified against git) + +Branch `codex/moddable-ships`, tip `375d6bd4`: 4 commits on `4b594e11`. `4b594e11` is an ancestor of fire +control, which has since moved 8 commits to `b66ba524`. Neither side touches the other's files. + +| SHA | What landed | +|---|---| +| `42eb5214` | `ShipAuthoring` record and validator; `AetherDb ship-authoring create/inspect/validate`; Blender LineArt capture; 2 xunit tests | +| `4ba4bbaf` | `ShipModCatalog.Compose` (derived catalog) and GLB node-id reader | +| `24c29640` | Unity `ShipModVisual` (glTFast runtime import, line mesh) and Editor preview; `com.unity.cloud.gltfast` 6.20.0 added to `Packages/manifest.json` | +| `375d6bd4` | Blender layout editing (hull grid, hardpoints) through `ship_cc.py` | + +The delta is +511 C# (including `tools/AetherDb`), +494 Python, +119 tests, and one new Unity package dependency. +It also carries a stray fix in `TradeMenu.cs` (an unused `using System.Runtime.Remoting.Contexts`). + +**Test counts.** The two ship tests pass on the merged tree: 364/364 (section 0). + +**Proof-gate status against the doc's own gates (`docs/moddable-ship-authoring.md:149-164` @`375d6bd4`):** + +| Gate | Doc implies | Verified in git | +|---|---|---| +| 1. C#/Python round trip preserving unknown fields | met | **C# only.** `StandaloneShipRoundTripsWithoutTouchingTheCatalog` pins C#. The Python half and "preserving unknown future fields" rest on the Quiet probe, whose output `Build/quiet.cc` is git-ignored. No repository test exercises `ship_cc.py`. | +| 2. Blender edits cells and hardpoints | met, except node-id authoring | **Code exists, unpinned.** `ship_cc.replace_layout` (`ship_cc.py:75-106`) has no test. | +| 3. Validator rejects malformed records, changes nothing on failure | met, and compose checks it | **Validator pinned.** `InvalidSchematicOrDanglingMountCannotBePublished` covers 5 cases. **Compose unpinned:** no test covers key collision, the atomic replace, a missing GLB node, or "the previous derived file is unchanged on failure" (`ShipModCatalog.cs:23-73`). | +| 4. Loads in a built player | outstanding | not started | +| 5. Play checks | outstanding | not started | + +**Corrections to `docs/moddable-ship-authoring.md` at `375d6bd4`:** + +1. **`:9-16` and `:132-136` report the Quiet probe's numbers as facts.** They include 1,040 polylines and + "206 shipped records became 208". Nothing in git reproduces them. Label them as an unrecorded probe, or + commit a fixture that pins them. +2. **The composed catalog holds two copies of every mod hull.** + - `:114-118` says compose preserves one owner per ship. + - `ShipModCatalog.cs:60-61` upserts `ship.Hull` as a `HullData` at `mod-hull:`, **and** the whole + `ShipAuthoring`, whose key 1 is the same `HullData`, at `mod-ship:`. + - Gameplay can read only the first. The second is a dead copy of the same truth inside a derived file. Q1 + decides the fix. +3. **`AetheriaStores.CatalogTypes` now routes `ShipAuthoring` to the shipped catalog** (`AetheriaStores.cs:9` + @`375d6bd4`), while the doc says the shipped catalog "contains no copy of that ship". Routing a type is not + holding a record, so this is harmless today. It stays correct only while nothing writes a `ShipAuthoring` + through a catalog-writable cache on `Aetheria.cc`. Name that invariant in the doc. +4. **`:98-106` "the same semantic validator" on every path.** The Blender side validates nothing semantic: it + checks grid bounds and footprint shape (`ship_cc.py:83-97`). "Shared path" is true for the C# callers only. + +## B.2 Merge path onto the fire-control-12 lineage + +| Probe | Result | +|---|---| +| `git merge-tree --write-tree origin/codex/fire-control-12 origin/codex/moddable-ships` | clean, tree `97711bcd` | +| `git merge-tree --write-tree 8a2db490 origin/codex/moddable-ships` (master+fc12) | clean, tree `88672977` | +| on top of mining merged into master+fc12 | clean, tree `a7410174` | +| files shared with fire control `4b594e11..b66ba524` (6 files: `Entity.cs`, `FireControl.cs`, two test files, two docs) | none | + +The merged tree passes the full headless suite: 364/364 alone, and 381/381 with mining (section 0). + +The only cross-lane seam is `Packages/manifest.json` (glTFast). It merges textually, but it needs Unity to +resolve the package on first open, like the CultMath tag already does (`docs/merge-to-master-checklist.md:14`). + +## B.3 Ordered cuts + +| Cut | Nature | Blocked by | +|---|---|---| +| S0 | Placement: merge master, correct the doc | fire-control-12 on master | +| S-Soul | Fix batch from the parallel Soul pass | Soul's report | +| S1 | One hull record, and a hull names its visual | MQ1 | +| S2 | The game composes and loads mod ships (`ShipInstance` wiring, boot preload) | S1; MQ4, MQ5 | +| S3 | Gate 4: a built player loads a mod ship | S2; a playable mod ship (content) | +| S4 | Gate 5: play checks | S3 | +| S5 | Migration of shipped hulls, onto variants; FBX builder retirement | S4; MQ2, MQ3; variants C4 | + +### S0. Placement and doc correction + +- **Repo/branch:** Aetheria, `codex/moddable-ships`, in a worktree. +- **Deletes first:** none. +- **Keeps/moves/adds:** `git merge --no-ff master`. Apply corrections 1-4 to the doc. +- **Verification:** + - Yggdrasil full suite on the merge (the M0 command). + - Unity batchmode compile of the merged tree with glTFast resolved. This is Self's step, and it needs network + for the package. + - The Editor smoke already in the branch: `-executeMethod ShipModPreview.Smoke -shipModPath ` + (`ShipModPreview.cs:8` @`375d6bd4`), run against a committed fixture package. It is the first recorded run + of that smoke. +- **Subtraction:** docs only. + +### S-Soul. Fix slot for the parallel Soul pass + +- **Repo/branch:** `codex/moddable-ships`, after S0. +- **Content:** Soul's findings. Two areas this map expects Soul to own, and does not duplicate: + - Compose's unpinned failure paths (B.1, gate 3). + - The hand-copied schema on the Python side: + - `ship_cc.py:17-21` slot constants (`HULL_SLOT 1`, `HULL_SHAPE_SLOT 5`, `HULL_HARDPOINTS_SLOT 23`); + - `ship_cc.py:88-98` hardpoint slots 0-6; + - `__init__.py:18-21`, a copy of the `HardpointType` and `ItemRotation` enums. + + All of these match C# today: `ItemData.cs:287` `Shape` is `Key(5)`, `HullData.Hardpoints` is `Key(23)` at + `:512`, and `HardpointData` is `:553-563`, all @`b66ba524`. `Enums.cs:38` matches too. The hazard is drift: + fire control added slot 6 (`FiringArc`) to `HardpointData` in this same lineage. The memory + `honor-invariants-over-workarounds` names hand-copied schemas as a known failure. The coherent fix reads + the published schema catalog. Soul decides whether it is in scope. + +### S1. One hull record, and a hull names its visual (written for MQ1 = B) + +- **Repo/branch:** `codex/moddable-ships`, after S-Soul. +- **Deletes first:** + - `ShipAuthoring.Hull` (`ShipAuthoring.cs:15`, key 1) becomes a retired-key comment. + - The embedded-hull branch of the validator (`ShipAuthoring.cs:84-133`) moves; it does not duplicate. + - The second upsert in `ShipModCatalog.Compose` (`ShipModCatalog.cs:60-61`) becomes a verbatim copy of the + mod store's records. + - `ship_cc.py`'s `body[HULL_SLOT]` indirection (`:63-66`, `:80-100`). + - The only existing `.cc` with the old shape is the git-ignored `Build/quiet.cc` probe. No committed content + migrates. +- **Adds:** + - A mod `.cc` holds two records: a `HullData` at the deterministic key `mod-hull:` (`ShipModCatalog.cs:20`) + and the `ShipAuthoring` visual package (`Id`, `ModelAsset`, `Anchors`, `SchematicLines`). + - `HullData` gains a nullable `CultRecordRef Visual` at the next free key on its ancestry. + `EquippableItemData` owns keys up to 31 (the comment at `ItemData.cs` `WeaponItemData.Fuse`), so it is 32, + unless C4 claims that slot first. + - The validator refuses a hull with both `Prefab` and `Visual` set, or neither, when it is a ship. + - The validator is one function over (hull, visual). It checks every `Hardpoints[].Transform` against the + visual's anchor ids, the checks that are at `ShipAuthoring.cs:111-136` today. +- **Authority map:** + - Owner: the `HullData` record owns hull semantics (cells, hardpoints, stats). The `ShipAuthoring` record owns + the visual package (model path, anchor-to-node map, lines). + - Inputs: the mod `.cc`, and the GLB node extras. + - Outputs: records copied verbatim into the derived catalog. + - Demotion: `ShipAuthoring` is no longer an owner of hull data. `mod-ship:` in the derived catalog is the + same record as in the mod store. + - Forbidden writers: compose transforming records; Blender writing hull slots inside `ShipAuthoring`; any + lookup from hull to visual by key-prefix convention. The typed `Visual` ref is the only binding. + - Shared paths: `ship-authoring validate`, compose, the Editor preview and the runtime loader all call the one + `(hull, visual)` validator. + - Deletion line: after S1, `grep -n "\.Hull\b" ShipAuthoring.cs ShipModCatalog.cs` finds no embedded hull. +- **Verification** (Yggdrasil, `--filter FullyQualifiedName~ShipAuthoring`, then the full suite): + - `ComposedCatalogHoldsOneHullPerModShip`: the negative for correction 2. It fails today. + - `HullNamesExactlyOneVisual`: Prefab xor Visual. + - `HardpointsMustResolveToTheVisualsAnchors`: moved from the old validator. + - `ComposeRefusesAKeyCollisionAndLeavesThePreviousFile` and `ComposeRefusesAMissingGlbNode`: the gate 3 + negatives, unless S-Soul already added them. + - `AHullVariantInheritsItsVisual`: runs only once Aetheria is on variants C4. It is the reason for B, and it + is recorded as pending until then. + - A Python check, in the Blender-free `python:3.12-slim` image with `pip install msgpack -e /packages/cultcache-py`: + `ship_cc.replace_layout` on a C#-written fixture preserves every untouched slot, then C# reopens it and it + validates. This pins gate 1's Python half for the first time. + - Operator: in Blender, load a layout, change a mount, save, then `ship-authoring validate`. +- **Subtraction estimate:** about 40 removed (the embedded-hull paths and the duplicate upsert), about 35 added + (`Visual`, the joint validator's plumbing), and about 25 in the Python path. + +### S2. The game composes and loads mod ships + +- **Repo/branch:** `codex/moddable-ships`, after S1. +- **Deletes first:** none. This cut is additive: it is the construction boundary the doc names at `:141-145`. +- **Adds, per MQ4 and MQ5:** + - **Compose at boot** (MQ5 = A): `ActionGameManager` opens its catalog at `ActionGameManager.cs:45-60` + (`b66ba524`). When `GameData/Mods` holds packages, it opens the derived file instead, composed at boot into + the persistent data path. `capturepreset` keeps writing the shipped catalog (`:605-620`, and the + `Loadout.cs:74` writable open). + - **Boot preload:** after the catalog opens and before the first zone loads, every hull with `Visual` set + imports its GLB through `ShipModVisual.LoadAsync` into an inactive prototype keyed by hull key. + `LoadEntity` then instantiates synchronously, as it does for prefabs. + - **Assembly:** a `ShipInstance` template prefab (Addressable) carries the shared effect prototypes: shield, + tractor, thruster particles, radiator material. The loader parents the GLB under it and fills + `EntityInstance`'s arrays (`EntityInstance.cs:15-27` @`b66ba524`: `MapIcon`, `Shield`, `HullColliders`, + `EquipmentHardpoints`, `RadiatorHardpoints`, `ThrusterHardpoints`, `WeaponHardpoints`, + `ArticulationPoints`, and `ShipInstance.TractorBeam`) from anchors by role. The name matches that + `EntityInstance.cs:245,260,271` and `ShipInstance.cs:69-70` do against `hp.Transform` keep working, because + anchor ids are the mount ids. + - **`ZoneRenderer.LoadEntity`** (`ZoneRenderer.cs:289-310` @`b66ba524`) branches on `hullData.Prefab` or + `hullData.Visual`. That is one branch point, owned by the hull record. +- **Authority map:** + - Owner: the hull's `Visual` ref decides the visual. The anchor role decides the component slot. + - Inputs: the derived catalog snapshot, the mod package directory, and the template prefab. + - Outputs: a `ShipInstance` indistinguishable to the simulation from a prefab one. + - Derived state: the prototypes (cache-only, rebuilt each boot) and the derived catalog (disposable). + - Forbidden writers: prefab arrays hand-wired for a mod ship; any async load during `LoadEntity`; any gameplay + read of a mod package outside the derived catalog. + - Shared paths: new game, Continue, zone entry, and wormhole transit all go through `LoadEntity`. +- **Verification:** + - Yggdrasil: `ComposeAtBootIsDeterministic`. The same packages give byte-identical derived records, which + pins run-store references across relaunches. + - `RunReferencingAMissingModRefusesContinue`, per MQ4. + - Unity batchmode (Self): an Editor play-mode smoke loads a fixture mod ship into a zone and asserts + `ThrusterHardpoints`, `WeaponHardpoints` and the collider are populated. + - Operator: none yet (S3 and S4). +- **Subtraction estimate:** 0 removed, about 150 added (loader, template, preload). This is the capability gate 4 + buys. + +### S3. Gate 4: a built player + +A player build loads a composed mod ship without the Editor or Blender. Only Self (the batchmode build) and the +operator (launch) can do this. It also requires a playable mod ship: a complete package with hardpoints, not the +Quiet probe, which has four anchors and zero hardpoints (`docs/moddable-ship-authoring.md:134-136`). That is +content, and it is the operator's to author or commission. + +### S4. Gate 5: play checks + +The operator only: spawn, equip, thrust, fire, shield, damage, save and reload, and the schematic UI for the mod +ship. It runs on master's post-fire-control rules, so the checklist's steps 11-15 (arcs, rolled impacts, blasts) +apply to the mod hull too. + +### S5. Migration onto variants + +After gate 5, and after MQ2 and MQ3. +- Shipped hulls move to `Visual` one at a time, and each migration deletes that hull's `Prefab`. +- Hull families collapse into variants in `Aetheria.cc`. The variants scan found Longinus/LonginusX, differing + in `Behaviors`, `Hardpoints` and two cosmetic members (`CultLib docs/document-variants-cut.md` §6). +- It waits on variants C4 (Aetheria adoption at `caching-unity-v1.5.0`). +- **Mount ids come first.** The typed lane requires unique, non-empty mount ids (`ShipAuthoring.cs:115-117` + @`375d6bd4`). No shipped hull has them: Djinni has 3 empty `Transform`s and one duplicate, and Zenith has 5 + hardpoints with one distinct `Transform` (section 0). Each migration first assigns unique mount ids and repoints + the prefab-era name matches. `Transform` is also not usable as a C2 element id, so C4's minted id and the mount + id stay two members: one is the identity for variants, and the other names the model node. +- **Deletion line:** when the last hull migrates, `HullData.Prefab` (key 24) retires, and `ShipPrefabAuthoring.cs` + goes (MQ3). + +## B.4 Subtraction ledger (estimate) + +| Cut | Removed | Added | Formats, targets, dependencies | +|---|---|---|---| +| landed | 1 | ~511 C#, ~494 Py, 119 test | `aetheria.ship_authoring` document type; glTFast package | +| S1 | ~40 | ~35 C#, ~25 Py | `ShipAuthoring` key 1 retired; `HullData` `Visual` slot added | +| S2 | 0 | ~150 | one template prefab; no new target | +| S5 | per hull: its prefab; finally `ShipPrefabAuthoring.cs` (252) plus its smoke (80) and `HullData.Prefab` | ~0 | FBX builder retired | + +The lane is net additive until S5, and S5 is where the old authority dies. Keeping two ship-authoring paths alive +indefinitely is the liability MQ3 exists to close. + +## B.5 Operator questions (moddable ships), one fork each + +**MQ1. Where does a mod ship's hull live, and how does a hull find its model?** (Blocks S1.) + +*Context.* +- Today a mod ship is one `ShipAuthoring` record with the `HullData` embedded inside it. +- Compose writes that hull twice into the derived catalog: once as a `HullData`, and once inside the + `ShipAuthoring` copy. +- The runtime has no binding yet from a hull to its model; the doc lists it as outstanding. +- You want variants to kill duplicate data entry for ships. A variant is a whole record whose base is another + record of the same type in the same store. +- "LonginusX is Longinus with another hardpoint layout" is therefore a `HullData` variant. An embedded hull inside + another document cannot be one. + +*Options.* +- **A:** keep the embedded hull. One record per mod, but a hull family must be full copies, and the derived + catalog keeps a dead second copy. +- **B:** split it. The hull is a plain `HullData` record, and it names its visual through a `Visual` ref, the way + it names a Unity prefab today. `ShipAuthoring` becomes the visual package only. A hull variant inherits its + base's visual through normal resolution. +- **C:** split it the other way. `ShipAuthoring` references its hull. Then a hull variant has no visual unless + something reads the variant's stored base key, and no consumer may read deltas (variants target, "Readers see + complete documents"). + +*Recommend B.* It is the only shape where variants reach ships without either copying or delta-reading. It costs +little now, because no committed content uses the old shape. + +**MQ2. Can a mod ship be a variant of a shipped hull?** (Blocks S5's plan for mods; does not block S1-S4.) + +*Context.* +- Your variants ruling 3 says a variant and its base live in the same store. A mod's `.cc` is its own store, so + "my Longinus with a turret" cannot name the shipped Longinus as its base. +- Separately, Blender reads mod stores through the Python runtime, and variants ruling 5 has Python refuse any + store holding a variant until Python resolution is built (it is parked). +- So even a family within one mod's store is blocked in Blender today. + +*Options.* +- **A:** no. A mod ships complete hulls, and families inside a mod wait for Python resolution. +- **B:** yes, through compose. Compose would resolve a mod-declared base against the shipped catalog, which is a + second resolver outside the cache. +- **C:** yes, by reopening ruling 3 for a mod layer. + +*Recommend A.* B is exactly the bespoke inheritance the variants campaign exists to prevent. C is a CultLib +decision bigger than this lane. + +**MQ3. What is the FBX prefab builder for, now that the typed `.cc` lane exists?** (Blocks S5.) + +*Context.* +- `ShipPrefabAuthoring.cs` (252 lines) and its smoke (80 lines) landed on fire-control-12 at `4b594e11`, so they + reach master in this merge. +- They build Unity prefabs from FBX collections by naming convention (`docs/ship-collection-authoring.md`). +- The typed lane builds the same `ShipInstance` from a `.cc` record and a GLB. +- The ships doc keeps the FBX builder "available to existing content", with retirement "following migration". +- As things stand, the game has two ship-authoring authorities, and no ruling says which one new ships use. + +*The question.* Is the FBX builder a permanent path, for example for artists who will never use Blender with +Brokkr? Or is it scaffolding until the typed lane passes gate 5? + +*Options.* +- **A:** scaffolding. New ships use the typed lane from gate 5 on, and the builder dies at S5. +- **B:** permanent. Both paths stay, and S2's loader keeps the `Prefab` branch forever. +- **C:** retire it now, and author no new ships until gate 5. + +*Recommend A.* Two permanent authoring authorities for one concept is the split this doctrine cuts. C stalls +content for no gain. + +**MQ4. What happens to a saved run when a mod it used is removed?** (Blocks S2's Continue path.) + +*Context.* +- The run store references designs by key, and a mod hull's key is `mod-hull:`. +- Remove the mod, and those references dangle. +- The fire-control checklist already records that an old run store throws on its first lot lookup + (`docs/merge-to-master-checklist.md:15`). + +*Options.* +- **A:** Continue refuses loudly and names the missing mod ids. +- **B:** the missing ships are stripped from the run on load. +- **C:** the run loads with placeholder hulls. + +*Recommend A.* A bad mod is rejected before any scene is built (the doc's own shared-path rule). B silently edits +the player's save. + +**MQ5. Who composes the derived catalog?** (Blocks S2.) + +*Context.* The doc's own objective is "a mod package can be installed without opening the Unity Editor". Gate 4 +says "in a built Unity player without Unity Editor or Blender". A built player ships no `AetherDb`, and today +only `AetherDb ship-authoring compose` composes. + +*Options.* +- **A:** the game composes at boot, from `GameData/Mods`, into a disposable file in the persistent data path. +- **B:** an installer or `AetherDb` step composes, and the game opens `Aetheria.modded.cc` when it exists. + +*Recommend A.* Compose already lives in `ServerShared` (`ShipModCatalog.cs`), so the game can call it. B makes +"is the derived file stale?" a new failure the player owns. + +## B.6 Dependencies (moddable ships) + +- **Fire-control-12 on master:** S0. +- **Unity package resolution:** glTFast 6.20.0 needs network on first open (S0 compile). +- **Document variants:** + - **C2 (element ids):** "ids everywhere" puts an element-id member on `ShipAnchor`, `ShipPolyline`, + `HardpointData`, `ItemRole` and `BehaviorData` (every object-list element these records reach). + Consequences I read from the code, not ran: + - **Hardpoint ids survive a Blender layout save** while the mount id is unchanged. `ship_cc.py:88-98` keeps + each hardpoint's slots from 7 on, keyed by `Transform`, so an id slot at 7 or later rides along. Renaming a + mount makes a new element, which is the ruling's own rule ("a moved element is a new element"). A + hardpoint created in Blender has no id until the next C# write mints one. + - **Line ids churn on every capture.** `replace_lines` replaces `SchematicLines` whole (`ship_cc.py:51`). + This is harmless: a variant would override lines whole anyway (variants Q7). + - **The Python side writes stores C2's registry will then require ids for.** C2 mints them on the first C# + write, and Python carries the id as an ordinary member (variants Q3 rollout). So mod stores need no + migration step. + - **Python refusal:** mod stores stay v1 (no variants) while Blender reads them through `cultcache-py` + (MQ2 = A). + - **C3 (Studio):** it edits hull variants in `Aetheria.cc`. It is a prerequisite for S5, not for S1-S4. + - **C4 (Aetheria adoption, `caching-unity-v1.5.0`):** S5, and the pending test `AHullVariantInheritsItsVisual`. + Slot coordination: S1 claims `HullData` key 32 for `Visual`, and C4's id slots must not collide with it. +- **Brokkr:** the Blender add-on depends on Brokkr's `brokkr_bridge` preferences for the CultLib Python path + (`__init__.py:182-186`). It is an operator-machine dependency with no pin in this repo. From 96321e41319a692ccf40fe5c0c6a95eead07461a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 13:10:16 +0200 Subject: [PATCH 006/113] Record operator rulings on the moddable-ships map (2026-09-30) Co-Authored-By: Claude Opus 5.5 --- docs/moddable-ships-cut.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/moddable-ships-cut.md b/docs/moddable-ships-cut.md index cb5aab860..4a7cdccdb 100644 --- a/docs/moddable-ships-cut.md +++ b/docs/moddable-ships-cut.md @@ -91,6 +91,30 @@ evidence. The merge has no conflicts (below), so it costs one merge commit per b --- +## Rulings (operator, 2026-09-30) + +- **MQ1: a ship `.cc` is self-contained.** Operator: "The idea is for the new moddable ships to eventually + take over from the existing system. A ship .cc should contain everything needed to instantiate and render a + ship." The confirmed reading: the hull is its own `HullData` record and the visual is its own record, both in + the one ship `.cc`, side by side. The hull is not embedded in the visual. A hull family such as Longinus and + LonginusX lives together in one `.cc`, so variants resolve within one store. S1 follows option B, with both + records inside the ship's own store. +- **MQ2 A for now.** There are no cross-file variants of shipped hulls in this lane. **Operator note, to be + carried into CultLib's variants target:** "we'll want cross-store variant support eventually; modders should + be able to add variants of existing items without modifying the original". +- **MQ3 A.** The FBX prefab builder is scaffolding. The typed `.cc` lane replaces the existing system, and the + builder dies at S5. +- **MQ4 A.** Continue refuses loudly and names the missing mod ids. +- **MQ5 A.** The game composes the modded catalog at boot, from `GameData/Mods`, into a disposable file. +- **Soul (2026-09-30) on `375d6bd4`** found the Blender↔C# `.cc` round trip real, with no vendored codec. + Before any cut builds on it, the S-Soul fix slot must fix: + - F1: 8 of 9 validator mutations survive, including the `..` path guard and the prefab refusal. + - F2: `Compose`, `ReadPackage` and `ReadNodeIds` are untested. + - F3: there are no Python tests; slot numbers and enums are duplicated by position. + - F6: `CatalogTypes` includes `ShipAuthoring`. Check name collisions. + - F7: a drive-by `using` removal. + - F5 is settled by MQ1. + # Part B. Moddable ships ## B.1 Status header (verified against git) From 1267efc9d25826e6fe383740f60ebdf89eb532ca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:50:04 +0200 Subject: [PATCH 007/113] Record that pivots own firing arcs (lands with HullData pivots) Co-Authored-By: Claude Opus 5.5 --- docs/moddable-ships-cut.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/moddable-ships-cut.md b/docs/moddable-ships-cut.md index 4a7cdccdb..7ce1d032d 100644 --- a/docs/moddable-ships-cut.md +++ b/docs/moddable-ships-cut.md @@ -115,6 +115,12 @@ evidence. The merge has no conflicts (below), so it costs one merge commit per b - F7: a drive-by `using` removal. - F5 is settled by MQ1. +- **Pivots own firing arcs (operator, 2026-09-30, from fire control).** A turret's arc belongs to its + pivot, not its hardpoints. When this campaign adds pivots to `HullData`, the arc moves from the hardpoint + to the pivot, and the interim "intersection of hardpoint arcs per pivot" rule in fire control is deleted. + Unity's `Pivot......` node-name parsing (`ShipPrefabAuthoring.cs:144`) must not + survive as a second arc authority. + # Part B. Moddable ships ## B.1 Status header (verified against git) From 5df72ba3631dc388852da83a0ff84409c1a2b92f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:46:44 +0200 Subject: [PATCH 008/113] Pin every refusal in ShipAuthoringStore.Validate and Read (S-Soul F1) Soul's mutation pass on 375d6bd4 found 8 of 9 validator mutations surviving, including the `..` model-path guard and the prefab refusal. The validator had five hand-picked cases. ShipValidationTests now carries one case per guard: each starts from the valid fixture, breaks exactly one thing, and names the message that guard alone produces. It also pins the ID alphabet at both ends of each range, the accepted model-asset spellings beside the refused ones, the range edges of point attributes, an anchor naming its hardpoint as parent, and Read's exactly-one-record and validate-on-load behaviour. ShipAuthoringTests.Fixture becomes internal so the new file shares it. Co-Authored-By: Claude Opus 5.5 --- .../ShipAuthoringTests.cs | 2 +- .../ShipValidationTests.cs | 227 ++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 tests/Aetheria.Shared.Tests/ShipValidationTests.cs diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 900d7f983..dd6574669 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -70,7 +70,7 @@ public sealed class ShipAuthoringTests ShipAuthoringStore.Validate(ship)).Message); } - private static ShipAuthoring Fixture() + internal static ShipAuthoring Fixture() { var shape = new Shape(2, 2); shape.Cells[0, 0] = true; diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs new file mode 100644 index 000000000..d8d94b8a6 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CultMath; +using Xunit; + +// One case per refusal in ShipAuthoringStore.Validate and Read. Each case starts from the valid fixture, breaks exactly +// one thing, and names the message that guard alone produces, so a guard that stops firing (or fires for the wrong +// reason) fails its own case and no other. +public sealed class ShipValidationTests +{ + private static readonly Dictionary Break, string Message)> Cases = Build(); + + public static IEnumerable CaseNames => Cases.Keys.Select(name => new object[] { name }); + + [Fact] + public void TheFixtureIsValid() => ShipAuthoringStore.Validate(ShipAuthoringTests.Fixture()); + + [Theory, MemberData(nameof(CaseNames))] + public void ValidatorRefusesEachBreakageByItsOwnGuard(string name) + { + var ship = ShipAuthoringTests.Fixture(); + Cases[name].Break(ship); + var error = Assert.Throws(() => ShipAuthoringStore.Validate(ship)); + Assert.Contains(Cases[name].Message, error.Message); + } + + [Fact] + public void ValidatorRefusesANullRecord() => + Assert.Contains("is null", Assert.Throws(() => ShipAuthoringStore.Validate(null)).Message); + + [Theory] + [InlineData("a"), InlineData("z"), InlineData("0"), InlineData("9"), InlineData("a0z9"), InlineData("0a"), InlineData("9z"), + InlineData("a.b_c-d"), InlineData("a-"), InlineData("z_")] + public void IdsMayUseLowerCaseAsciiDigitsDotsUnderscoresAndHyphens(string id) + { + var ship = ShipAuthoringTests.Fixture(); + ship.Id = id; + ShipAuthoringStore.Validate(ship); + } + + [Theory] + [InlineData("`a"), InlineData("{a"), InlineData("/a"), InlineData(":a"), InlineData("@a"), InlineData("-a"), InlineData(".a"), InlineData("_a"), + InlineData("a`"), InlineData("a{"), InlineData("a/"), InlineData("a:"), InlineData("a@"), InlineData("aA"), InlineData("a b")] + public void IdsOutsideThatAlphabetAreRefused(string id) + { + var ship = ShipAuthoringTests.Fixture(); + ship.Id = id; + Assert.Contains("ship ID must use", Assert.Throws(() => ShipAuthoringStore.Validate(ship)).Message); + } + + [Theory] + [InlineData("skiff.glb"), InlineData("models/skiff.glb"), InlineData("models\\skiff.glb"), InlineData("..skiff.glb"), InlineData("a..b/skiff.glb")] + public void ModelAssetsMayLiveInsideTheirPackage(string asset) + { + var ship = ShipAuthoringTests.Fixture(); + ship.ModelAsset = asset; + ShipAuthoringStore.Validate(ship); + } + + [Theory] + [InlineData("../skiff.glb"), InlineData("..\\skiff.glb"), InlineData("a/../skiff.glb"), InlineData("a\\..\\skiff.glb"), InlineData("a/..")] + public void ModelAssetsCannotEscapeTheirPackage(string asset) + { + var ship = ShipAuthoringTests.Fixture(); + ship.ModelAsset = asset; + Assert.Contains("relative package path", Assert.Throws(() => ShipAuthoringStore.Validate(ship)).Message); + } + + [Fact] + public void RootedModelAssetsAreRefused() + { + var ship = ShipAuthoringTests.Fixture(); + ship.ModelAsset = Path.GetFullPath("skiff.glb"); + Assert.Contains("relative package path", Assert.Throws(() => ShipAuthoringStore.Validate(ship)).Message); + } + + [Fact] + public void OptionalPartsAndRangeEdgesAreAccepted() + { + var ship = ShipAuthoringTests.Fixture(); + var line = ship.SchematicLines[0]; + line.Points = new[] { 0f, 0f, 0f, 1f, 0f, 0f, 2f, 0f, 0f }; + line.Radii = new[] { 0f, 5f, 0f }; + line.Opacities = new[] { 0f, 1f, .5f }; + ShipAuthoringStore.Validate(ship); + line.Radii = null; + line.Opacities = null; + line.Color = null; + ShipAuthoringStore.Validate(ship); + ship.SchematicLines = null; + ship.Hull.Hardpoints = null; + ship.Anchors.RemoveAt(ship.Anchors.Count - 1); + ShipAuthoringStore.Validate(ship); + } + + [Fact] + public void AnAnchorMayNameItsHardpointAsParent() + { + var ship = ShipAuthoringTests.Fixture(); + ship.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" }); + ShipAuthoringStore.Validate(ship); + } + + [Fact] + public async Task ReadRefusesAFileThatHoldsTwoShips() + { + using var directory = new TempDirectory(); + var path = Path.Combine(directory.Path, "two.cc"); + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + cache.Upsert(ShipAuthoringTests.Fixture()); + var other = ShipAuthoringTests.Fixture(); + other.Id = "mod.other"; + cache.Upsert(other); + await cache.FlushAsync(); + } + Assert.Contains("found 2", Assert.Throws(() => ShipAuthoringStore.Read(path)).Message); + } + + [Fact] + public async Task ReadValidatesWhatItLoads() + { + using var directory = new TempDirectory(); + var path = Path.Combine(directory.Path, "bad.cc"); + var ship = ShipAuthoringTests.Fixture(); + ship.Hull.Name = ""; + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + cache.Upsert(ship); + await cache.FlushAsync(); + } + Assert.Contains("hull name is required", Assert.Throws(() => ShipAuthoringStore.Read(path)).Message); + } + + private static Dictionary, string)> Build() + { + var cases = new Dictionary, string)>(); + void Add(string name, Action breakIt, string message) => cases.Add(name, (breakIt, message)); + HardpointData Mount(ShipAuthoring ship) => ship.Hull.Hardpoints[0]; + + Add("id-null", s => s.Id = null, "Ship ID is required"); + Add("id-blank", s => s.Id = " ", "Ship ID is required"); + Add("hull-null", s => s.Hull = null, "hull data is required"); + Add("hull-name-null", s => s.Hull.Name = null, "hull name is required"); + Add("hull-name-blank", s => s.Hull.Name = " ", "hull name is required"); + Add("hull-shape-null", s => s.Hull.Shape = null, "at least one cell"); + Add("hull-cells-null", s => s.Hull.Shape.Cells = null, "at least one cell"); + Add("hull-width-zero", s => s.Hull.Shape.Cells = new bool[0, 2], "at least one cell"); + Add("hull-height-zero", s => s.Hull.Shape.Cells = new bool[2, 0], "at least one cell"); + Add("hull-no-occupied-cell", s => s.Hull.Shape.Cells = new bool[2, 2], "at least one cell"); + Add("hull-names-a-prefab", s => s.Hull.Prefab = "Djinni", "cannot name a Unity prefab"); + Add("model-null", s => s.ModelAsset = null, "relative package path"); + Add("model-blank", s => s.ModelAsset = " ", "relative package path"); + + Add("anchors-null", s => s.Anchors = null, "anchors are required"); + Add("anchor-null", s => s.Anchors.Add(null), "anchor IDs must be present and unique"); + Add("anchor-id-null", s => s.Anchors[4].Id = null, "anchor IDs must be present and unique"); + Add("anchor-id-blank", s => s.Anchors.Add(new ShipAnchor { Id = " ", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique"); + Add("anchor-id-duplicate", s => s.Anchors.Add(new ShipAnchor { Id = "map", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique"); + Add("anchor-node-null", s => s.Anchors[4].ModelNodeId = null, "needs a model node ID"); + Add("anchor-node-blank", s => s.Anchors[4].ModelNodeId = " ", "needs a model node ID"); + Add("anchor-node-shared", s => s.Anchors.Add(new ShipAnchor { Id = "other", Role = "articulation", ModelNodeId = "map" }), "claimed by more than one anchor"); + foreach (var role in new[] { "map-icon", "hull-collider", "shield", "tractor" }) + { + Add("role-missing-" + role, s => s.Anchors.RemoveAll(a => a.Role == role), $"exactly one {role} anchor is required"); + Add("role-doubled-" + role, s => s.Anchors.Add(new ShipAnchor { Id = "extra", Role = role, ModelNodeId = "extra" }), $"exactly one {role} anchor is required"); + } + + Add("hardpoint-null", s => s.Hull.Hardpoints.Add(null), "hardpoint IDs must be present and unique"); + Add("hardpoint-transform-null", s => Mount(s).Transform = null, "hardpoint IDs must be present and unique"); + Add("hardpoint-transform-blank", s => Mount(s).Transform = " ", "hardpoint IDs must be present and unique"); + Add("hardpoint-transform-duplicate", s => s.Hull.Hardpoints.Add(new HardpointData { Position = new int2(0, 0), Transform = "thruster.port" }), "hardpoint IDs must be present and unique"); + Add("hardpoint-shape-null", s => Mount(s).Shape = null, "has no cells"); + Add("hardpoint-cells-null", s => Mount(s).Shape.Cells = null, "has no cells"); + Add("hardpoint-cells-all-empty", s => Mount(s).Shape.Cells = new bool[1, 1], "has no cells"); + Add("hardpoint-left-of-hull", s => Mount(s).Position = new int2(-1, 0), "outside the hull schematic"); + Add("hardpoint-below-hull", s => Mount(s).Position = new int2(1, -1), "outside the hull schematic"); + Add("hardpoint-right-of-hull", s => Mount(s).Position = new int2(2, 0), "outside the hull schematic"); + Add("hardpoint-above-hull", s => Mount(s).Position = new int2(1, 2), "outside the hull schematic"); + Add("hardpoint-on-an-empty-hull-cell", s => Mount(s).Position = new int2(0, 1), "outside the hull schematic"); + Add("hardpoint-footprint-overhangs", s => + { + Mount(s).Shape = new Shape(2, 1); + Mount(s).Shape.Cells[0, 0] = Mount(s).Shape.Cells[1, 0] = true; + }, "outside the hull schematic"); + Add("hardpoints-overlap", s => + { + s.Hull.Hardpoints.Add(new HardpointData { Position = new int2(1, 0), Transform = "thruster.starboard" }); + s.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" }); + }, "overlaps another hardpoint"); + Add("hardpoint-without-anchor", s => s.Anchors.RemoveAll(a => a.Id == "thruster.port"), "has no model anchor"); + Add("anchor-parent-unknown", s => s.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "nowhere" }), "unknown hardpoint parent"); + + Add("line-null", s => s.SchematicLines.Add(null), "finite XYZ points"); + Add("line-points-null", s => s.SchematicLines[0].Points = null, "finite XYZ points"); + Add("line-one-point", s => s.SchematicLines[0].Points = new[] { 0f, 0f, 0f }, "finite XYZ points"); + Add("line-points-not-triples", s => s.SchematicLines[0].Points = new float[7], "finite XYZ points"); + Add("line-point-nan", s => s.SchematicLines[0].Points[3] = float.NaN, "finite XYZ points"); + Add("line-point-infinite", s => s.SchematicLines[0].Points[5] = float.PositiveInfinity, "finite XYZ points"); + Add("line-radii-too-short", s => s.SchematicLines[0].Radii = new[] { .1f }, "attributes have the wrong length"); + Add("line-radii-too-long", s => s.SchematicLines[0].Radii = new[] { .1f, .1f, .1f }, "attributes have the wrong length"); + Add("line-opacities-too-short", s => s.SchematicLines[0].Opacities = new[] { 1f }, "attributes have the wrong length"); + Add("line-opacities-too-long", s => s.SchematicLines[0].Opacities = new[] { 1f, 1f, 1f }, "attributes have the wrong length"); + Add("line-radius-nan", s => s.SchematicLines[0].Radii[1] = float.NaN, "radius and opacity must be finite"); + Add("line-radius-infinite", s => s.SchematicLines[0].Radii[1] = float.PositiveInfinity, "radius and opacity must be finite"); + Add("line-radius-negative", s => s.SchematicLines[0].Radii[0] = -.01f, "radius and opacity must be finite"); + Add("line-opacity-nan", s => s.SchematicLines[0].Opacities[1] = float.NaN, "radius and opacity must be finite"); + Add("line-opacity-infinite", s => s.SchematicLines[0].Opacities[1] = float.PositiveInfinity, "radius and opacity must be finite"); + Add("line-opacity-negative", s => s.SchematicLines[0].Opacities[0] = -.01f, "radius and opacity must be finite"); + Add("line-opacity-above-one", s => s.SchematicLines[0].Opacities[0] = 1.01f, "radius and opacity must be finite"); + Add("line-color-short", s => s.SchematicLines[0].Color = new[] { 1f, 1f, 1f }, "must be finite RGBA"); + Add("line-color-long", s => s.SchematicLines[0].Color = new[] { 1f, 1f, 1f, 1f, 1f }, "must be finite RGBA"); + Add("line-color-nan", s => s.SchematicLines[0].Color[2] = float.NaN, "must be finite RGBA"); + Add("line-color-infinite", s => s.SchematicLines[0].Color[3] = float.NegativeInfinity, "must be finite RGBA"); + return cases; + } +} + +internal sealed class TempDirectory : IDisposable +{ + public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aetheria-ship-" + Guid.NewGuid().ToString("N")); + public TempDirectory() => Directory.CreateDirectory(Path); + public void Dispose() => Directory.Delete(Path, true); +} From 1987009ba19c616c264e6a089c04cb35e2a65ad8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:47:13 +0200 Subject: [PATCH 009/113] Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6) Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now covers the derived catalog, the atomic replace of an existing one, every failure path leaving the previous derived file byte-identical, the unsafe source/output refusals, package binding (directory name, GLB existence and extension, anchor node presence), and the GLB reader (each header guard, the JSON chunk boundary, node index accounting, duplicate ids). Writing them exposed three defects in Compose, fixed here: - F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped catalog, and CultCache indexes names per concrete type with last writer winning while GetByName over a base type throws on a cross-subtype match. Compose checked keys only, so a mod hull named like a shipped item, or like another mod's hull, silently shadowed it or broke lookups. It now refuses a hull name matching any catalog ItemData and a ship ID matching an existing ShipAuthoring, naming the ship. - The backing store writes a .lock file beside every file it opens, so each Compose left ..tmp.lock behind, success or failure, once per boot. The working copy now lives in its own directory that is deleted whole. - Deleted: the duplicate-ID check (ReadPackage already pins ID to directory name, so it could not fire) and ReadPackage's containment StartsWith (Validate already refuses rooted and `..` model paths on the same Read). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ShipModCatalog.cs | 26 +- .../ShipModCatalogTests.cs | 319 ++++++++++++++++++ 2 files changed, 336 insertions(+), 9 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 512f0509e..8bb737d01 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -33,21 +33,22 @@ public static class ShipModCatalog throw new InvalidOperationException("The derived catalog cannot replace a mod source or asset."); var ships = new List(); - var ids = new HashSet(StringComparer.Ordinal); foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal)) { var path = Path.Combine(directory, "ship.cc"); if (!File.Exists(path)) continue; - var package = ReadPackage(path); - if (!ids.Add(package.Ship.Id)) - throw new InvalidOperationException($"{path}: duplicate ship ID {package.Ship.Id}."); - ships.Add(package.Ship); + // ReadPackage pins each ID to its directory name, so IDs within one mods root are already unique. + ships.Add(ReadPackage(path).Ship); } Directory.CreateDirectory(Path.GetDirectoryName(output)); - var temporary = output + "." + Guid.NewGuid().ToString("N") + ".tmp"; + // The store keeps a lock file beside every file it opens, so the working copy gets its own directory and the + // whole directory goes away, whatever the store left in it. + var workspace = output + "." + Guid.NewGuid().ToString("N") + ".tmp"; + var temporary = Path.Combine(workspace, "catalog.cc"); try { + Directory.CreateDirectory(workspace); File.Copy(source, temporary); using (var cache = AetheriaStores.Open(temporary, catalogWritable: true)) { @@ -57,6 +58,13 @@ public static class ShipModCatalog var authoringKey = AuthoringKey(ship.Id); if (cache.AllStoredDocuments.Any(record => record.Key.Equals(hullKey) || record.Key.Equals(authoringKey))) throw new InvalidOperationException($"{ship.Id}: a mod key collides with an existing catalog record."); + // CultCache indexes names per concrete type and lets the last writer win, and GetByName over a base + // type throws on a match across subtypes. A mod hull named like any shipped item, or another mod's + // hull (upserted earlier in this loop), would make name lookups wrong or throw. + if (cache.GetAll().Any(item => string.Equals(item.Name, ship.Hull.Name, StringComparison.Ordinal))) + throw new InvalidOperationException($"{ship.Id}: hull name '{ship.Hull.Name}' collides with an existing catalog item."); + if (cache.GetAll().Any(existing => string.Equals(existing.Id, ship.Id, StringComparison.Ordinal))) + throw new InvalidOperationException($"{ship.Id}: ship ID collides with an existing catalog ship authoring record."); cache.UpsertAsync(typeof(HullData), ship.Hull, hullKey).GetAwaiter().GetResult(); cache.UpsertAsync(typeof(ShipAuthoring), ship, authoringKey).GetAwaiter().GetResult(); } @@ -68,7 +76,7 @@ public static class ShipModCatalog } finally { - if (File.Exists(temporary)) File.Delete(temporary); + if (Directory.Exists(workspace)) Directory.Delete(workspace, true); } } @@ -80,8 +88,8 @@ public static class ShipModCatalog if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal)) throw new InvalidOperationException($"{path}: ship ID must match its package directory name."); var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset)); - if (!modelPath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || - !File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.OrdinalIgnoreCase)) + // Containment is ShipAuthoringStore.Validate's job: it refuses rooted and `..` model paths before this line. + if (!File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"{ship.Id}: model asset must name an existing GLB inside its package."); var modelNodes = ReadNodeIds(modelPath); foreach (var anchor in ship.Anchors) diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs new file mode 100644 index 000000000..3c651f38e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using GameCult.Caching; +using Xunit; + +// Compose, ReadPackage and ReadNodeIds: the derived catalog a game would open, and the GLB node ids its anchors bind to. +public sealed class ShipModCatalogTests : IDisposable +{ + private readonly TempDirectory _directory = new TempDirectory(); + private string Shipped => Path.Combine(_directory.Path, "Aetheria.cc"); + private string Derived => Path.Combine(_directory.Path, "Derived", "Aetheria.modded.cc"); + private string Mods => Path.Combine(_directory.Path, "Mods"); + private static readonly string[] FixtureNodes = { "map", "collider", "shield", "tractor", "thruster-port" }; + + public ShipModCatalogTests() + { + Directory.CreateDirectory(Mods); + using var cache = AetheriaStores.Open(Shipped, catalogWritable: true); + // The test assembly registers TestCatalogGlobal (AetheriaStoresTests), so any populated catalog must hold one. + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(new HullData { Name = "Wasp", Shape = ShipAuthoringTests.Fixture().Hull.Shape }); + cache.Upsert(new GearData { Name = "Widget" }); + cache.FlushAsync().Wait(); + } + + public void Dispose() => _directory.Dispose(); + + [Fact] + public void ComposeDerivesACatalogAndLeavesTheShippedOneUntouched() + { + WritePackage("mod.skiff"); + Directory.CreateDirectory(Path.Combine(Mods, "notes")); + var shippedBefore = Hash(Shipped); + + Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods)); + + Assert.Equal(shippedBefore, Hash(Shipped)); + using var cache = AetheriaStores.Open(Derived); + Assert.Contains("Wasp", cache.GetAll().Select(hull => hull.Name)); + Assert.Contains("Widget", cache.GetAll().Select(gear => gear.Name)); + var records = cache.AllStoredDocuments.ToDictionary(record => record.Key.Value, record => record.Document); + Assert.Equal("Skiff", Assert.IsType(records[ShipModCatalog.HullKey("mod.skiff").Value]).Name); + Assert.Equal("mod.skiff", Assert.IsType(records[ShipModCatalog.AuthoringKey("mod.skiff").Value]).Id); + AssertOnlyTheDerivedFile(); + } + + [Fact] + public void ComposeCountsEveryPackageAndReplacesAnExistingDerivedCatalog() + { + WritePackage("mod.skiff"); + Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods)); + WritePackage("mod.barge", hullName: "Barge"); + + Assert.Equal(2, ShipModCatalog.Compose(Shipped, Derived, Mods)); + + using var cache = AetheriaStores.Open(Derived); + Assert.Equal(new[] { "Barge", "Skiff", "Wasp" }, cache.GetAll().Select(hull => hull.Name).OrderBy(name => name)); + AssertOnlyTheDerivedFile(); + } + + [Fact] + public void AFailedComposeLeavesThePreviousDerivedFileAndNoTemporaryFile() + { + WritePackage("mod.skiff"); + ShipModCatalog.Compose(Shipped, Derived, Mods); + var before = File.ReadAllBytes(Derived); + + WritePackage("mod.barge", hullName: "Wasp"); + Assert.Contains("collides with an existing catalog item", Assert.Throws(() => + ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); + Assert.Equal(before, File.ReadAllBytes(Derived)); + AssertOnlyTheDerivedFile(); + + Directory.Delete(Path.Combine(Mods, "mod.barge"), true); + WritePackage("mod.barge", hullName: "Barge", nodes: FixtureNodes.Where(node => node != "shield").ToArray()); + Assert.Contains("model has no node with aetheria.id=shield", Assert.Throws(() => + ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); + Assert.Equal(before, File.ReadAllBytes(Derived)); + } + + [Theory] + [InlineData("mod-hull:mod.skiff", false)] + [InlineData("mod-ship:mod.skiff", true)] + public void ComposeRefusesAModKeyThatAlreadyExistsInTheCatalog(string key, bool authoring) + { + WritePackage("mod.skiff"); + using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) + { + if (authoring) + { + var stranger = ShipAuthoringTests.Fixture(); + stranger.Id = "stranger"; + cache.UpsertAsync(typeof(ShipAuthoring), stranger, new CultRecordKey(key)).GetAwaiter().GetResult(); + } + else + cache.UpsertAsync(typeof(HullData), new HullData { Name = "Stranger", Shape = ShipAuthoringTests.Fixture().Hull.Shape }, + new CultRecordKey(key)).GetAwaiter().GetResult(); + cache.FlushAsync().Wait(); + } + + Assert.Contains("a mod key collides", Assert.Throws(() => + ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); + Assert.False(File.Exists(Derived)); + } + + [Theory] + [InlineData(typeof(HullData))] + [InlineData(typeof(GearData))] + public void ComposeRefusesAHullNamedLikeAShippedItem(Type shippedType) + { + using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) + { + if (shippedType == typeof(HullData)) + cache.Upsert(new HullData { Name = "Skiff", Shape = ShipAuthoringTests.Fixture().Hull.Shape }); + else + cache.Upsert(new GearData { Name = "Skiff" }); + cache.FlushAsync().Wait(); + } + WritePackage("mod.skiff"); + + Assert.Contains("hull name 'Skiff' collides with an existing catalog item", Assert.Throws(() => + ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); + Assert.False(File.Exists(Derived)); + } + + [Fact] + public void ComposeRefusesTwoModsThatShareAHullName() + { + WritePackage("mod.a", hullName: "Skiff"); + WritePackage("mod.b", hullName: "Skiff"); + + Assert.Contains("mod.b: hull name 'Skiff' collides", Assert.Throws(() => + ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); + Assert.False(File.Exists(Derived)); + } + + [Fact] + public void ComposeRefusesAShipIdThatAShippedAuthoringRecordAlreadyUses() + { + using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) + { + cache.UpsertAsync(typeof(ShipAuthoring), ShipAuthoringTests.Fixture(), new CultRecordKey("shipped:skiff")).GetAwaiter().GetResult(); + cache.FlushAsync().Wait(); + } + WritePackage("mod.skiff", hullName: "Different"); + + Assert.Contains("ship ID collides with an existing catalog ship authoring record", Assert.Throws(() => + ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); + Assert.False(File.Exists(Derived)); + } + + [Fact] + public void ComposeRefusesUnsafeInputsAndOutputs() + { + WritePackage("mod.skiff"); + Assert.Contains("Shipped catalog is missing", Assert.Throws(() => + ShipModCatalog.Compose(Path.Combine(_directory.Path, "absent.cc"), Derived, Mods)).Message); + Assert.Contains("Mod root is missing", Assert.Throws(() => + ShipModCatalog.Compose(Shipped, Derived, Path.Combine(_directory.Path, "NoMods"))).Message); + var before = Hash(Shipped); + Assert.Contains("cannot replace the shipped catalog", Assert.Throws(() => + ShipModCatalog.Compose(Shipped, Shipped, Mods)).Message); + Assert.Contains("cannot replace the shipped catalog", Assert.Throws(() => + ShipModCatalog.Compose(Shipped, Shipped.ToUpperInvariant().Replace(_directory.Path.ToUpperInvariant(), _directory.Path), Mods)).Message); + Assert.Contains("cannot replace a mod source or asset", Assert.Throws(() => + ShipModCatalog.Compose(Shipped, Path.Combine(Mods, "mod.skiff", "out.cc"), Mods)).Message); + Assert.Equal(before, Hash(Shipped)); + Assert.False(File.Exists(Path.Combine(Mods, "mod.skiff", "out.cc"))); + } + + [Fact] + public void ReadPackageBindsTheShipToItsDirectoryAndGlb() + { + WritePackage("mod.skiff"); + var package = ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc")); + Assert.Equal("mod.skiff", package.Ship.Id); + Assert.Equal(Path.GetFullPath(Path.Combine(Mods, "mod.skiff", "skiff.glb")), package.ModelPath); + Assert.Equal(new Dictionary { ["map"] = 0, ["collider"] = 1, ["shield"] = 2, ["tractor"] = 3, ["thruster-port"] = 4 }, package.NodeIndices); + } + + [Fact] + public void ReadPackageRefusesADirectoryNameThatIsNotTheShipId() + { + WritePackage("mod.skiff"); + Directory.Move(Path.Combine(Mods, "mod.skiff"), Path.Combine(Mods, "renamed")); + Assert.Contains("ship ID must match its package directory name", Assert.Throws(() => + ShipModCatalog.ReadPackage(Path.Combine(Mods, "renamed", "ship.cc"))).Message); + } + + [Fact] + public void ReadPackageRefusesAMissingOrWronglyNamedModel() + { + WritePackage("mod.skiff"); + File.Delete(Path.Combine(Mods, "mod.skiff", "skiff.glb")); + Assert.Contains("must name an existing GLB inside its package", Assert.Throws(() => + ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); + + Directory.Delete(Path.Combine(Mods, "mod.skiff"), true); + WritePackage("mod.skiff", modelAsset: "skiff.bin"); + Assert.Contains("must name an existing GLB inside its package", Assert.Throws(() => + ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); + } + + [Fact] + public void ReadPackageRefusesAnAnchorWhoseNodeIsNotInTheModel() + { + WritePackage("mod.skiff", nodes: FixtureNodes.Where(node => node != "thruster-port").ToArray()); + Assert.Contains("model has no node with aetheria.id=thruster-port for anchor thruster.port", Assert.Throws(() => + ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); + } + + [Fact] + public void NodeIdsMapToTheirGlbNodeIndexSkippingNodesWithoutOne() + { + var path = Write("nodes.glb", Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[ + {""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""extras"":{""aetheria.id"":""b""}}]}")); + Assert.Equal(new Dictionary { ["a"] = 1, ["b"] = 4 }, ShipModCatalog.ReadNodeIds(path)); + } + + [Fact] + public void AGlbWithoutNodesHasNoIds() => + Assert.Empty(ShipModCatalog.ReadNodeIds(Write("empty.glb", Glb(@"{""asset"":{""version"":""2.0""}}")))); + + [Fact] + public void ADuplicateNodeIdIsRefused() => + Assert.Contains("duplicate GLB node aetheria.id=a", Assert.Throws(() => + ShipModCatalog.ReadNodeIds(Write("dup.glb", Glb(@"{""nodes"":[{""extras"":{""aetheria.id"":""a""}},{""extras"":{""aetheria.id"":""a""}}]}")))).Message); + + [Fact] + public void AMalformedGlbHeaderIsRefused() + { + var good = Glb(@"{""nodes"":[]}"); + void Refuse(string name, byte[] bytes, string message) => + Assert.Contains(message, Assert.Throws(() => ShipModCatalog.ReadNodeIds(Write(name, bytes))).Message); + + Refuse("short.glb", With(good.Take(19).ToArray(), 8, 19), "invalid GLB 2 header"); + Refuse("magic.glb", With(good, 0, 0x46546C68), "invalid GLB 2 header"); + Refuse("version.glb", With(good, 4, 1), "invalid GLB 2 header"); + Refuse("length.glb", With(good, 8, (uint)good.Length + 1), "invalid GLB 2 header"); + Refuse("chunktype.glb", With(good, 16, 0x004E4942), "missing GLB JSON chunk"); + Refuse("chunklength.glb", With(good, 12, (uint)good.Length), "missing GLB JSON chunk"); + } + + [Fact] + public void AGlbWhoseJsonChunkFillsTheFileIsAccepted() + { + var json = Encoding.UTF8.GetBytes(@"{""nodes"":[{""extras"":{""aetheria.id"":""a""}}]} "); + var bytes = new byte[20 + json.Length]; + BitConverter.GetBytes(0x46546C67u).CopyTo(bytes, 0); + BitConverter.GetBytes(2u).CopyTo(bytes, 4); + BitConverter.GetBytes((uint)bytes.Length).CopyTo(bytes, 8); + BitConverter.GetBytes((uint)json.Length).CopyTo(bytes, 12); + BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16); + json.CopyTo(bytes, 20); + Assert.Equal(new Dictionary { ["a"] = 0 }, ShipModCatalog.ReadNodeIds(Write("full.glb", bytes))); + } + + private void AssertOnlyTheDerivedFile() + { + var names = Directory.GetFiles(Path.GetDirectoryName(Derived)).Select(file => Path.GetFileName(file)).ToArray(); + Assert.True(names.Length == 1 && names[0] == "Aetheria.modded.cc", string.Join(" | ", names)); + } + + private static byte[] With(byte[] bytes, int offset, uint value) + { + var copy = (byte[])bytes.Clone(); + BitConverter.GetBytes(value).CopyTo(copy, offset); + return copy; + } + + private static byte[] Glb(string json) + { + var body = Encoding.UTF8.GetBytes(json); + var padded = new byte[(body.Length + 3) / 4 * 4]; + Array.Fill(padded, (byte)' '); + body.CopyTo(padded, 0); + var bytes = new byte[20 + padded.Length + 8]; + BitConverter.GetBytes(0x46546C67u).CopyTo(bytes, 0); + BitConverter.GetBytes(2u).CopyTo(bytes, 4); + BitConverter.GetBytes((uint)bytes.Length).CopyTo(bytes, 8); + BitConverter.GetBytes((uint)padded.Length).CopyTo(bytes, 12); + BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16); + padded.CopyTo(bytes, 20); + BitConverter.GetBytes(0u).CopyTo(bytes, 20 + padded.Length); + BitConverter.GetBytes(0x004E4942u).CopyTo(bytes, 24 + padded.Length); + return bytes; + } + + private string Write(string name, byte[] bytes) + { + var path = Path.Combine(_directory.Path, name); + File.WriteAllBytes(path, bytes); + return path; + } + + private void WritePackage(string id, string hullName = "Skiff", string[] nodes = null, string modelAsset = "skiff.glb") + { + var ship = ShipAuthoringTests.Fixture(); + ship.Id = id; + ship.Hull.Name = hullName; + ship.ModelAsset = modelAsset; + var directory = Path.Combine(Mods, id); + Directory.CreateDirectory(directory); + using (var cache = ShipAuthoringStore.Open(Path.Combine(directory, "ship.cc"), writable: true)) + { + cache.Upsert(ship); + cache.FlushAsync().Wait(); + } + var json = @"{""asset"":{""version"":""2.0""},""nodes"":[" + + string.Join(",", (nodes ?? FixtureNodes).Select(node => $@"{{""extras"":{{""aetheria.id"":""{node}""}}}}")) + "]}"; + File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(json)); + } + + private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); +} From 1f8c0ccd6da64d4451c9919c14f90b72f6a6fd33 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:55:46 +0200 Subject: [PATCH 010/113] Give the Blender side tests and pin its hand-copied schema to C# (S-Soul F3) ship_cc.py edited a ShipAuthoring record by bare slot numbers and hardpoint positions (hp[3], hp[7:], raw[0..6]), and __init__.py copied the HardpointType and ItemRotation enums by position. Nothing tested either file and nothing tied the numbers to the C# [Key] attributes that own them. - ship_cc.py names its schema facts once: the four record slots (each commented with the C# member it mirrors), HARDPOINT_MEMBERS (HardpointData member names in slot order), and the two enum name tuples. Hardpoint rows are built and read through encode_hardpoint/decode_hardpoint; replace_layout and the Blender operators no longer index a row by literal. __init__.py builds its enum items from the tuples. The rotation label "Counterclockwise" becomes "CounterClockwise", matching the C# member. - tools/blender/tests/test_ship_cc.py (30 unittest cases, bpy-free, needs CultLib's cultcache-py and msgpack): read, replace_lines, read_layout, replace_layout (untouched slots and later-schema hardpoint slots preserved by mount id, schema catalog preserved, every refusal leaves the file byte-identical), the row codec, and capture_grease_pencil against stand-in objects. - ShipSchemaPinTests (C#, 7 cases) reads ship_cc.py and fails when a slot constant, a HARDPOINT_MEMBERS name or position, or an enum name/value disagrees with the C# types, and when a serialized ship no longer has the nested array shapes the Python indexes (schematic [[w, h, cells]], cells x-major, hardpoint transform and footprint positions). Reading the slots from the file's schema catalog instead was rejected: the catalog lists only document members, and Hull, Shape and HardpointData are nested types it does not describe. Co-Authored-By: Claude Opus 5.5 --- .../ShipSchemaPinTests.cs | 105 +++++ tools/blender/aetheria_ships/__init__.py | 31 +- tools/blender/aetheria_ships/ship_cc.py | 46 ++- tools/blender/tests/test_ship_cc.py | 370 ++++++++++++++++++ 4 files changed, 525 insertions(+), 27 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs create mode 100644 tools/blender/tests/test_ship_cc.py diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs new file mode 100644 index 000000000..b892c1908 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; + +// tools/blender/aetheria_ships/ship_cc.py edits a ShipAuthoring record by MessagePack slot number and by hardpoint +// member position. These tests bind that file to the C# types that own the numbers: any renumbered, renamed or +// reordered member, enum value, or nested array shape fails here instead of corrupting a ship the next time Blender saves. +public sealed class ShipSchemaPinTests +{ + private static readonly string Source = File.ReadAllText(Locate("tools", "blender", "aetheria_ships", "ship_cc.py")); + + [Theory] + [InlineData("SCHEMATIC_LINES_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.SchematicLines))] + [InlineData("HULL_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.Hull))] + [InlineData("HULL_SHAPE_SLOT", typeof(HullData), nameof(HullData.Shape))] + [InlineData("HULL_HARDPOINTS_SLOT", typeof(HullData), nameof(HullData.Hardpoints))] + public void PythonSlotConstantsAreTheKeysOfTheMembersTheyNameInTheirComments(string constant, Type owner, string member) + { + Assert.Equal(KeyOf(owner, member), Integer(constant)); + Assert.Matches(new Regex($@"^{constant} = \d+ # \w+\.{member}\r?$", RegexOptions.Multiline), Source); + } + + [Fact] + public void PythonHardpointMembersAreHardpointDataMembersAtTheirOwnPositions() + { + var members = Strings("HARDPOINT_MEMBERS"); + Assert.Equal(7, members.Length); + for (var slot = 0; slot < members.Length; slot++) + Assert.Equal(slot, KeyOf(typeof(HardpointData), members[slot])); + } + + [Fact] + public void PythonEnumNamesAreTheEnumsInValueOrder() + { + Assert.Equal(EnumNames(), Strings("HARDPOINT_TYPE_NAMES")); + Assert.Equal(EnumNames(), Strings("ROTATION_NAMES")); + } + + [Fact] + public void SerializedShipHasTheNestedArrayShapesPythonIndexes() + { + var ship = ShipAuthoringTests.Fixture(); + var bytes = MessagePackSerializer.Serialize(ship, CultDocumentMessagePackSerialization.OptionsFor(typeof(ShipAuthoring).Assembly)); + var body = Assert.IsType(MessagePackSerializer.Deserialize(bytes, MessagePackSerializerOptions.Standard)); + + Assert.Equal("mod.skiff", body[0]); + var hull = Assert.IsType(body[Integer("HULL_SLOT")]); + // A schematic is [[width, height, cells]] with cells flattened x-major (index x * height + y). + var hullShape = Assert.IsType(Assert.IsType(hull[Integer("HULL_SHAPE_SLOT")])[0]); + Assert.Equal(new object[] { 2, 2 }, hullShape.Take(2).Select(value => (object)Convert.ToInt32(value))); + Assert.Equal(new[] { true, false, true, false }, Assert.IsType(hullShape[2]).Cast()); + + var hardpoints = Assert.IsType(hull[Integer("HULL_HARDPOINTS_SLOT")]); + var row = Assert.IsType(Assert.Single(hardpoints)); + var members = Strings("HARDPOINT_MEMBERS"); + Assert.True(row.Length >= members.Length); + Assert.Equal("thruster.port", row[Array.IndexOf(members, "Transform")]); + var footprint = Assert.IsType(Assert.IsType(row[Array.IndexOf(members, "Shape")])[0]); + Assert.Equal(3, footprint.Length); + Assert.Equal(new[] { true }, Assert.IsType(footprint[2]).Cast()); + Assert.Equal(new[] { 1, 0 }, Assert.IsType(row[Array.IndexOf(members, "Position")]).Select(value => Convert.ToInt32(value))); + + var lines = Assert.IsType(body[Integer("SCHEMATIC_LINES_SLOT")]); + Assert.Equal("Hull", Assert.IsType(Assert.Single(lines))[0]); + } + + private static int KeyOf(Type owner, string member) + { + var found = owner.GetMember(member, BindingFlags.Public | BindingFlags.Instance).Single(); + return Assert.IsType(found.GetCustomAttribute().IntKey); + } + + private static int Integer(string constant) => + int.Parse(Regex.Match(Source, $@"^{constant} = (\d+)", RegexOptions.Multiline).Groups[1].Value); + + private static string[] Strings(string constant) + { + var block = Regex.Match(Source, $@"^{constant} = \((.*?)\)\r?$", RegexOptions.Multiline | RegexOptions.Singleline); + Assert.True(block.Success, $"{constant} is not a tuple in ship_cc.py"); + return Regex.Matches(block.Groups[1].Value, "\"([^\"]*)\"").Select(match => match.Groups[1].Value).ToArray(); + } + + private static string[] EnumNames() where T : struct, Enum + { + var values = Enum.GetValues().OrderBy(value => Convert.ToInt32(value)).ToArray(); + Assert.Equal(Enumerable.Range(0, values.Length), values.Select(value => Convert.ToInt32(value))); + return values.Select(value => value.ToString()).ToArray(); + } + + private static string Locate(params string[] relative) + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory != null; directory = directory.Parent) + { + var candidate = Path.Combine(new[] { directory.FullName }.Concat(relative).ToArray()); + if (File.Exists(candidate)) return candidate; + } + throw new FileNotFoundException("Repository file not found above the test binary: " + Path.Combine(relative)); + } +} diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index b12a7b38e..e84c4332d 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -12,15 +12,12 @@ bl_info = { import bpy -from .ship_cc import capture_grease_pencil, read, read_layout, replace_layout, replace_lines +from .ship_cc import (HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, + encode_hardpoint, read, read_layout, replace_layout, replace_lines) -HARDPOINT_TYPES = tuple((str(i), name, name) for i, name in enumerate(( - "Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Reactor", "Radiator", - "Shield", "Sensors", "Energy", "Ballistic", "Launcher", "ControlModule", "AetherDrive", -))) -ROTATIONS = (("0", "None", "None"), ("1", "Counterclockwise", "Counterclockwise"), - ("2", "Reversed", "Reversed"), ("3", "Clockwise", "Clockwise")) +HARDPOINT_TYPES = tuple((str(i), name, name) for i, name in enumerate(HARDPOINT_TYPE_NAMES)) +ROTATIONS = tuple((str(i), name, name) for i, name in enumerate(ROTATION_NAMES)) class AETHERIA_PG_cell(bpy.types.PropertyGroup): @@ -97,14 +94,15 @@ class AETHERIA_OT_load_layout(bpy.types.Operator): state.cells.add().occupied = bool(cell) state.hardpoints.clear() for raw in hardpoints: + fields = decode_hardpoint(raw) hp = state.hardpoints.add() - hp.kind = str(raw[0]) - hp.x, hp.y = raw[1] - hp.footprint = _footprint(raw[2]) - hp.mount_id = raw[3] or "" - hp.rotation = str(raw[4]) - hp.armor = raw[5] - hp.firing_arc = raw[6] + hp.kind = str(fields["Type"]) + hp.x, hp.y = fields["Position"] + hp.footprint = _footprint(fields["Shape"]) + hp.mount_id = fields["Transform"] or "" + hp.rotation = str(fields["Rotation"]) + hp.armor = fields["Armor"] + hp.firing_arc = fields["FiringArc"] self.report({"INFO"}, f"Loaded {width}x{height} layout and {len(hardpoints)} hardpoints") return {"FINISHED"} except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: @@ -165,8 +163,9 @@ class AETHERIA_OT_save_layout(bpy.types.Operator): if state.ship_id != collection["aetheria.id"] or state.ship_cc != path: raise ValueError("Load this collection's layout before saving") cells = [cell.occupied for cell in state.cells] - hardpoints = [[int(hp.kind), [hp.x, hp.y], _parse_footprint(hp.footprint), - hp.mount_id, int(hp.rotation), hp.armor, hp.firing_arc] + hardpoints = [encode_hardpoint( + Type=int(hp.kind), Position=[hp.x, hp.y], Shape=_parse_footprint(hp.footprint), + Transform=hp.mount_id, Rotation=int(hp.rotation), Armor=hp.armor, FiringArc=hp.firing_arc) for hp in state.hardpoints] state.revision = replace_layout( path, _brokkr_cultlib(context), state.ship_id, state.revision, diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 727db49e0..54064b598 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -15,10 +15,31 @@ from pathlib import Path from typing import Any SCHEMA = "aetheria.ship_authoring" -SCHEMATIC_LINES_SLOT = 4 -HULL_SLOT = 1 -HULL_SHAPE_SLOT = 5 -HULL_HARDPOINTS_SLOT = 23 + +# MessagePack slots of the C# types, which own them (ShipAuthoring, ItemData.Shape, HullData.Hardpoints, +# HardpointData). ShipSchemaPinTests in tests/Aetheria.Shared.Tests fails when any number or name below +# disagrees with those types' [Key] attributes, so a renumbered or added member cannot drift silently. +SCHEMATIC_LINES_SLOT = 4 # ShipAuthoring.SchematicLines +HULL_SLOT = 1 # ShipAuthoring.Hull +HULL_SHAPE_SLOT = 5 # ItemData.Shape +HULL_HARDPOINTS_SLOT = 23 # HullData.Hardpoints +# HardpointData member names in slot order; a hardpoint row is one value per name, and any slot past the last +# name belongs to a later schema and is carried through untouched. +HARDPOINT_MEMBERS = ("Type", "Position", "Shape", "Transform", "Rotation", "Armor", "FiringArc") +# Enum member names in value order (HardpointType, ItemRotation). +HARDPOINT_TYPE_NAMES = ("Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Reactor", "Radiator", + "Shield", "Sensors", "Energy", "Ballistic", "Launcher", "ControlModule", "AetherDrive") +ROTATION_NAMES = ("None", "CounterClockwise", "Reversed", "Clockwise") + + +def decode_hardpoint(row: list[Any]) -> dict[str, Any]: + return dict(zip(HARDPOINT_MEMBERS, row)) + + +def encode_hardpoint(**fields: Any) -> list[Any]: + if set(fields) != set(HARDPOINT_MEMBERS): + raise ValueError(f"A hardpoint row needs exactly {', '.join(HARDPOINT_MEMBERS)}") + return [fields[name] for name in HARDPOINT_MEMBERS] def _libraries(cultlib_packages: str): @@ -85,17 +106,20 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_ raise ValueError("Hull grid must be 1..32 cells wide and high with one value per cell") if not any(cells): raise ValueError("Hull grid needs an occupied cell") - previous = {hp[3]: hp[7:] for hp in body[HULL_SLOT][HULL_HARDPOINTS_SLOT] - if isinstance(hp, list) and len(hp) > 7 and isinstance(hp[3], str)} + known = len(HARDPOINT_MEMBERS) + previous = {decode_hardpoint(hp)["Transform"]: hp[known:] for hp in hull[HULL_HARDPOINTS_SLOT] + if isinstance(hp, list) and len(hp) > known and isinstance(decode_hardpoint(hp)["Transform"], str)} updated_hardpoints = [] for hardpoint in hardpoints: - if len(hardpoint) != 7 or not isinstance(hardpoint[3], str) or not hardpoint[3]: - raise ValueError("Each hardpoint needs seven typed fields and a stable mount ID") - hp_width, hp_height, hp_cells = hardpoint[2][0] + fields = decode_hardpoint(hardpoint) + mount = fields.get("Transform") + if len(hardpoint) != known or not isinstance(mount, str) or not mount: + raise ValueError(f"Each hardpoint needs {known} typed fields and a stable mount ID") + hp_width, hp_height, hp_cells = fields["Shape"][0] if not (1 <= hp_width <= 32 and 1 <= hp_height <= 32 and len(hp_cells) == hp_width * hp_height and any(hp_cells)): - raise ValueError(f"Hardpoint {hardpoint[3]} has an invalid footprint") - updated_hardpoints.append(hardpoint + previous.get(hardpoint[3], [])) + raise ValueError(f"Hardpoint {mount} has an invalid footprint") + updated_hardpoints.append(hardpoint + previous.get(mount, [])) hull[HULL_SHAPE_SLOT] = [shape] hull[HULL_HARDPOINTS_SLOT] = updated_hardpoints store.push(replace( diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py new file mode 100644 index 000000000..ab9eb6299 --- /dev/null +++ b/tools/blender/tests/test_ship_cc.py @@ -0,0 +1,370 @@ +"""Blender-free tests for aetheria_ships/ship_cc.py, the Python view of a typed ShipAuthoring record. + +Run with CultLib's cultcache-py and msgpack importable: + + CULTLIB_PACKAGES=/packages python -m unittest discover -s tools/blender/tests + +ship_cc is loaded by path because the package __init__ imports bpy. The record layout the tests build comes from +ship_cc's own slot constants; ShipSchemaPinTests (C#) pins those constants to the C# [Key] attributes. +""" + +import importlib.util +import os +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +PACKAGES = os.environ.get("CULTLIB_PACKAGES") +if not PACKAGES: + raise RuntimeError("Set CULTLIB_PACKAGES to CultLib's packages directory") + +_spec = importlib.util.spec_from_file_location( + "ship_cc", Path(__file__).resolve().parents[1] / "aetheria_ships" / "ship_cc.py") +ship_cc = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(ship_cc) + +sys.path.insert(0, str(Path(PACKAGES) / "cultcache-py" / "src")) +import cultcache_py # noqa: E402 +import msgpack # noqa: E402 + + +def shape(width, height, cells): + return [width, height, cells] + + +def hardpoint_row(mount="thruster.port", tail=("future-hardpoint-slot",)): + row = ship_cc.encode_hardpoint( + Type=3, Position=[1, 0], Shape=[shape(1, 1, [True])], Transform=mount, Rotation=0, Armor=0.0, FiringArc=0.0) + return row + list(tail) + + +def hull_slots(): + hull = [None] * (ship_cc.HULL_HARDPOINTS_SLOT + 1) + hull[1] = "Skiff" + hull[ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, False, True, False])] + hull[ship_cc.HULL_HARDPOINTS_SLOT] = [hardpoint_row()] + hull.append("future-hull-slot") + return hull + + +LINES = [["Hull", "White", [0.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0.01, 0.01], [1.0, 1.0], False, [1.0, 1.0, 1.0, 1.0]]] + + +def ship_body(): + body = [None] * (ship_cc.SCHEMATIC_LINES_SLOT + 1) + body[0] = "mod.skiff" + body[ship_cc.HULL_SLOT] = hull_slots() + body[2] = "skiff.glb" + body[3] = [["map", "map-icon", "map", None, 0]] + body[ship_cc.SCHEMATIC_LINES_SLOT] = LINES + return body + + +CATALOG = cultcache_py.CultCacheSchemaCatalogEntry( + schema_id="ship-authoring-schema-1", schema_name=ship_cc.SCHEMA, schema_version="1", + content_hash="hash", canonical_schema_json='{"members":[{"slot":9,"name":"FutureMember"}]}', + compatible_schema_ids=("ship-authoring-schema-1",), + members=(cultcache_py.CultCacheSchemaCatalogMember(slot=9, member_name="FutureMember", type_name="string"),)) + + +class ShipFileCase(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.path = str(Path(directory.name) / "ship.cc") + self.write(ship_body()) + + def write(self, body, path=None): + cultcache_py.SingleFileMessagePackBackingStore(path or self.path).push(cultcache_py.CultCacheEnvelope.create( + key="mod.skiff", type=ship_cc.SCHEMA, payload=msgpack.packb(body, use_bin_type=True), + schema_id=CATALOG.schema_id, catalog_entry=CATALOG)) + + def body(self): + return ship_cc.read(self.path, PACKAGES)[2] + + def bytes(self): + return Path(self.path).read_bytes() + + +class ReadTests(ShipFileCase): + def test_reads_the_one_ship_record(self): + store, envelope, body, _ = ship_cc.read(self.path, PACKAGES) + self.assertEqual("mod.skiff", body[0]) + self.assertEqual(ship_cc.SCHEMA, envelope.type) + + def test_refuses_a_file_with_another_record_beside_the_ship(self): + cultcache_py.SingleFileMessagePackBackingStore(self.path).push(cultcache_py.CultCacheEnvelope.create( + key="other", type="aetheria.something", payload=msgpack.packb([1], use_bin_type=True))) + with self.assertRaisesRegex(ValueError, "must hold exactly one"): + ship_cc.read(self.path, PACKAGES) + + def test_refuses_a_file_with_no_record(self): + empty = str(Path(self.path).with_name("empty.cc")) + Path(empty).write_bytes(b"") + with self.assertRaisesRegex(ValueError, "must hold exactly one"): + ship_cc.read(empty, PACKAGES) + + def test_refuses_a_payload_too_short_for_the_line_slot(self): + self.write(["mod.skiff", None]) + with self.assertRaisesRegex(ValueError, "incompatible"): + ship_cc.read(self.path, PACKAGES) + + def test_refuses_a_payload_that_is_not_an_array(self): + self.write({"Id": "mod.skiff"}) + with self.assertRaisesRegex(ValueError, "incompatible"): + ship_cc.read(self.path, PACKAGES) + + +class ReplaceLinesTests(ShipFileCase): + def test_replaces_only_the_line_slot(self): + new_lines = [["Detail", "Red", [0.0] * 6, [0.1, 0.1], [1.0, 1.0], True, [1.0, 0.0, 0.0, 1.0]]] + before = self.body() + self.assertEqual(1, ship_cc.replace_lines(self.path, PACKAGES, new_lines)) + after = self.body() + self.assertEqual(new_lines, after[ship_cc.SCHEMATIC_LINES_SLOT]) + for slot in range(len(before)): + if slot != ship_cc.SCHEMATIC_LINES_SLOT: + self.assertEqual(before[slot], after[slot], f"slot {slot} changed") + + def test_keeps_the_schema_catalog(self): + ship_cc.replace_lines(self.path, PACKAGES, LINES) + envelope = cultcache_py.SingleFileMessagePackBackingStore(self.path).pull_all()[0] + self.assertEqual(CATALOG.members, envelope.catalog_entry.members) + self.assertEqual(CATALOG.canonical_schema_json, envelope.catalog_entry.canonical_schema_json) + + def test_refuses_an_empty_capture_and_changes_nothing(self): + before = self.bytes() + with self.assertRaisesRegex(ValueError, "No Grease Pencil strokes"): + ship_cc.replace_lines(self.path, PACKAGES, []) + self.assertEqual(before, self.bytes()) + + +class LayoutTests(ShipFileCase): + def layout(self): + return ship_cc.read_layout(self.path, PACKAGES) + + def edit(self, revision, width=2, height=2, cells=None, hardpoints=None, ship_id="mod.skiff"): + return ship_cc.replace_layout( + self.path, PACKAGES, ship_id, revision, + [width, height, cells if cells is not None else [True, True, True, False]], + hardpoints if hardpoints is not None else [hardpoint_row(tail=())]) + + def test_reads_the_shape_hardpoints_and_a_revision(self): + shape_triple, hardpoints, revision = self.layout() + self.assertEqual([2, 2, [True, False, True, False]], shape_triple) + self.assertEqual("thruster.port", ship_cc.decode_hardpoint(hardpoints[0])["Transform"]) + self.assertEqual(64, len(revision)) + + def test_the_revision_follows_the_layout_and_only_the_layout(self): + _, _, revision = self.layout() + body = self.body() + body[ship_cc.SCHEMATIC_LINES_SLOT] = [] + body[ship_cc.HULL_SLOT][1] = "Renamed" + self.write(body) + self.assertEqual(revision, self.layout()[2]) + body[ship_cc.HULL_SLOT][ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, True, True, True])] + self.write(body) + self.assertNotEqual(revision, self.layout()[2]) + + def test_saves_the_layout_and_returns_the_new_revision(self): + _, _, revision = self.layout() + moved = hardpoint_row(tail=()) + moved[1] = [0, 0] + returned = self.edit(revision, cells=[True, True, True, False], hardpoints=[moved]) + shape_triple, hardpoints, saved = self.layout() + self.assertEqual([2, 2, [True, True, True, False]], shape_triple) + self.assertEqual([0, 0], ship_cc.decode_hardpoint(hardpoints[0])["Position"]) + self.assertEqual(saved, returned) + self.assertNotEqual(revision, returned) + + def test_preserves_every_slot_it_does_not_edit(self): + _, _, revision = self.layout() + before = self.body() + self.edit(revision) + after = self.body() + for slot in range(len(before)): + if slot != ship_cc.HULL_SLOT: + self.assertEqual(before[slot], after[slot], f"ship slot {slot} changed") + for slot in range(len(before[ship_cc.HULL_SLOT])): + if slot not in (ship_cc.HULL_SHAPE_SLOT, ship_cc.HULL_HARDPOINTS_SLOT): + self.assertEqual(before[ship_cc.HULL_SLOT][slot], after[ship_cc.HULL_SLOT][slot], f"hull slot {slot} changed") + + def test_carries_a_later_schemas_hardpoint_slots_by_mount_id(self): + _, _, revision = self.layout() + starboard = hardpoint_row("thruster.starboard", tail=()) + self.edit(revision, hardpoints=[starboard, hardpoint_row(tail=())]) + rows = self.layout()[1] + self.assertEqual(["thruster.starboard", "thruster.port"], [ship_cc.decode_hardpoint(row)["Transform"] for row in rows]) + self.assertEqual(len(ship_cc.HARDPOINT_MEMBERS), len(rows[0])) + self.assertEqual(["future-hardpoint-slot"], rows[1][len(ship_cc.HARDPOINT_MEMBERS):]) + + def test_drops_the_row_of_a_removed_mount(self): + _, _, revision = self.layout() + self.edit(revision, hardpoints=[]) + self.assertEqual([], self.layout()[1]) + + def test_keeps_the_schema_catalog(self): + _, _, revision = self.layout() + self.edit(revision) + envelope = cultcache_py.SingleFileMessagePackBackingStore(self.path).pull_all()[0] + self.assertEqual(CATALOG.members, envelope.catalog_entry.members) + + def test_refuses_a_ship_id_that_changed_and_writes_nothing(self): + _, _, revision = self.layout() + before = self.bytes() + with self.assertRaisesRegex(ValueError, "bound ship ID changed"): + self.edit(revision, ship_id="mod.other") + self.assertEqual(before, self.bytes()) + + def test_refuses_a_stale_revision_and_writes_nothing(self): + _, _, revision = self.layout() + self.edit(revision) + before = self.bytes() + with self.assertRaisesRegex(ValueError, "layout changed since Load"): + self.edit(revision) + self.assertEqual(before, self.bytes()) + + def test_refuses_grid_sizes_outside_one_to_thirty_two_and_writes_nothing(self): + _, _, revision = self.layout() + before = self.bytes() + for width, height in ((0, 2), (2, 0), (33, 1), (1, 33)): + with self.subTest(width=width, height=height): + with self.assertRaisesRegex(ValueError, "1..32 cells"): + self.edit(revision, width=width, height=height, cells=[True] * (width * height)) + for width, height in ((1, 1), (32, 32)): + with self.subTest(width=width, height=height): + self.edit(revision, width=width, height=height, cells=[True] * (width * height), hardpoints=[]) + revision = self.layout()[2] + self.assertNotEqual(before, self.bytes()) + + def test_refuses_a_cell_count_that_does_not_fill_the_grid(self): + _, _, revision = self.layout() + before = self.bytes() + with self.assertRaisesRegex(ValueError, "1..32 cells"): + self.edit(revision, cells=[True, True, True]) + self.assertEqual(before, self.bytes()) + + def test_refuses_a_grid_with_no_occupied_cell(self): + _, _, revision = self.layout() + with self.assertRaisesRegex(ValueError, "needs an occupied cell"): + self.edit(revision, cells=[False] * 4) + + def test_refuses_a_hardpoint_row_of_the_wrong_arity_or_without_a_mount_id(self): + _, _, revision = self.layout() + before = self.bytes() + row = hardpoint_row(tail=()) + for name, bad in (("short", row[:-1]), ("long", row + [0]), + ("blank", ship_cc.encode_hardpoint(**{**ship_cc.decode_hardpoint(row), "Transform": ""})), + ("none", ship_cc.encode_hardpoint(**{**ship_cc.decode_hardpoint(row), "Transform": None})), + ("number", ship_cc.encode_hardpoint(**{**ship_cc.decode_hardpoint(row), "Transform": 4}))): + with self.subTest(name): + with self.assertRaisesRegex(ValueError, "stable mount ID"): + self.edit(revision, hardpoints=[bad]) + self.assertEqual(before, self.bytes()) + + def test_refuses_a_hardpoint_footprint_that_is_not_a_filled_grid_of_one_to_thirty_two(self): + _, _, revision = self.layout() + before = self.bytes() + for name, footprint in (("no width", shape(0, 1, [])), ("no height", shape(1, 0, [])), + ("too wide", shape(33, 1, [True] * 33)), ("too tall", shape(1, 33, [True] * 33)), + ("short", shape(2, 1, [True])), ("empty", shape(1, 1, [False]))): + row = ship_cc.encode_hardpoint(**{**ship_cc.decode_hardpoint(hardpoint_row(tail=())), "Shape": [footprint]}) + with self.subTest(name): + with self.assertRaisesRegex(ValueError, "thruster.port has an invalid footprint"): + self.edit(revision, hardpoints=[row]) + self.assertEqual(before, self.bytes()) + + def test_refuses_a_payload_without_the_hull_hardpoint_slot(self): + body = self.body() + body[ship_cc.HULL_SLOT] = body[ship_cc.HULL_SLOT][:ship_cc.HULL_HARDPOINTS_SLOT] + self.write(body) + with self.assertRaisesRegex(ValueError, "incompatible typed payload"): + self.layout() + + +class HardpointRowTests(unittest.TestCase): + def test_a_row_is_one_value_per_member_in_slot_order(self): + fields = dict(Type=1, Position=[2, 3], Shape="shape", Transform="mount", Rotation=2, Armor=4.0, FiringArc=90.0) + row = ship_cc.encode_hardpoint(**fields) + self.assertEqual([fields[name] for name in ship_cc.HARDPOINT_MEMBERS], row) + self.assertEqual(fields, ship_cc.decode_hardpoint(row)) + + def test_encoding_needs_every_member_and_no_other(self): + good = dict.fromkeys(ship_cc.HARDPOINT_MEMBERS, 0) + with self.assertRaises(ValueError): + ship_cc.encode_hardpoint(**{name: 0 for name in ship_cc.HARDPOINT_MEMBERS[1:]}) + with self.assertRaises(ValueError): + ship_cc.encode_hardpoint(**good, Extra=1) + + +class Matrix: + """Stands in for mathutils.Matrix: a translation by (10, 20, 30).""" + + def __matmul__(self, position): + return tuple(value + offset for value, offset in zip(position, (10.0, 20.0, 30.0))) + + +def point(x, radius=0.5, opacity=1.0): + return SimpleNamespace(position=(x, 0.0, 0.0), radius=radius, opacity=opacity) + + +def stroke(points, material_index=0, cyclic=False): + return SimpleNamespace(points=points, material_index=material_index, cyclic=cyclic) + + +def frame(number, strokes): + return SimpleNamespace(frame_number=number, drawing=SimpleNamespace(strokes=strokes)) + + +def layer(name, frames): + return SimpleNamespace(name=name, frames=frames) + + +def grease_pencil(layers, materials): + return SimpleNamespace(type="GREASEPENCIL", matrix_world=Matrix(), data=SimpleNamespace(layers=layers, materials=materials)) + + +class CaptureTests(unittest.TestCase): + def test_refuses_an_object_that_is_not_grease_pencil(self): + with self.assertRaisesRegex(ValueError, "is not a Grease Pencil object"): + ship_cc.capture_grease_pencil(SimpleNamespace(type="MESH", name="Cube"), None, 1) + + def test_captures_the_latest_frame_at_or_before_the_current_one(self): + materials = [SimpleNamespace(name="Ink", grease_pencil=SimpleNamespace(color=(0.1, 0.2, 0.3, 1.0)))] + obj = grease_pencil([layer("Hull", [frame(1, [stroke([point(1.0), point(2.0)])]), + frame(5, [stroke([point(3.0), point(4.0)])]), + frame(9, [stroke([point(5.0), point(6.0)])])])], materials) + lines = ship_cc.capture_grease_pencil(obj, None, 5, evaluated=False) + self.assertEqual([["Hull", "Ink", [13.0, 20.0, 30.0, 14.0, 20.0, 30.0], [0.5, 0.5], [1.0, 1.0], False, + [0.1, 0.2, 0.3, 1.0]]], lines) + self.assertEqual(13.0, ship_cc.capture_grease_pencil(obj, None, 6, evaluated=False)[0][2][0]) + self.assertEqual(11.0, ship_cc.capture_grease_pencil(obj, None, 1, evaluated=False)[0][2][0]) + + def test_skips_layers_without_a_frame_yet_and_strokes_of_one_point(self): + obj = grease_pencil([layer("Later", [frame(7, [stroke([point(1.0), point(2.0)])])]), + layer("Now", [frame(1, [stroke([point(1.0)]), stroke([point(1.0), point(2.0)], cyclic=True)])])], + [SimpleNamespace(name="Plain", grease_pencil=None, diffuse_color=(0.4, 0.5, 0.6, 1.0))]) + lines = ship_cc.capture_grease_pencil(obj, None, 3, evaluated=False) + self.assertEqual(1, len(lines)) + self.assertEqual(["Now", "Plain"], lines[0][:2]) + self.assertTrue(lines[0][5]) + self.assertEqual([0.4, 0.5, 0.6, 1.0], lines[0][6]) + + def test_a_stroke_without_a_material_is_white_and_unnamed(self): + obj = grease_pencil([layer("Hull", [frame(1, [stroke([point(1.0), point(2.0)])])])], [None]) + line = ship_cc.capture_grease_pencil(obj, None, 1, evaluated=False)[0] + self.assertEqual(["Hull", ""], line[:2]) + self.assertEqual([1.0, 1.0, 1.0, 1.0], line[6]) + + def test_reads_strokes_from_the_evaluated_copy_but_transforms_by_the_source_object(self): + evaluated = grease_pencil([layer("Baked", [frame(1, [stroke([point(1.0), point(2.0)])])])], [None]) + source = grease_pencil([layer("Raw", [frame(1, [stroke([point(1.0), point(2.0)])])])], [None]) + source.evaluated_get = lambda depsgraph: evaluated + self.assertEqual("Baked", ship_cc.capture_grease_pencil(source, "graph", 1)[0][0]) + self.assertEqual("Raw", ship_cc.capture_grease_pencil(source, "graph", 1, evaluated=False)[0][0]) + + +if __name__ == "__main__": + unittest.main() From fe38f28865d2f0d8ae5662dd3fbbcd023d0348f3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 02:14:10 +0200 Subject: [PATCH 011/113] Close the gaps a mutation pass found in the new ship tests (S-Soul F1-F3) A pass of 137 hand-written mutations over ShipAuthoring.cs, ShipModCatalog.cs and ship_cc.py, run against the tests from the last three commits, left six surviving or unrunnable. Each is resolved: - Validator: the hull Width/Height < 1 guards and the opacity IsInfinity guard could not change any outcome (an empty grid has no occupied cell; +Infinity exceeds 1 and -Infinity is below 0). Deleted. - Two accepted shapes had no test: a hardpoint footprint with empty cells beside occupied ones, and an empty ParentId meaning no parent. Added. - The GLB header length was pinned only from above. Added the shorter case. - Compose's working-directory cleanup was invisible because the assertion listed files, not directory entries. It now lists both. - ReadPackage runs the catalog's item validation on the hull (the derived catalog must reopen); that call now has a test. - Python: a single non-ship record, a non-array payload long enough to fill every slot, and a hardpoint-only change to the layout revision each had a surviving mutation. Tests added. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 5 ++-- .../ShipModCatalogTests.cs | 17 +++++++++++--- .../ShipValidationTests.cs | 17 ++++++++++++++ tools/blender/tests/test_ship_cc.py | 23 ++++++++++++++++--- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index c19ab153d..992e6405f 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -83,8 +83,7 @@ public static class ShipAuthoringStore throw new InvalidOperationException($"{ship.Id}: ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens."); var hull = ship.Hull ?? throw new InvalidOperationException($"{ship.Id}: hull data is required."); if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required."); - if (hull.Shape?.Cells == null || hull.Shape.Width < 1 || hull.Shape.Height < 1 || - !hull.Shape.Cells.Cast().Any(occupied => occupied)) + if (hull.Shape?.Cells == null || !hull.Shape.Cells.Cast().Any(occupied => occupied)) throw new InvalidOperationException($"{ship.Id}: schematic must contain at least one cell."); if (!string.IsNullOrEmpty(hull.Prefab)) throw new InvalidOperationException($"{ship.Id}: a mod ship cannot name a Unity prefab."); @@ -146,7 +145,7 @@ public static class ShipAuthoringStore line.Opacities != null && line.Opacities.Length != count) throw new InvalidOperationException($"{ship.Id}: schematic line point attributes have the wrong length."); if (line.Radii != null && line.Radii.Any(value => float.IsNaN(value) || float.IsInfinity(value) || value < 0) || - line.Opacities != null && line.Opacities.Any(value => float.IsNaN(value) || float.IsInfinity(value) || value < 0 || value > 1)) + line.Opacities != null && line.Opacities.Any(value => float.IsNaN(value) || value < 0 || value > 1)) throw new InvalidOperationException($"{ship.Id}: schematic line radius and opacity must be finite and in range."); if (line.Color != null && (line.Color.Length != 4 || line.Color.Any(value => float.IsNaN(value) || float.IsInfinity(value)))) diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 3c651f38e..029ea81c8 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -205,6 +205,14 @@ public sealed class ShipModCatalogTests : IDisposable ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); } + [Fact] + public void ReadPackageAppliesTheCatalogsItemValidationToTheHull() + { + WritePackage("mod.skiff", tweak: ship => ship.Hull.MinimumTemperature = ship.Hull.MaximumTemperature); + Assert.Contains("zero-span range", Assert.Throws(() => + ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); + } + [Fact] public void ReadPackageRefusesAnAnchorWhoseNodeIsNotInTheModel() { @@ -240,7 +248,8 @@ public sealed class ShipModCatalogTests : IDisposable Refuse("short.glb", With(good.Take(19).ToArray(), 8, 19), "invalid GLB 2 header"); Refuse("magic.glb", With(good, 0, 0x46546C68), "invalid GLB 2 header"); Refuse("version.glb", With(good, 4, 1), "invalid GLB 2 header"); - Refuse("length.glb", With(good, 8, (uint)good.Length + 1), "invalid GLB 2 header"); + Refuse("longer.glb", With(good, 8, (uint)good.Length + 1), "invalid GLB 2 header"); + Refuse("shorter.glb", With(good, 8, (uint)good.Length - 4), "invalid GLB 2 header"); Refuse("chunktype.glb", With(good, 16, 0x004E4942), "missing GLB JSON chunk"); Refuse("chunklength.glb", With(good, 12, (uint)good.Length), "missing GLB JSON chunk"); } @@ -261,7 +270,7 @@ public sealed class ShipModCatalogTests : IDisposable private void AssertOnlyTheDerivedFile() { - var names = Directory.GetFiles(Path.GetDirectoryName(Derived)).Select(file => Path.GetFileName(file)).ToArray(); + var names = Directory.GetFileSystemEntries(Path.GetDirectoryName(Derived)).Select(entry => Path.GetFileName(entry)).ToArray(); Assert.True(names.Length == 1 && names[0] == "Aetheria.modded.cc", string.Join(" | ", names)); } @@ -297,9 +306,11 @@ public sealed class ShipModCatalogTests : IDisposable return path; } - private void WritePackage(string id, string hullName = "Skiff", string[] nodes = null, string modelAsset = "skiff.glb") + private void WritePackage(string id, string hullName = "Skiff", string[] nodes = null, string modelAsset = "skiff.glb", + Action tweak = null) { var ship = ShipAuthoringTests.Fixture(); + tweak?.Invoke(ship); ship.Id = id; ship.Hull.Name = hullName; ship.ModelAsset = modelAsset; diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index d8d94b8a6..17d54ad4b 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -96,6 +96,23 @@ public sealed class ShipValidationTests ShipAuthoringStore.Validate(ship); } + [Fact] + public void EmptyCellsInAHardpointFootprintNeedNotLieOnTheHull() + { + var ship = ShipAuthoringTests.Fixture(); + ship.Hull.Hardpoints[0].Shape = new Shape(2, 1); + ship.Hull.Hardpoints[0].Shape.Cells[0, 0] = true; + ShipAuthoringStore.Validate(ship); + } + + [Fact] + public void AnEmptyParentIdMeansNoParent() + { + var ship = ShipAuthoringTests.Fixture(); + ship.Anchors[0].ParentId = ""; + ShipAuthoringStore.Validate(ship); + } + [Fact] public void AnAnchorMayNameItsHardpointAsParent() { diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index ab9eb6299..2e6715822 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -100,6 +100,13 @@ class ReadTests(ShipFileCase): with self.assertRaisesRegex(ValueError, "must hold exactly one"): ship_cc.read(self.path, PACKAGES) + def test_refuses_a_file_whose_only_record_is_not_a_ship(self): + other = str(Path(self.path).with_name("other.cc")) + cultcache_py.SingleFileMessagePackBackingStore(other).push(cultcache_py.CultCacheEnvelope.create( + key="other", type="aetheria.something", payload=msgpack.packb([1], use_bin_type=True))) + with self.assertRaisesRegex(ValueError, "must hold exactly one"): + ship_cc.read(other, PACKAGES) + def test_refuses_a_file_with_no_record(self): empty = str(Path(self.path).with_name("empty.cc")) Path(empty).write_bytes(b"") @@ -112,9 +119,11 @@ class ReadTests(ShipFileCase): ship_cc.read(self.path, PACKAGES) def test_refuses_a_payload_that_is_not_an_array(self): - self.write({"Id": "mod.skiff"}) - with self.assertRaisesRegex(ValueError, "incompatible"): - ship_cc.read(self.path, PACKAGES) + for payload in ({"Id": "mod.skiff"}, {str(index): index for index in range(9)}, "a string long enough to fill every slot"): + with self.subTest(payload=payload): + self.write(payload) + with self.assertRaisesRegex(ValueError, "incompatible"): + ship_cc.read(self.path, PACKAGES) class ReplaceLinesTests(ShipFileCase): @@ -168,6 +177,14 @@ class LayoutTests(ShipFileCase): self.write(body) self.assertNotEqual(revision, self.layout()[2]) + def test_the_revision_follows_the_hardpoints_too(self): + _, _, revision = self.layout() + moved = hardpoint_row(tail=()) + moved[1] = [0, 0] + same_cells = [True, False, True, False] + self.assertEqual(revision, self.edit(revision, cells=same_cells, hardpoints=[hardpoint_row(tail=())])) + self.assertNotEqual(revision, self.edit(revision, cells=same_cells, hardpoints=[moved])) + def test_saves_the_layout_and_returns_the_new_revision(self): _, _, revision = self.layout() moved = hardpoint_row(tail=()) From 01e9db1c27738b63ddd97ff882ad26b8d7ff20c7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 02:26:50 +0200 Subject: [PATCH 012/113] Ship S0: correct the authoring doc, record the merge status in the map Applies corrections 1-4 of docs/moddable-ships-cut.md B.1 to docs/moddable-ship-authoring.md: label the Quiet probe numbers as unrecorded, name the double-held mod hull in the derived catalog, name the ShipAuthoring catalog-routing invariant, and scope "same semantic validator" to C# callers. The map's status header gains the S0 result (615 tests on the merge), the S-Soul status, the corrected TradeMenu.cs path, and anchors re-taken against the merged tree. Co-Authored-By: Claude Sonnet 5.5 --- docs/moddable-ship-authoring.md | 20 ++++++++++++++++++-- docs/moddable-ships-cut.md | 23 ++++++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 85f9c79f8..0b6a814ea 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -6,6 +6,10 @@ authoring, validation, loading, and play checks. ## LineArt proof from Quiet.blend +The numbers in this section and in the compose example below come from an +unrecorded probe. `Build/quiet.cc` is git-ignored and no committed fixture or +test reproduces them. + `C:\Users\Meta\Desktop\Quiet.blend` is a read-only source for this probe. At frame 1, `Quiet / Dexter Quiet Lineart` evaluates to 1,040 polylines with 3,125 points. Blender 5.2.2 wrote those coordinates, point radii and opacities, @@ -96,7 +100,9 @@ is not yet an end-to-end ship loader in the current game. migrated hull; it does not keep two writable definitions of that hull. `ship-authoring validate`, the catalog composer, and the visual importer call -the same semantic validator. Blender edits the hull grid, hardpoints, and +the same semantic validator; that shared path is true of the C# callers only. +The Blender side checks grid bounds and footprint shape (`ship_cc.py`) and +nothing semantic. Blender edits the hull grid, hardpoints, and captured lines in the typed source record; `inspect` reads incomplete drafts. Unity's **Aetheria / Preview Mod Ship Package** imports a validated package from disk, resolves stable GLB node IDs, @@ -107,13 +113,23 @@ records. ## Runtime catalog seam +`AetheriaStores.CatalogTypes` routes `ShipAuthoring` to the shipped catalog, +but the shipped catalog contains no `ShipAuthoring` record. This stays correct +only while nothing writes a `ShipAuthoring` through a catalog-writable cache on +`Aetheria.cc`; a mod ship's authoring record lives in its own `ship.cc` and in +the derived catalog. + `CultCache` routes `ItemData` to one home backing store, and `ActionGameManager.CultCache` holds that store for the entire play session. Attaching each mod's `.cc` as another item store would violate the cache's one-home invariant. `ship-authoring compose` instead derives one runtime catalog from the shipped catalog and validated mod packages, preserving all shipped record keys. Each mod's stable `ShipAuthoring.Id` yields separate deterministic keys -for its copied `HullData` and its runtime authoring record. The generated +for its copied `HullData` (`mod-hull:`) and its runtime authoring record +(`mod-ship:`). The authoring record's key 1 is the same `HullData`, so the +derived catalog currently holds each mod hull twice. Gameplay reads only the +`mod-hull` copy; the `mod-ship` copy is dead weight inside a disposable file +until the open question on which record owns the hull is settled. 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. diff --git a/docs/moddable-ships-cut.md b/docs/moddable-ships-cut.md index 7ce1d032d..8562de141 100644 --- a/docs/moddable-ships-cut.md +++ b/docs/moddable-ships-cut.md @@ -136,10 +136,31 @@ control, which has since moved 8 commits to `b66ba524`. Neither side touches the | `375d6bd4` | Blender layout editing (hull grid, hardpoints) through `ship_cc.py` | The delta is +511 C# (including `tools/AetherDb`), +494 Python, +119 tests, and one new Unity package dependency. -It also carries a stray fix in `TradeMenu.cs` (an unused `using System.Runtime.Remoting.Contexts`). +It also carries a stray fix in `Assets/Scripts/UI/Menu/TradeMenu.cs` (an unused `using System.Runtime.Remoting.Contexts`). +Self's ruling: it stays on this branch as cleanup. Master compiled clean in Unity with the using present, so master does +not need it. **Test counts.** The two ship tests pass on the merged tree: 364/364 (section 0). +**S-Soul status:** F1, F2, F3 and F6 fixed at `fe38f288` (479 tests). F4 has no spec entry. The Python tests +(`tools/blender/tests/test_ship_cc.py`, 32) have no runner yet; run them by hand. + +**S0 status (2026-09-30):** master `65c63495` (fire control 12, `dcd7bbc5`) merged into `codex/moddable-ships` with +`--no-ff` at `18e12d81`, no conflicts. Corrections 1-4 applied to `docs/moddable-ship-authoring.md`. Yggdrasil full +suite on the merge (`dotnet test tests/Aetheria.Shared.Tests`, CultLib `45c2f40`, CultMath `6d5e209`): **615 passed, 0 failed**. The Unity batchmode compile and the `ShipModPreview.Smoke` run are Self's +steps and have not run. + +**Anchors re-taken against the merged tree (`18e12d81`).** +- Unchanged: `ShipAuthoring.cs:15` (`Hull`, key 1), `:84`, `:111-136` (the hardpoint checks are `:112-131`), + `ShipModCatalog.cs:20-21` (keys), `ItemData.cs:512` (`Hardpoints` `Key(23)`), `:515` (`Prefab` `Key(24)`), + `Loadout.cs:74`, `EntityInstance.cs:15-27`, `ZoneRenderer.cs:289-310` (`LoadEntity`), `AetheriaStores.cs:9`. +- Moved: the second upsert in `ShipModCatalog.Compose` is `:68-69` (was `:60-61`; the F1-F3 fix added the + name-collision checks above it). `ship_cc.py`: slot constants are `:23-25`, `replace_layout` is `:96-124`, + the `body[HULL_SLOT]` reads are `:84,101`. `ActionGameManager.cs`: the catalog opens at `:55-59` + (`AetheriaStores.Open`, not `:45-60`); `capturepreset` is `:621-662`. +- S1's key 32 for `HullData.Visual` holds: `EquippableItemData` owns 30 and 31, `HullData` uses 23-29, and + `WeaponItemData.Fuse` at 32 (`ItemData.cs:505`) is a sibling class, not an ancestor. + **Proof-gate status against the doc's own gates (`docs/moddable-ship-authoring.md:149-164` @`375d6bd4`):** | Gate | Doc implies | Verified in git | From de1b5b8fa247abeddb5726f3e3e80654aba2c7b2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 02:57:08 +0200 Subject: [PATCH 013/113] Ships map: S0 Unity compile and smoke passed; fixture package owed Co-Authored-By: Claude Opus 5.5 --- docs/moddable-ships-cut.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/moddable-ships-cut.md b/docs/moddable-ships-cut.md index 8562de141..1b9e428fc 100644 --- a/docs/moddable-ships-cut.md +++ b/docs/moddable-ships-cut.md @@ -147,8 +147,12 @@ not need it. **S0 status (2026-09-30):** master `65c63495` (fire control 12, `dcd7bbc5`) merged into `codex/moddable-ships` with `--no-ff` at `18e12d81`, no conflicts. Corrections 1-4 applied to `docs/moddable-ship-authoring.md`. Yggdrasil full -suite on the merge (`dotnet test tests/Aetheria.Shared.Tests`, CultLib `45c2f40`, CultMath `6d5e209`): **615 passed, 0 failed**. The Unity batchmode compile and the `ShipModPreview.Smoke` run are Self's -steps and have not run. +suite on the merge (`dotnet test tests/Aetheria.Shared.Tests`, CultLib `45c2f40`, CultMath `6d5e209`): **615 passed, 0 failed**. Self's steps, 2026-09-30 05:15 CEST: the Unity 6000.3.24f1 +batchmode compile of `01e9db1c` has 0 `error CS` and 0 `warning CS`, and `ShipModPreview.Smoke` passed +(`SHIP_MOD_VISUAL_SMOKE anchors=5 points=2 segments=1`, its first recorded run). **The branch has no committed +fixture package:** the smoke ran against one generated from `ShipAuthoringTests.Fixture()` with a hand-added glTF +`scene` (the test GLB has nodes but no scene; whether glTFast instantiates a scene-less GLB is unchecked). S1 should +commit a scene-bearing fixture package so the smoke is reproducible. **Anchors re-taken against the merged tree (`18e12d81`).** - Unchanged: `ShipAuthoring.cs:15` (`Hull`, key 1), `:84`, `:111-136` (the hardpoint checks are `:112-131`), From 1702e0549ff06b04fea6ad4a3e2b73f6db3a23ae Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 03:06:43 +0200 Subject: [PATCH 014/113] Ship S1: one hull record, and a hull names its visual A mod ship file now holds a HullData at mod-hull: and the ShipAuthoring visual package at mod-ship:. HullData.Visual (key 32) is the typed ref between them; ShipAuthoring.Hull (key 1) is retired. - One joint validator over (hull, visual) is the only semantic check; Load owns the file structure (one hull, one visual, deterministic keys). - Compose copies the mod store's records verbatim, so the derived catalog holds one hull per mod ship. - ship_cc.py edits the hull record's slots directly; the body[HULL_SLOT] indirection is gone. - The package fixture is generated from ShipAuthoringTests.Fixture() (glTF scene included) and exported when AETHERIA_SHIP_FIXTURE_DIR is set. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/Gameplay/ShipModVisual.cs | 2 +- Assets/Scripts/ServerShared/ItemData.cs | 5 + Assets/Scripts/ServerShared/ShipAuthoring.cs | 52 +++-- Assets/Scripts/ServerShared/ShipModCatalog.cs | 26 +-- .../ShipAuthoringTests.cs | 182 +++++++++++++----- tests/Aetheria.Shared.Tests/ShipFixture.cs | 54 ++++++ .../ShipModCatalogTests.cs | 113 ++++++----- .../ShipSchemaPinTests.cs | 43 +++-- .../ShipValidationTests.cs | 151 +++++++-------- tools/AetherDb/ShipAuthoringCommands.cs | 24 ++- tools/blender/aetheria_ships/__init__.py | 11 +- tools/blender/aetheria_ships/ship_cc.py | 93 +++++---- tools/blender/tests/test_ship_cc.py | 181 ++++++++++++----- 13 files changed, 620 insertions(+), 317 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/ShipFixture.cs diff --git a/Assets/Scripts/Gameplay/ShipModVisual.cs b/Assets/Scripts/Gameplay/ShipModVisual.cs index 9c4c0c33b..fd4058d83 100644 --- a/Assets/Scripts/Gameplay/ShipModVisual.cs +++ b/Assets/Scripts/Gameplay/ShipModVisual.cs @@ -33,7 +33,7 @@ public static class ShipModVisual public static async Task LoadAsync(string shipPath, Transform parent) { var package = ShipModCatalog.ReadPackage(shipPath); - var ship = package.Ship; + var ship = package.Visual; var root = new GameObject("mod-ship:" + ship.Id); root.SetActive(false); diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 59d93c1ba..10831042b 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -530,6 +530,11 @@ public class HullData : EquippableItemData [Inspectable, JsonProperty("canTow"), Key(29)] public bool CanTow; + // The hull's visual: a ShipAuthoring record holding its model package, or unset for a Unity-prefab hull. A hull + // names exactly one of Prefab and Visual. Keys 30 and 31 are EquippableItemData's, so 32 is the next free key. + [JsonProperty("visual"), Key(32)] + public CultRecordRef Visual; + [IgnoreMember] public Shape InteriorCells { diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index 992e6405f..c9ea6c058 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -6,13 +6,13 @@ using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; -// A mod ship's authoring record. Hull is the existing simulation design, embedded here so a -// proof ship has one writable source; the shipped catalog contains no copy of that ship. +// A mod ship's visual package: model, anchors, schematic lines. The hull semantics (cells, hardpoints, stats) live in +// the ship's own HullData record beside this one, and HullData.Visual is the only binding between the two. [CultDocument("aetheria.ship_authoring", "1"), MessagePackObject] public sealed class ShipAuthoring { [CultName, Key(0)] public string Id; - [Key(1)] public HullData Hull; + // Key 1 held the embedded HullData until S1; it is retired and never reused. [Key(2)] public string ModelAsset; [Key(3)] public List Anchors = new List(); [Key(4)] public List SchematicLines = new List(); @@ -54,7 +54,7 @@ public static class ShipAuthoringStore var cache = new CultCache(); try { - cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, !writable), new[] { typeof(ShipAuthoring) }); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, !writable), new[] { typeof(HullData), typeof(ShipAuthoring) }); return cache; } catch @@ -64,29 +64,55 @@ public static class ShipAuthoringStore } } - public static ShipAuthoring Read(string path) + // Writes one ship's two records under their deterministic keys, the shape Load expects. + public static void Write(CultCache cache, HullData hull, ShipAuthoring visual) { - using var cache = Open(path); - var records = cache.GetAll().ToArray(); - if (records.Length != 1) - throw new InvalidOperationException($"{path} must hold exactly one ship authoring record, found {records.Length}."); - Validate(records[0]); - return records[0]; + cache.UpsertAsync(typeof(HullData), hull, ShipModCatalog.HullKey(visual.Id)).GetAwaiter().GetResult(); + cache.UpsertAsync(typeof(ShipAuthoring), visual, ShipModCatalog.AuthoringKey(visual.Id)).GetAwaiter().GetResult(); } - public static void Validate(ShipAuthoring ship) + // The structure of a ship file, without judging its content (drafts load): exactly one hull and one visual, each at + // its deterministic key, the hull naming that visual by its typed ref. + public static (HullData Hull, ShipAuthoring Visual) Load(string path) + { + using var cache = Open(path); + var hulls = cache.GetAll().ToArray(); + var visuals = cache.GetAll().ToArray(); + if (visuals.Length != 1) + throw new InvalidOperationException($"{path} must hold exactly one ship authoring record, found {visuals.Length}."); + if (hulls.Length != 1) + throw new InvalidOperationException($"{path} must hold exactly one hull record, found {hulls.Length}."); + var (hull, visual) = (hulls[0], visuals[0]); + if (!cache.TryGetHandle(hull).Value.Key.Equals(ShipModCatalog.HullKey(visual.Id)) || + !cache.TryGetHandle(visual).Value.Key.Equals(ShipModCatalog.AuthoringKey(visual.Id))) + throw new InvalidOperationException($"{path}: {visual.Id}: records must be stored under {ShipModCatalog.HullKey(visual.Id).Value} and {ShipModCatalog.AuthoringKey(visual.Id).Value}."); + return (hull, visual); + } + + public static (HullData Hull, ShipAuthoring Visual) Read(string path) + { + var (hull, visual) = Load(path); + Validate(hull, visual); + return (hull, visual); + } + + // The one semantic check every path shares: the hull and its visual, judged together. + public static void Validate(HullData hull, ShipAuthoring ship) { if (ship == null) throw new InvalidOperationException("Ship authoring record is null."); if (string.IsNullOrWhiteSpace(ship.Id)) throw new InvalidOperationException("Ship ID is required."); if (!(ship.Id[0] >= 'a' && ship.Id[0] <= 'z' || ship.Id[0] >= '0' && ship.Id[0] <= '9') || ship.Id.Any(c => !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-'))) throw new InvalidOperationException($"{ship.Id}: ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens."); - var hull = ship.Hull ?? throw new InvalidOperationException($"{ship.Id}: hull data is required."); + if (hull == null) throw new InvalidOperationException($"{ship.Id}: hull data is required."); if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required."); if (hull.Shape?.Cells == null || !hull.Shape.Cells.Cast().Any(occupied => occupied)) throw new InvalidOperationException($"{ship.Id}: schematic must contain at least one cell."); + // A hull names exactly one visual: a Unity prefab (shipped hulls) or a ShipAuthoring record (mod ships). if (!string.IsNullOrEmpty(hull.Prefab)) throw new InvalidOperationException($"{ship.Id}: a mod ship cannot name a Unity prefab."); + if (!hull.Visual.Key.Equals(ShipModCatalog.AuthoringKey(ship.Id))) + throw new InvalidOperationException($"{ship.Id}: the hull must name its visual record {ShipModCatalog.AuthoringKey(ship.Id).Value}."); if (string.IsNullOrWhiteSpace(ship.ModelAsset) || Path.IsPathRooted(ship.ModelAsset) || ship.ModelAsset.Replace('\\', '/').Split('/').Any(part => part == "..")) throw new InvalidOperationException($"{ship.Id}: model asset must be a relative package path."); diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 8bb737d01..148a3372e 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -12,7 +12,8 @@ public static class ShipModCatalog { public sealed class Package { - public ShipAuthoring Ship; + public HullData Hull; + public ShipAuthoring Visual; public string ModelPath; public Dictionary NodeIndices; } @@ -32,13 +33,13 @@ public static class ShipModCatalog if (output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("The derived catalog cannot replace a mod source or asset."); - var ships = new List(); + var packages = new List(); foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal)) { var path = Path.Combine(directory, "ship.cc"); if (!File.Exists(path)) continue; // ReadPackage pins each ID to its directory name, so IDs within one mods root are already unique. - ships.Add(ReadPackage(path).Ship); + packages.Add(ReadPackage(path)); } Directory.CreateDirectory(Path.GetDirectoryName(output)); @@ -52,8 +53,9 @@ public static class ShipModCatalog File.Copy(source, temporary); using (var cache = AetheriaStores.Open(temporary, catalogWritable: true)) { - foreach (var ship in ships) + foreach (var package in packages) { + var (hull, ship) = (package.Hull, package.Visual); var hullKey = HullKey(ship.Id); var authoringKey = AuthoringKey(ship.Id); if (cache.AllStoredDocuments.Any(record => record.Key.Equals(hullKey) || record.Key.Equals(authoringKey))) @@ -61,18 +63,18 @@ public static class ShipModCatalog // CultCache indexes names per concrete type and lets the last writer win, and GetByName over a base // type throws on a match across subtypes. A mod hull named like any shipped item, or another mod's // hull (upserted earlier in this loop), would make name lookups wrong or throw. - if (cache.GetAll().Any(item => string.Equals(item.Name, ship.Hull.Name, StringComparison.Ordinal))) - throw new InvalidOperationException($"{ship.Id}: hull name '{ship.Hull.Name}' collides with an existing catalog item."); + if (cache.GetAll().Any(item => string.Equals(item.Name, hull.Name, StringComparison.Ordinal))) + throw new InvalidOperationException($"{ship.Id}: hull name '{hull.Name}' collides with an existing catalog item."); if (cache.GetAll().Any(existing => string.Equals(existing.Id, ship.Id, StringComparison.Ordinal))) throw new InvalidOperationException($"{ship.Id}: ship ID collides with an existing catalog ship authoring record."); - cache.UpsertAsync(typeof(HullData), ship.Hull, hullKey).GetAwaiter().GetResult(); - cache.UpsertAsync(typeof(ShipAuthoring), ship, authoringKey).GetAwaiter().GetResult(); + // Verbatim: the mod store's own two records, under the keys it already holds them at. + ShipAuthoringStore.Write(cache, hull, ship); } cache.FlushAsync().GetAwaiter().GetResult(); } if (File.Exists(output)) File.Replace(temporary, output, null); else File.Move(temporary, output); - return ships.Count; + return packages.Count; } finally { @@ -84,7 +86,7 @@ public static class ShipModCatalog { var path = Path.GetFullPath(shipPath); var directory = Path.GetDirectoryName(path); - var ship = ShipAuthoringStore.Read(path); + var (hull, ship) = ShipAuthoringStore.Read(path); if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal)) throw new InvalidOperationException($"{path}: ship ID must match its package directory name."); var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset)); @@ -95,8 +97,8 @@ public static class ShipModCatalog foreach (var anchor in ship.Anchors) if (!modelNodes.ContainsKey(anchor.ModelNodeId)) throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}."); - CultRecordRefs.Validate(ship.Hull); - return new Package { Ship = ship, ModelPath = modelPath, NodeIndices = modelNodes }; + CultRecordRefs.Validate(hull); + return new Package { Hull = hull, Visual = ship, ModelPath = modelPath, NodeIndices = modelNodes }; } // GLB is the xenos asset boundary. Only its node extras are read here; geometry belongs to the runtime importer. diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index dd6574669..54144ffe7 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using CultMath; +using GameCult.Caching; using Xunit; public sealed class ShipAuthoringTests @@ -16,19 +17,20 @@ public sealed class ShipAuthoringTests try { var ship = Fixture(); - ShipAuthoringStore.Validate(ship); + ship.Validate(); using (var cache = ShipAuthoringStore.Open(path, writable: true)) { - cache.Upsert(ship); + ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual); await cache.FlushAsync(); } - var reopened = ShipAuthoringStore.Read(path); - Assert.Equal("mod.skiff", reopened.Id); - Assert.Equal("Skiff", reopened.Hull.Name); - Assert.True(reopened.Hull.Shape.Cells[1, 0]); - Assert.Equal("thruster.port", reopened.Hull.Hardpoints[0].Transform); - Assert.Equal("skiff.glb", reopened.ModelAsset); - Assert.Equal(2, reopened.SchematicLines[0].Points.Length / 3); + var (hull, visual) = ShipAuthoringStore.Read(path); + Assert.Equal("mod.skiff", visual.Id); + Assert.Equal("Skiff", hull.Name); + Assert.True(hull.Shape.Cells[1, 0]); + Assert.Equal("thruster.port", hull.Hardpoints[0].Transform); + Assert.Equal(ShipModCatalog.AuthoringKey("mod.skiff"), hull.Visual.Key); + Assert.Equal("skiff.glb", visual.ModelAsset); + Assert.Equal(2, visual.SchematicLines[0].Points.Length / 3); } finally { Directory.Delete(root, true); } } @@ -38,24 +40,15 @@ public sealed class ShipAuthoringTests { var ship = Fixture(); ship.Hull.Shape.Cells[1, 0] = false; - Assert.Contains("outside the hull schematic", Assert.Throws(() => - ShipAuthoringStore.Validate(ship)).Message); + Assert.Contains("outside the hull schematic", Assert.Throws(ship.Validate).Message); ship = Fixture(); - ship.Anchors.RemoveAt(ship.Anchors.Count - 1); - Assert.Contains("has no model anchor", Assert.Throws(() => - ShipAuthoringStore.Validate(ship)).Message); + ship.Visual.Anchors.RemoveAt(ship.Visual.Anchors.Count - 1); + Assert.Contains("has no model anchor", Assert.Throws(ship.Validate).Message); ship = Fixture(); - ship.SchematicLines[0].Points[0] = float.NaN; - Assert.Contains("finite XYZ", Assert.Throws(() => - ShipAuthoringStore.Validate(ship)).Message); - - ship = Fixture(); - ShipAuthoringStore.Validate(ship); - ship.Hull.Shape.Cells[1, 0] = false; - Assert.Contains("outside the hull schematic", Assert.Throws(() => - ShipAuthoringStore.Validate(ship)).Message); + ship.Visual.SchematicLines[0].Points[0] = float.NaN; + Assert.Contains("finite XYZ", Assert.Throws(ship.Validate).Message); ship = Fixture(); ship.Hull.Hardpoints.Add(new HardpointData @@ -65,24 +58,98 @@ public sealed class ShipAuthoringTests Shape = new Shape(), Transform = "thruster.starboard" }); - ship.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" }); - Assert.Contains("overlaps another hardpoint", Assert.Throws(() => - ShipAuthoringStore.Validate(ship)).Message); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" }); + Assert.Contains("overlaps another hardpoint", Assert.Throws(ship.Validate).Message); } - internal static ShipAuthoring Fixture() + // The hull owns hull semantics and the visual owns the model package, so the one joint check refuses a hull mount the + // visual has no anchor for, and a visual anchor parented to a mount the hull does not have. + [Fact] + public void HardpointsMustResolveToTheVisualsAnchors() + { + var ship = Fixture(); + ship.Hull.Hardpoints[0].Transform = "thruster.renamed"; + Assert.Contains("thruster.renamed has no model anchor", Assert.Throws(ship.Validate).Message); + + ship = Fixture(); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.gone" }); + Assert.Contains("unknown hardpoint parent thruster.gone", Assert.Throws(ship.Validate).Message); + + Fixture().Validate(); + } + + [Fact] + public void HullNamesExactlyOneVisual() + { + var ship = Fixture(); + ship.Hull.Prefab = "Djinni"; + Assert.Contains("cannot name a Unity prefab", Assert.Throws(ship.Validate).Message); + + ship = Fixture(); + ship.Hull.Visual = default; + Assert.Contains("the hull must name its visual record mod-ship:mod.skiff", Assert.Throws(ship.Validate).Message); + + ship = Fixture(); + ship.Hull.Visual = new CultRecordRef(ShipModCatalog.AuthoringKey("mod.other")); + Assert.Contains("the hull must name its visual record mod-ship:mod.skiff", Assert.Throws(ship.Validate).Message); + + Fixture().Validate(); + } + + [Fact] + public async Task LoadRefusesAFileWhoseRecordsAreNotOneHullAndOneVisualAtTheirKeys() + { + using var directory = new TempDirectory(); + async Task Write(string name, Action fill) + { + var path = Path.Combine(directory.Path, name); + using var cache = ShipAuthoringStore.Open(path, writable: true); + fill(cache); + await cache.FlushAsync(); + return path; + } + void Hull(CultCache cache, string key) => + cache.UpsertAsync(typeof(HullData), Fixture().Hull, new CultRecordKey(key)).GetAwaiter().GetResult(); + void Visual(CultCache cache, string key, string id = "mod.skiff") => + cache.UpsertAsync(typeof(ShipAuthoring), Fixture().WithId(id).Visual, new CultRecordKey(key)).GetAwaiter().GetResult(); + + var twoShips = await Write("two.cc", cache => + { + ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual); + Visual(cache, "mod-ship:mod.other", "mod.other"); + }); + Assert.Contains("found 2", Assert.Throws(() => ShipAuthoringStore.Load(twoShips)).Message); + + var noHull = await Write("nohull.cc", cache => Visual(cache, "mod-ship:mod.skiff")); + Assert.Contains("exactly one hull record, found 0", Assert.Throws(() => ShipAuthoringStore.Load(noHull)).Message); + + var twoHulls = await Write("twohulls.cc", cache => + { + ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual); + Hull(cache, "mod-hull:mod.other"); + }); + Assert.Contains("exactly one hull record, found 2", Assert.Throws(() => ShipAuthoringStore.Load(twoHulls)).Message); + + var wrongHullKey = await Write("hullkey.cc", cache => { Hull(cache, "hull:skiff"); Visual(cache, "mod-ship:mod.skiff"); }); + Assert.Contains("records must be stored under mod-hull:mod.skiff and mod-ship:mod.skiff", + Assert.Throws(() => ShipAuthoringStore.Load(wrongHullKey)).Message); + + var wrongVisualKey = await Write("visualkey.cc", cache => { Hull(cache, "mod-hull:mod.skiff"); Visual(cache, "ship:skiff"); }); + Assert.Contains("records must be stored under", Assert.Throws(() => ShipAuthoringStore.Load(wrongVisualKey)).Message); + } + + internal static ShipParts Fixture() { var shape = new Shape(2, 2); shape.Cells[0, 0] = true; shape.Cells[1, 0] = true; - return new ShipAuthoring + return new ShipParts { - Id = "mod.skiff", - ModelAsset = "skiff.glb", Hull = new HullData { Name = "Skiff", Shape = shape, + Visual = new CultRecordRef(ShipModCatalog.AuthoringKey("mod.skiff")), Hardpoints = new List { new HardpointData @@ -94,26 +161,49 @@ public sealed class ShipAuthoringTests } } }, - Anchors = new List + Visual = new ShipAuthoring { - new ShipAnchor { Id = "map", Role = "map-icon", ModelNodeId = "map" }, - new ShipAnchor { Id = "collider", Role = "hull-collider", ModelNodeId = "collider" }, - new ShipAnchor { Id = "shield", Role = "shield", ModelNodeId = "shield" }, - new ShipAnchor { Id = "tractor", Role = "tractor", ModelNodeId = "tractor" }, - new ShipAnchor { Id = "thruster.port", Role = "thruster-emitter", ModelNodeId = "thruster-port" } - }, - SchematicLines = new List - { - new ShipPolyline + Id = "mod.skiff", + ModelAsset = "skiff.glb", + Anchors = new List { - Layer = "Hull", - Material = "White", - Points = new[] { 0f, 0f, 0f, 1f, 0f, 0f }, - Radii = new[] { .01f, .01f }, - Opacities = new[] { 1f, 1f }, - Color = new[] { 1f, 1f, 1f, 1f } + new ShipAnchor { Id = "map", Role = "map-icon", ModelNodeId = "map" }, + new ShipAnchor { Id = "collider", Role = "hull-collider", ModelNodeId = "collider" }, + new ShipAnchor { Id = "shield", Role = "shield", ModelNodeId = "shield" }, + new ShipAnchor { Id = "tractor", Role = "tractor", ModelNodeId = "tractor" }, + new ShipAnchor { Id = "thruster.port", Role = "thruster-emitter", ModelNodeId = "thruster-port" } + }, + SchematicLines = new List + { + new ShipPolyline + { + Layer = "Hull", + Material = "White", + Points = new[] { 0f, 0f, 0f, 1f, 0f, 0f }, + Radii = new[] { .01f, .01f }, + Opacities = new[] { 1f, 1f }, + Color = new[] { 1f, 1f, 1f, 1f } + } } } }; } } + +// One ship's two records: the hull and the visual it names. +internal sealed class ShipParts +{ + public HullData Hull; + public ShipAuthoring Visual; + + public void Deconstruct(out HullData hull, out ShipAuthoring visual) => (hull, visual) = (Hull, Visual); + + public ShipParts WithId(string id) + { + Visual.Id = id; + Hull.Visual = new CultRecordRef(ShipModCatalog.AuthoringKey(id)); + return this; + } + + public void Validate() => ShipAuthoringStore.Validate(Hull, Visual); +} diff --git a/tests/Aetheria.Shared.Tests/ShipFixture.cs b/tests/Aetheria.Shared.Tests/ShipFixture.cs new file mode 100644 index 000000000..eb8945780 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipFixture.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; + +// Writes the mod package the tests, the Python round-trip check and the Unity smoke (ShipModPreview.Smoke) share. It is +// generated from ShipAuthoringTests.Fixture() so no opaque bytes are committed. +internal static class ShipFixture +{ + public static readonly string[] Nodes = { "map", "collider", "shield", "tractor", "thruster-port" }; + + // The GLB carries a glTF scene naming every node: glTFast instantiates a scene, and a scene-less file has none. + public static string GlbJson(string[] nodes) => + @"{""asset"":{""version"":""2.0""},""scene"":0,""scenes"":[{""nodes"":[" + string.Join(",", nodes.Select((_, index) => index)) + @"]}],""nodes"":[" + + string.Join(",", nodes.Select(node => $@"{{""name"":""{node}"",""extras"":{{""aetheria.id"":""{node}""}}}}")) + "]}"; + + public static byte[] Glb(string json) + { + var body = Encoding.UTF8.GetBytes(json); + var padded = new byte[(body.Length + 3) / 4 * 4]; + Array.Fill(padded, (byte)' '); + body.CopyTo(padded, 0); + var bytes = new byte[20 + padded.Length + 8]; + BitConverter.GetBytes(0x46546C67u).CopyTo(bytes, 0); + BitConverter.GetBytes(2u).CopyTo(bytes, 4); + BitConverter.GetBytes((uint)bytes.Length).CopyTo(bytes, 8); + BitConverter.GetBytes((uint)padded.Length).CopyTo(bytes, 12); + BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16); + padded.CopyTo(bytes, 20); + BitConverter.GetBytes(0u).CopyTo(bytes, 20 + padded.Length); + BitConverter.GetBytes(0x004E4942u).CopyTo(bytes, 24 + padded.Length); + return bytes; + } + + // //ship.cc plus the GLB at the record's ModelAsset path. + public static string WritePackage(string modsRoot, string id, string hullName = "Skiff", string[] nodes = null, + string modelAsset = "skiff.glb", Action tweak = null) + { + var ship = ShipAuthoringTests.Fixture(); + tweak?.Invoke(ship); + ship.WithId(id); + ship.Hull.Name = hullName; + ship.Visual.ModelAsset = modelAsset; + var directory = Path.Combine(modsRoot, id); + Directory.CreateDirectory(directory); + using (var cache = ShipAuthoringStore.Open(Path.Combine(directory, "ship.cc"), writable: true)) + { + ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual); + cache.FlushAsync().Wait(); + } + File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(GlbJson(nodes ?? Nodes))); + return directory; + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 029ea81c8..54563348f 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Security.Cryptography; using System.Text; using GameCult.Caching; +using Newtonsoft.Json.Linq; using Xunit; // Compose, ReadPackage and ReadNodeIds: the derived catalog a game would open, and the GLB node ids its anchors bind to. @@ -14,7 +15,7 @@ public sealed class ShipModCatalogTests : IDisposable private string Shipped => Path.Combine(_directory.Path, "Aetheria.cc"); private string Derived => Path.Combine(_directory.Path, "Derived", "Aetheria.modded.cc"); private string Mods => Path.Combine(_directory.Path, "Mods"); - private static readonly string[] FixtureNodes = { "map", "collider", "shield", "tractor", "thruster-port" }; + private static readonly string[] FixtureNodes = ShipFixture.Nodes; public ShipModCatalogTests() { @@ -48,6 +49,60 @@ public sealed class ShipModCatalogTests : IDisposable AssertOnlyTheDerivedFile(); } + // Correction 2 of the S0 map: the derived catalog held each mod hull twice, once as a HullData and once inside the + // ship's authoring record. The hull is now one record, and the visual holds no hull. + [Fact] + public void ComposedCatalogHoldsOneHullPerModShip() + { + WritePackage("mod.skiff", hullName: "Skiffhull"); + ShipModCatalog.Compose(Shipped, Derived, Mods); + + using var cache = AetheriaStores.Open(Derived); + Assert.Equal(1, cache.GetAll().Count(hull => hull.Name == "Skiffhull")); + // The hull's name is stored exactly once in the derived file, so no second copy of the hull hides in another record. + Assert.Equal(1, Occurrences(File.ReadAllBytes(Derived), Encoding.UTF8.GetBytes("Skiffhull"))); + var visual = cache.GetAll().Single(); + Assert.Equal("mod.skiff", visual.Id); + } + + [Fact] + public void ComposeCopiesTheModStoresRecordsUnchangedAndTheHullNamesItsVisualByRef() + { + WritePackage("mod.skiff"); + var (modHull, modVisual) = ShipAuthoringStore.Read(Path.Combine(Mods, "mod.skiff", "ship.cc")); + ShipModCatalog.Compose(Shipped, Derived, Mods); + + using var cache = AetheriaStores.Open(Derived); + var hull = cache.Get(ShipModCatalog.HullKey("mod.skiff")); + var visual = cache.Get(ShipModCatalog.AuthoringKey("mod.skiff")); + Assert.Same(visual, cache.Get(hull.Visual)); + var options = GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.OptionsFor(typeof(ShipAuthoring).Assembly); + Assert.Equal(MessagePack.MessagePackSerializer.Serialize(modHull, options), MessagePack.MessagePackSerializer.Serialize(hull, options)); + Assert.Equal(MessagePack.MessagePackSerializer.Serialize(modVisual, options), MessagePack.MessagePackSerializer.Serialize(visual, options)); + } + + // The package the Unity smoke (ShipModPreview.Smoke) loads. Set AETHERIA_SHIP_FIXTURE_DIR to have this run leave it + // there: `-shipModPath $AETHERIA_SHIP_FIXTURE_DIR/mod.skiff/ship.cc`. + [Fact] + public void TheFixturePackageIsCompleteAndItsGlbCarriesAScene() + { + var directory = ShipFixture.WritePackage(Mods, "mod.skiff"); + + var package = ShipModCatalog.ReadPackage(Path.Combine(directory, "ship.cc")); + Assert.Equal(ShipFixture.Nodes.Length, package.NodeIndices.Count); + var glb = File.ReadAllBytes(package.ModelPath); + var json = JObject.Parse(Encoding.UTF8.GetString(glb, 20, (int)BitConverter.ToUInt32(glb, 12))); + Assert.Equal(0, (int)json["scene"]); + Assert.Equal(Enumerable.Range(0, ShipFixture.Nodes.Length), json["scenes"][0]["nodes"].Select(node => (int)node)); + Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods)); + + var export = Environment.GetEnvironmentVariable("AETHERIA_SHIP_FIXTURE_DIR"); + if (string.IsNullOrEmpty(export)) return; + var target = Path.Combine(export, "mod.skiff"); + Directory.CreateDirectory(target); + foreach (var name in new[] { "ship.cc", "skiff.glb" }) File.Copy(Path.Combine(directory, name), Path.Combine(target, name), true); + } + [Fact] public void ComposeCountsEveryPackageAndReplacesAnExistingDerivedCatalog() { @@ -92,8 +147,7 @@ public sealed class ShipModCatalogTests : IDisposable { if (authoring) { - var stranger = ShipAuthoringTests.Fixture(); - stranger.Id = "stranger"; + var stranger = ShipAuthoringTests.Fixture().WithId("stranger").Visual; cache.UpsertAsync(typeof(ShipAuthoring), stranger, new CultRecordKey(key)).GetAwaiter().GetResult(); } else @@ -143,7 +197,7 @@ public sealed class ShipModCatalogTests : IDisposable { using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) { - cache.UpsertAsync(typeof(ShipAuthoring), ShipAuthoringTests.Fixture(), new CultRecordKey("shipped:skiff")).GetAwaiter().GetResult(); + cache.UpsertAsync(typeof(ShipAuthoring), ShipAuthoringTests.Fixture().Visual, new CultRecordKey("shipped:skiff")).GetAwaiter().GetResult(); cache.FlushAsync().Wait(); } WritePackage("mod.skiff", hullName: "Different"); @@ -177,7 +231,7 @@ public sealed class ShipModCatalogTests : IDisposable { WritePackage("mod.skiff"); var package = ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc")); - Assert.Equal("mod.skiff", package.Ship.Id); + Assert.Equal("mod.skiff", package.Visual.Id); Assert.Equal(Path.GetFullPath(Path.Combine(Mods, "mod.skiff", "skiff.glb")), package.ModelPath); Assert.Equal(new Dictionary { ["map"] = 0, ["collider"] = 1, ["shield"] = 2, ["tractor"] = 3, ["thruster-port"] = 4 }, package.NodeIndices); } @@ -224,24 +278,24 @@ public sealed class ShipModCatalogTests : IDisposable [Fact] public void NodeIdsMapToTheirGlbNodeIndexSkippingNodesWithoutOne() { - var path = Write("nodes.glb", Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[ + var path = Write("nodes.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[ {""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""extras"":{""aetheria.id"":""b""}}]}")); Assert.Equal(new Dictionary { ["a"] = 1, ["b"] = 4 }, ShipModCatalog.ReadNodeIds(path)); } [Fact] public void AGlbWithoutNodesHasNoIds() => - Assert.Empty(ShipModCatalog.ReadNodeIds(Write("empty.glb", Glb(@"{""asset"":{""version"":""2.0""}}")))); + Assert.Empty(ShipModCatalog.ReadNodeIds(Write("empty.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""}}")))); [Fact] public void ADuplicateNodeIdIsRefused() => Assert.Contains("duplicate GLB node aetheria.id=a", Assert.Throws(() => - ShipModCatalog.ReadNodeIds(Write("dup.glb", Glb(@"{""nodes"":[{""extras"":{""aetheria.id"":""a""}},{""extras"":{""aetheria.id"":""a""}}]}")))).Message); + ShipModCatalog.ReadNodeIds(Write("dup.glb", ShipFixture.Glb(@"{""nodes"":[{""extras"":{""aetheria.id"":""a""}},{""extras"":{""aetheria.id"":""a""}}]}")))).Message); [Fact] public void AMalformedGlbHeaderIsRefused() { - var good = Glb(@"{""nodes"":[]}"); + var good = ShipFixture.Glb(@"{""nodes"":[]}"); void Refuse(string name, byte[] bytes, string message) => Assert.Contains(message, Assert.Throws(() => ShipModCatalog.ReadNodeIds(Write(name, bytes))).Message); @@ -281,24 +335,6 @@ public sealed class ShipModCatalogTests : IDisposable return copy; } - private static byte[] Glb(string json) - { - var body = Encoding.UTF8.GetBytes(json); - var padded = new byte[(body.Length + 3) / 4 * 4]; - Array.Fill(padded, (byte)' '); - body.CopyTo(padded, 0); - var bytes = new byte[20 + padded.Length + 8]; - BitConverter.GetBytes(0x46546C67u).CopyTo(bytes, 0); - BitConverter.GetBytes(2u).CopyTo(bytes, 4); - BitConverter.GetBytes((uint)bytes.Length).CopyTo(bytes, 8); - BitConverter.GetBytes((uint)padded.Length).CopyTo(bytes, 12); - BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16); - padded.CopyTo(bytes, 20); - BitConverter.GetBytes(0u).CopyTo(bytes, 20 + padded.Length); - BitConverter.GetBytes(0x004E4942u).CopyTo(bytes, 24 + padded.Length); - return bytes; - } - private string Write(string name, byte[] bytes) { var path = Path.Combine(_directory.Path, name); @@ -307,23 +343,14 @@ public sealed class ShipModCatalogTests : IDisposable } private void WritePackage(string id, string hullName = "Skiff", string[] nodes = null, string modelAsset = "skiff.glb", - Action tweak = null) + Action tweak = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak); + + private static int Occurrences(byte[] haystack, byte[] needle) { - var ship = ShipAuthoringTests.Fixture(); - tweak?.Invoke(ship); - ship.Id = id; - ship.Hull.Name = hullName; - ship.ModelAsset = modelAsset; - var directory = Path.Combine(Mods, id); - Directory.CreateDirectory(directory); - using (var cache = ShipAuthoringStore.Open(Path.Combine(directory, "ship.cc"), writable: true)) - { - cache.Upsert(ship); - cache.FlushAsync().Wait(); - } - var json = @"{""asset"":{""version"":""2.0""},""nodes"":[" + - string.Join(",", (nodes ?? FixtureNodes).Select(node => $@"{{""extras"":{{""aetheria.id"":""{node}""}}}}")) + "]}"; - File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(json)); + var count = 0; + for (var start = 0; start <= haystack.Length - needle.Length; start++) + if (haystack.AsSpan(start, needle.Length).SequenceEqual(needle)) count++; + return count; } private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index b892c1908..b5dc61ffe 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -4,12 +4,13 @@ using System.IO; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; +using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; using Xunit; -// tools/blender/aetheria_ships/ship_cc.py edits a ShipAuthoring record by MessagePack slot number and by hardpoint -// member position. These tests bind that file to the C# types that own the numbers: any renumbered, renamed or +// tools/blender/aetheria_ships/ship_cc.py edits a ship file's HullData and ShipAuthoring records by MessagePack slot +// number and by hardpoint member position. These tests bind that file to the C# types that own the numbers: any renumbered, renamed or // reordered member, enum value, or nested array shape fails here instead of corrupting a ship the next time Blender saves. public sealed class ShipSchemaPinTests { @@ -17,7 +18,6 @@ public sealed class ShipSchemaPinTests [Theory] [InlineData("SCHEMATIC_LINES_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.SchematicLines))] - [InlineData("HULL_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.Hull))] [InlineData("HULL_SHAPE_SLOT", typeof(HullData), nameof(HullData.Shape))] [InlineData("HULL_HARDPOINTS_SLOT", typeof(HullData), nameof(HullData.Hardpoints))] public void PythonSlotConstantsAreTheKeysOfTheMembersTheyNameInTheirComments(string constant, Type owner, string member) @@ -26,6 +26,16 @@ public sealed class ShipSchemaPinTests Assert.Matches(new Regex($@"^{constant} = \d+ # \w+\.{member}\r?$", RegexOptions.Multiline), Source); } + [Fact] + public void PythonSchemaNamesAreTheDocumentsTheyNameAndTheRetiredSlotIsNotReused() + { + Assert.Equal(typeof(ShipAuthoring).GetCustomAttribute().SchemaName, Text("SCHEMA")); + Assert.Equal(typeof(HullData).GetCustomAttribute().SchemaName, Text("HULL_SCHEMA")); + // 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), + member => member.GetCustomAttribute()?.IntKey == 1); + } + [Fact] public void PythonHardpointMembersAreHardpointDataMembersAtTheirOwnPositions() { @@ -45,18 +55,23 @@ public sealed class ShipSchemaPinTests [Fact] public void SerializedShipHasTheNestedArrayShapesPythonIndexes() { - var ship = ShipAuthoringTests.Fixture(); - var bytes = MessagePackSerializer.Serialize(ship, CultDocumentMessagePackSerialization.OptionsFor(typeof(ShipAuthoring).Assembly)); - var body = Assert.IsType(MessagePackSerializer.Deserialize(bytes, MessagePackSerializerOptions.Standard)); + var (hull, visual) = ShipAuthoringTests.Fixture(); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ShipAuthoring).Assembly); + object[] Body(object document) => Assert.IsType(MessagePackSerializer.Deserialize( + MessagePackSerializer.Serialize(document.GetType(), document, options), MessagePackSerializerOptions.Standard)); + var body = Body(visual); Assert.Equal("mod.skiff", body[0]); - var hull = Assert.IsType(body[Integer("HULL_SLOT")]); + var lines = Assert.IsType(body[Integer("SCHEMATIC_LINES_SLOT")]); + Assert.Equal("Hull", Assert.IsType(Assert.Single(lines))[0]); + + var hullBody = Body(hull); // A schematic is [[width, height, cells]] with cells flattened x-major (index x * height + y). - var hullShape = Assert.IsType(Assert.IsType(hull[Integer("HULL_SHAPE_SLOT")])[0]); + var hullShape = Assert.IsType(Assert.IsType(hullBody[Integer("HULL_SHAPE_SLOT")])[0]); Assert.Equal(new object[] { 2, 2 }, hullShape.Take(2).Select(value => (object)Convert.ToInt32(value))); Assert.Equal(new[] { true, false, true, false }, Assert.IsType(hullShape[2]).Cast()); - var hardpoints = Assert.IsType(hull[Integer("HULL_HARDPOINTS_SLOT")]); + var hardpoints = Assert.IsType(hullBody[Integer("HULL_HARDPOINTS_SLOT")]); var row = Assert.IsType(Assert.Single(hardpoints)); var members = Strings("HARDPOINT_MEMBERS"); Assert.True(row.Length >= members.Length); @@ -65,9 +80,6 @@ public sealed class ShipSchemaPinTests Assert.Equal(3, footprint.Length); Assert.Equal(new[] { true }, Assert.IsType(footprint[2]).Cast()); Assert.Equal(new[] { 1, 0 }, Assert.IsType(row[Array.IndexOf(members, "Position")]).Select(value => Convert.ToInt32(value))); - - var lines = Assert.IsType(body[Integer("SCHEMATIC_LINES_SLOT")]); - Assert.Equal("Hull", Assert.IsType(Assert.Single(lines))[0]); } private static int KeyOf(Type owner, string member) @@ -79,6 +91,13 @@ public sealed class ShipSchemaPinTests private static int Integer(string constant) => int.Parse(Regex.Match(Source, $@"^{constant} = (\d+)", RegexOptions.Multiline).Groups[1].Value); + private static string Text(string constant) + { + var match = Regex.Match(Source, $@"^{constant} = ""([^""]*)""", RegexOptions.Multiline); + Assert.True(match.Success, $"{constant} is not a string in ship_cc.py"); + return match.Groups[1].Value; + } + private static string[] Strings(string constant) { var block = Regex.Match(Source, $@"^{constant} = \((.*?)\)\r?$", RegexOptions.Multiline | RegexOptions.Singleline); diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index 17d54ad4b..e7f3815af 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -11,25 +11,25 @@ using Xunit; // reason) fails its own case and no other. public sealed class ShipValidationTests { - private static readonly Dictionary Break, string Message)> Cases = Build(); + private static readonly Dictionary Break, string Message)> Cases = Build(); public static IEnumerable CaseNames => Cases.Keys.Select(name => new object[] { name }); [Fact] - public void TheFixtureIsValid() => ShipAuthoringStore.Validate(ShipAuthoringTests.Fixture()); + public void TheFixtureIsValid() => ShipAuthoringTests.Fixture().Validate(); [Theory, MemberData(nameof(CaseNames))] public void ValidatorRefusesEachBreakageByItsOwnGuard(string name) { var ship = ShipAuthoringTests.Fixture(); Cases[name].Break(ship); - var error = Assert.Throws(() => ShipAuthoringStore.Validate(ship)); + var error = Assert.Throws(ship.Validate); Assert.Contains(Cases[name].Message, error.Message); } [Fact] public void ValidatorRefusesANullRecord() => - Assert.Contains("is null", Assert.Throws(() => ShipAuthoringStore.Validate(null)).Message); + Assert.Contains("is null", Assert.Throws(() => ShipAuthoringStore.Validate(ShipAuthoringTests.Fixture().Hull, null)).Message); [Theory] [InlineData("a"), InlineData("z"), InlineData("0"), InlineData("9"), InlineData("a0z9"), InlineData("0a"), InlineData("9z"), @@ -37,8 +37,7 @@ public sealed class ShipValidationTests public void IdsMayUseLowerCaseAsciiDigitsDotsUnderscoresAndHyphens(string id) { var ship = ShipAuthoringTests.Fixture(); - ship.Id = id; - ShipAuthoringStore.Validate(ship); + ship.WithId(id).Validate(); } [Theory] @@ -47,8 +46,8 @@ public sealed class ShipValidationTests public void IdsOutsideThatAlphabetAreRefused(string id) { var ship = ShipAuthoringTests.Fixture(); - ship.Id = id; - Assert.Contains("ship ID must use", Assert.Throws(() => ShipAuthoringStore.Validate(ship)).Message); + ship.WithId(id); + Assert.Contains("ship ID must use", Assert.Throws(ship.Validate).Message); } [Theory] @@ -56,8 +55,8 @@ public sealed class ShipValidationTests public void ModelAssetsMayLiveInsideTheirPackage(string asset) { var ship = ShipAuthoringTests.Fixture(); - ship.ModelAsset = asset; - ShipAuthoringStore.Validate(ship); + ship.Visual.ModelAsset = asset; + ship.Validate(); } [Theory] @@ -65,35 +64,35 @@ public sealed class ShipValidationTests public void ModelAssetsCannotEscapeTheirPackage(string asset) { var ship = ShipAuthoringTests.Fixture(); - ship.ModelAsset = asset; - Assert.Contains("relative package path", Assert.Throws(() => ShipAuthoringStore.Validate(ship)).Message); + ship.Visual.ModelAsset = asset; + Assert.Contains("relative package path", Assert.Throws(ship.Validate).Message); } [Fact] public void RootedModelAssetsAreRefused() { var ship = ShipAuthoringTests.Fixture(); - ship.ModelAsset = Path.GetFullPath("skiff.glb"); - Assert.Contains("relative package path", Assert.Throws(() => ShipAuthoringStore.Validate(ship)).Message); + ship.Visual.ModelAsset = Path.GetFullPath("skiff.glb"); + Assert.Contains("relative package path", Assert.Throws(ship.Validate).Message); } [Fact] public void OptionalPartsAndRangeEdgesAreAccepted() { var ship = ShipAuthoringTests.Fixture(); - var line = ship.SchematicLines[0]; + var line = ship.Visual.SchematicLines[0]; line.Points = new[] { 0f, 0f, 0f, 1f, 0f, 0f, 2f, 0f, 0f }; line.Radii = new[] { 0f, 5f, 0f }; line.Opacities = new[] { 0f, 1f, .5f }; - ShipAuthoringStore.Validate(ship); + ship.Validate(); line.Radii = null; line.Opacities = null; line.Color = null; - ShipAuthoringStore.Validate(ship); - ship.SchematicLines = null; + ship.Validate(); + ship.Visual.SchematicLines = null; ship.Hull.Hardpoints = null; - ship.Anchors.RemoveAt(ship.Anchors.Count - 1); - ShipAuthoringStore.Validate(ship); + ship.Visual.Anchors.RemoveAt(ship.Visual.Anchors.Count - 1); + ship.Validate(); } [Fact] @@ -102,39 +101,23 @@ public sealed class ShipValidationTests var ship = ShipAuthoringTests.Fixture(); ship.Hull.Hardpoints[0].Shape = new Shape(2, 1); ship.Hull.Hardpoints[0].Shape.Cells[0, 0] = true; - ShipAuthoringStore.Validate(ship); + ship.Validate(); } [Fact] public void AnEmptyParentIdMeansNoParent() { var ship = ShipAuthoringTests.Fixture(); - ship.Anchors[0].ParentId = ""; - ShipAuthoringStore.Validate(ship); + ship.Visual.Anchors[0].ParentId = ""; + ship.Validate(); } [Fact] public void AnAnchorMayNameItsHardpointAsParent() { var ship = ShipAuthoringTests.Fixture(); - ship.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" }); - ShipAuthoringStore.Validate(ship); - } - - [Fact] - public async Task ReadRefusesAFileThatHoldsTwoShips() - { - using var directory = new TempDirectory(); - var path = Path.Combine(directory.Path, "two.cc"); - using (var cache = ShipAuthoringStore.Open(path, writable: true)) - { - cache.Upsert(ShipAuthoringTests.Fixture()); - var other = ShipAuthoringTests.Fixture(); - other.Id = "mod.other"; - cache.Upsert(other); - await cache.FlushAsync(); - } - Assert.Contains("found 2", Assert.Throws(() => ShipAuthoringStore.Read(path)).Message); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" }); + ship.Validate(); } [Fact] @@ -146,20 +129,20 @@ public sealed class ShipValidationTests ship.Hull.Name = ""; using (var cache = ShipAuthoringStore.Open(path, writable: true)) { - cache.Upsert(ship); + ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual); await cache.FlushAsync(); } Assert.Contains("hull name is required", Assert.Throws(() => ShipAuthoringStore.Read(path)).Message); } - private static Dictionary, string)> Build() + private static Dictionary, string)> Build() { - var cases = new Dictionary, string)>(); - void Add(string name, Action breakIt, string message) => cases.Add(name, (breakIt, message)); - HardpointData Mount(ShipAuthoring ship) => ship.Hull.Hardpoints[0]; + var cases = new Dictionary, string)>(); + void Add(string name, Action breakIt, string message) => cases.Add(name, (breakIt, message)); + HardpointData Mount(ShipParts ship) => ship.Hull.Hardpoints[0]; - Add("id-null", s => s.Id = null, "Ship ID is required"); - Add("id-blank", s => s.Id = " ", "Ship ID is required"); + Add("id-null", s => s.Visual.Id = null, "Ship ID is required"); + Add("id-blank", s => s.Visual.Id = " ", "Ship ID is required"); Add("hull-null", s => s.Hull = null, "hull data is required"); Add("hull-name-null", s => s.Hull.Name = null, "hull name is required"); Add("hull-name-blank", s => s.Hull.Name = " ", "hull name is required"); @@ -169,21 +152,21 @@ public sealed class ShipValidationTests Add("hull-height-zero", s => s.Hull.Shape.Cells = new bool[2, 0], "at least one cell"); Add("hull-no-occupied-cell", s => s.Hull.Shape.Cells = new bool[2, 2], "at least one cell"); Add("hull-names-a-prefab", s => s.Hull.Prefab = "Djinni", "cannot name a Unity prefab"); - Add("model-null", s => s.ModelAsset = null, "relative package path"); - Add("model-blank", s => s.ModelAsset = " ", "relative package path"); + Add("model-null", s => s.Visual.ModelAsset = null, "relative package path"); + Add("model-blank", s => s.Visual.ModelAsset = " ", "relative package path"); - Add("anchors-null", s => s.Anchors = null, "anchors are required"); - Add("anchor-null", s => s.Anchors.Add(null), "anchor IDs must be present and unique"); - Add("anchor-id-null", s => s.Anchors[4].Id = null, "anchor IDs must be present and unique"); - Add("anchor-id-blank", s => s.Anchors.Add(new ShipAnchor { Id = " ", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique"); - Add("anchor-id-duplicate", s => s.Anchors.Add(new ShipAnchor { Id = "map", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique"); - Add("anchor-node-null", s => s.Anchors[4].ModelNodeId = null, "needs a model node ID"); - Add("anchor-node-blank", s => s.Anchors[4].ModelNodeId = " ", "needs a model node ID"); - Add("anchor-node-shared", s => s.Anchors.Add(new ShipAnchor { Id = "other", Role = "articulation", ModelNodeId = "map" }), "claimed by more than one anchor"); + Add("anchors-null", s => s.Visual.Anchors = null, "anchors are required"); + Add("anchor-null", s => s.Visual.Anchors.Add(null), "anchor IDs must be present and unique"); + Add("anchor-id-null", s => s.Visual.Anchors[4].Id = null, "anchor IDs must be present and unique"); + Add("anchor-id-blank", s => s.Visual.Anchors.Add(new ShipAnchor { Id = " ", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique"); + Add("anchor-id-duplicate", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "map", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique"); + Add("anchor-node-null", s => s.Visual.Anchors[4].ModelNodeId = null, "needs a model node ID"); + Add("anchor-node-blank", s => s.Visual.Anchors[4].ModelNodeId = " ", "needs a model node ID"); + Add("anchor-node-shared", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "other", Role = "articulation", ModelNodeId = "map" }), "claimed by more than one anchor"); foreach (var role in new[] { "map-icon", "hull-collider", "shield", "tractor" }) { - Add("role-missing-" + role, s => s.Anchors.RemoveAll(a => a.Role == role), $"exactly one {role} anchor is required"); - Add("role-doubled-" + role, s => s.Anchors.Add(new ShipAnchor { Id = "extra", Role = role, ModelNodeId = "extra" }), $"exactly one {role} anchor is required"); + Add("role-missing-" + role, s => s.Visual.Anchors.RemoveAll(a => a.Role == role), $"exactly one {role} anchor is required"); + Add("role-doubled-" + role, s => s.Visual.Anchors.Add(new ShipAnchor { Id = "extra", Role = role, ModelNodeId = "extra" }), $"exactly one {role} anchor is required"); } Add("hardpoint-null", s => s.Hull.Hardpoints.Add(null), "hardpoint IDs must be present and unique"); @@ -206,32 +189,32 @@ public sealed class ShipValidationTests Add("hardpoints-overlap", s => { s.Hull.Hardpoints.Add(new HardpointData { Position = new int2(1, 0), Transform = "thruster.starboard" }); - s.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" }); + s.Visual.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" }); }, "overlaps another hardpoint"); - Add("hardpoint-without-anchor", s => s.Anchors.RemoveAll(a => a.Id == "thruster.port"), "has no model anchor"); - Add("anchor-parent-unknown", s => s.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "nowhere" }), "unknown hardpoint parent"); + Add("hardpoint-without-anchor", s => s.Visual.Anchors.RemoveAll(a => a.Id == "thruster.port"), "has no model anchor"); + Add("anchor-parent-unknown", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "nowhere" }), "unknown hardpoint parent"); - Add("line-null", s => s.SchematicLines.Add(null), "finite XYZ points"); - Add("line-points-null", s => s.SchematicLines[0].Points = null, "finite XYZ points"); - Add("line-one-point", s => s.SchematicLines[0].Points = new[] { 0f, 0f, 0f }, "finite XYZ points"); - Add("line-points-not-triples", s => s.SchematicLines[0].Points = new float[7], "finite XYZ points"); - Add("line-point-nan", s => s.SchematicLines[0].Points[3] = float.NaN, "finite XYZ points"); - Add("line-point-infinite", s => s.SchematicLines[0].Points[5] = float.PositiveInfinity, "finite XYZ points"); - Add("line-radii-too-short", s => s.SchematicLines[0].Radii = new[] { .1f }, "attributes have the wrong length"); - Add("line-radii-too-long", s => s.SchematicLines[0].Radii = new[] { .1f, .1f, .1f }, "attributes have the wrong length"); - Add("line-opacities-too-short", s => s.SchematicLines[0].Opacities = new[] { 1f }, "attributes have the wrong length"); - Add("line-opacities-too-long", s => s.SchematicLines[0].Opacities = new[] { 1f, 1f, 1f }, "attributes have the wrong length"); - Add("line-radius-nan", s => s.SchematicLines[0].Radii[1] = float.NaN, "radius and opacity must be finite"); - Add("line-radius-infinite", s => s.SchematicLines[0].Radii[1] = float.PositiveInfinity, "radius and opacity must be finite"); - Add("line-radius-negative", s => s.SchematicLines[0].Radii[0] = -.01f, "radius and opacity must be finite"); - Add("line-opacity-nan", s => s.SchematicLines[0].Opacities[1] = float.NaN, "radius and opacity must be finite"); - Add("line-opacity-infinite", s => s.SchematicLines[0].Opacities[1] = float.PositiveInfinity, "radius and opacity must be finite"); - Add("line-opacity-negative", s => s.SchematicLines[0].Opacities[0] = -.01f, "radius and opacity must be finite"); - Add("line-opacity-above-one", s => s.SchematicLines[0].Opacities[0] = 1.01f, "radius and opacity must be finite"); - Add("line-color-short", s => s.SchematicLines[0].Color = new[] { 1f, 1f, 1f }, "must be finite RGBA"); - Add("line-color-long", s => s.SchematicLines[0].Color = new[] { 1f, 1f, 1f, 1f, 1f }, "must be finite RGBA"); - Add("line-color-nan", s => s.SchematicLines[0].Color[2] = float.NaN, "must be finite RGBA"); - Add("line-color-infinite", s => s.SchematicLines[0].Color[3] = float.NegativeInfinity, "must be finite RGBA"); + Add("line-null", s => s.Visual.SchematicLines.Add(null), "finite XYZ points"); + Add("line-points-null", s => s.Visual.SchematicLines[0].Points = null, "finite XYZ points"); + Add("line-one-point", s => s.Visual.SchematicLines[0].Points = new[] { 0f, 0f, 0f }, "finite XYZ points"); + Add("line-points-not-triples", s => s.Visual.SchematicLines[0].Points = new float[7], "finite XYZ points"); + Add("line-point-nan", s => s.Visual.SchematicLines[0].Points[3] = float.NaN, "finite XYZ points"); + Add("line-point-infinite", s => s.Visual.SchematicLines[0].Points[5] = float.PositiveInfinity, "finite XYZ points"); + Add("line-radii-too-short", s => s.Visual.SchematicLines[0].Radii = new[] { .1f }, "attributes have the wrong length"); + Add("line-radii-too-long", s => s.Visual.SchematicLines[0].Radii = new[] { .1f, .1f, .1f }, "attributes have the wrong length"); + Add("line-opacities-too-short", s => s.Visual.SchematicLines[0].Opacities = new[] { 1f }, "attributes have the wrong length"); + Add("line-opacities-too-long", s => s.Visual.SchematicLines[0].Opacities = new[] { 1f, 1f, 1f }, "attributes have the wrong length"); + Add("line-radius-nan", s => s.Visual.SchematicLines[0].Radii[1] = float.NaN, "radius and opacity must be finite"); + Add("line-radius-infinite", s => s.Visual.SchematicLines[0].Radii[1] = float.PositiveInfinity, "radius and opacity must be finite"); + Add("line-radius-negative", s => s.Visual.SchematicLines[0].Radii[0] = -.01f, "radius and opacity must be finite"); + Add("line-opacity-nan", s => s.Visual.SchematicLines[0].Opacities[1] = float.NaN, "radius and opacity must be finite"); + Add("line-opacity-infinite", s => s.Visual.SchematicLines[0].Opacities[1] = float.PositiveInfinity, "radius and opacity must be finite"); + Add("line-opacity-negative", s => s.Visual.SchematicLines[0].Opacities[0] = -.01f, "radius and opacity must be finite"); + Add("line-opacity-above-one", s => s.Visual.SchematicLines[0].Opacities[0] = 1.01f, "radius and opacity must be finite"); + Add("line-color-short", s => s.Visual.SchematicLines[0].Color = new[] { 1f, 1f, 1f }, "must be finite RGBA"); + Add("line-color-long", s => s.Visual.SchematicLines[0].Color = new[] { 1f, 1f, 1f, 1f, 1f }, "must be finite RGBA"); + Add("line-color-nan", s => s.Visual.SchematicLines[0].Color[2] = float.NaN, "must be finite RGBA"); + Add("line-color-infinite", s => s.Visual.SchematicLines[0].Color[3] = float.NegativeInfinity, "must be finite RGBA"); return cases; } } diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index 061f58e41..e6b380ed3 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -1,3 +1,4 @@ +using GameCult.Caching; using System; using System.Collections.Generic; using System.IO; @@ -23,17 +24,14 @@ public static class ShipAuthoringCommands } if (args[0] == "inspect" && args.Length == 2) { - using var cache = ShipAuthoringStore.Open(args[1]); - var records = cache.GetAll().ToArray(); - if (records.Length != 1) throw new InvalidOperationException($"Expected one ship, found {records.Length}."); - var ship = records[0]; - Console.WriteLine($"{ship.Id}: {ship.Hull?.Name}, {ship.Hull?.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points"); + var (hull, ship) = ShipAuthoringStore.Load(args[1]); + Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points"); return 0; } if (args[0] == "validate" && args.Length == 2) { - var ship = ShipAuthoringStore.Read(args[1]); - Console.WriteLine($"{ship.Id}: {ship.Hull.Name}, {ship.Hull.Hardpoints.Count} hardpoints, {ship.SchematicLines.Count} lines"); + var (hull, ship) = ShipAuthoringStore.Read(args[1]); + Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints.Count} hardpoints, {ship.SchematicLines.Count} lines"); return 0; } if (args[0] == "create" && args.Length == 4) @@ -41,16 +39,16 @@ public static class ShipAuthoringCommands var path = Path.GetFullPath(args[1]); if (File.Exists(path)) throw new InvalidOperationException($"Refusing to replace {path}"); Directory.CreateDirectory(Path.GetDirectoryName(path)); - var ship = new ShipAuthoring + var ship = new ShipAuthoring { Id = args[2], ModelAsset = "ship.glb", Anchors = new List() }; + var hull = new HullData { - Id = args[2], - Hull = new HullData { Name = args[3], Shape = new Shape() }, - ModelAsset = "ship.glb", - Anchors = new List() + Name = args[3], + Shape = new Shape(), + Visual = new CultRecordRef(ShipModCatalog.AuthoringKey(ship.Id)) }; using (var cache = ShipAuthoringStore.Open(path, writable: true)) { - cache.Upsert(ship); + ShipAuthoringStore.Write(cache, hull, ship); cache.FlushAsync().Wait(); } Console.WriteLine($"Created draft {path}; fill its model bindings and schematic in Blender."); diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index e84c4332d..1c21ded35 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -219,15 +219,15 @@ class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): else: raise ValueError("Select an object in exactly one ship collection") path = bpy.path.abspath(context.scene.aetheria_ship_cc_path) - _, _, body, _ = read(path, _brokkr_cultlib(context)) + ship_id = read(path, _brokkr_cultlib(context)).ship.body[0] if collection.get("aetheria.asset_kind") not in (None, "ship"): raise ValueError(f"{collection.name} has another aetheria.asset_kind") - if collection.get("aetheria.id") not in (None, body[0]): + if collection.get("aetheria.id") not in (None, ship_id): raise ValueError(f"{collection.name} is bound to another ship ID") collection["aetheria.asset_kind"] = "ship" - collection["aetheria.id"] = body[0] + collection["aetheria.id"] = ship_id collection["aetheria.ship_cc"] = bpy.path.relpath(path) - self.report({"INFO"}, f"Bound {collection.name} to {body[0]}") + self.report({"INFO"}, f"Bound {collection.name} to {ship_id}") return {"FINISHED"} except (OSError, ValueError, RuntimeError, ImportError) as exc: self.report({"ERROR"}, str(exc)) @@ -248,8 +248,7 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): raise ValueError(f"{collection.name} has no bound ship .cc path") path = bpy.path.abspath(collection["aetheria.ship_cc"]) cultlib = _brokkr_cultlib(context) - _, _, body, _ = read(path, cultlib) - if body[0] != collection["aetheria.id"]: + if read(path, cultlib).ship.body[0] != collection["aetheria.id"]: raise ValueError("The bound collection ID does not match its .cc ship ID") lines = capture_grease_pencil( obj, context.evaluated_depsgraph_get(), context.scene.frame_current, diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 54064b598..08a5b9e28 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -1,8 +1,8 @@ -"""Aetheria's Blender-side view of one typed ShipAuthoring CultCache record. +"""Aetheria's Blender-side view of one ship file: a typed HullData record and the ShipAuthoring visual it names. -The schema and authority live in Aetheria's C# ShipAuthoring type. This module -preserves the C# schema catalog and all slots it does not edit. Brokkr supplies -the Blender host and CultLib Python dependency path. +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 +supplies the Blender host and CultLib Python dependency path. """ from __future__ import annotations @@ -12,15 +12,15 @@ import hashlib from dataclasses import replace from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, NamedTuple SCHEMA = "aetheria.ship_authoring" +HULL_SCHEMA = "aetheria.hulldata" # MessagePack slots of the C# types, which own them (ShipAuthoring, ItemData.Shape, HullData.Hardpoints, # HardpointData). ShipSchemaPinTests in tests/Aetheria.Shared.Tests fails when any number or name below # disagrees with those types' [Key] attributes, so a renumbered or added member cannot drift silently. SCHEMATIC_LINES_SLOT = 4 # ShipAuthoring.SchematicLines -HULL_SLOT = 1 # ShipAuthoring.Hull HULL_SHAPE_SLOT = 5 # ItemData.Shape HULL_HARDPOINTS_SLOT = 23 # HullData.Hardpoints # HardpointData member names in slot order; a hardpoint row is one value per name, and any slot past the last @@ -51,40 +51,65 @@ def _libraries(cultlib_packages: str): return cultcache_py, msgpack -def read(path: str, cultlib_packages: str): +class Record(NamedTuple): + envelope: Any + body: list + + +class ShipFile(NamedTuple): + store: Any + ship: Record # the ShipAuthoring visual + hull: Record # the HullData it belongs to + msgpack: Any + + +def read(path: str, cultlib_packages: str) -> ShipFile: cultcache, msgpack = _libraries(cultlib_packages) store = cultcache.SingleFileMessagePackBackingStore(path) envelopes = store.pull_all() ships = [envelope for envelope in envelopes if envelope.type == SCHEMA] - if len(envelopes) != 1 or len(ships) != 1: - raise ValueError(f"{path} must hold exactly one {SCHEMA} record") - envelope = ships[0] - body = msgpack.unpackb(envelope.payload, raw=False) - if not isinstance(body, list) or len(body) < SCHEMATIC_LINES_SLOT + 1: + hulls = [envelope for envelope in envelopes if envelope.type == HULL_SCHEMA] + if len(envelopes) != 2 or len(ships) != 1 or len(hulls) != 1: + raise ValueError(f"{path} must hold exactly one {SCHEMA} record and one {HULL_SCHEMA} record") + bodies = [] + for envelope in (ships[0], hulls[0]): + body = msgpack.unpackb(envelope.payload, raw=False) + if not isinstance(body, list): + raise ValueError(f"{path} has an incompatible {envelope.type} payload") + bodies.append(Record(envelope, body)) + ship, hull = bodies + if len(ship.body) < SCHEMATIC_LINES_SLOT + 1: raise ValueError(f"{path} has an incompatible {SCHEMA} payload") - return store, envelope, body, msgpack + return ShipFile(store, ship, hull, msgpack) + + +def _stamped(record: Record, msgpack: Any): + return replace( + record.envelope, + payload=msgpack.packb(record.body, use_bin_type=True), + stored_at=datetime.now(timezone.utc).isoformat(), + ) def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> int: - store, envelope, body, msgpack = read(path, cultlib_packages) + file = read(path, cultlib_packages) if not lines: raise ValueError("No Grease Pencil strokes were captured; the ship record was not changed") - body[SCHEMATIC_LINES_SLOT] = lines - updated = replace( - envelope, - payload=msgpack.packb(body, use_bin_type=True), - stored_at=datetime.now(timezone.utc).isoformat(), - ) - store.push(updated) + file.ship.body[SCHEMATIC_LINES_SLOT] = lines + file.store.push(_stamped(file.ship, file.msgpack)) return len(lines) -def read_layout(path: str, cultlib_packages: str): - _, _, body, msgpack = read(path, cultlib_packages) - hull = body[HULL_SLOT] - if not isinstance(hull, list) or len(hull) <= HULL_HARDPOINTS_SLOT: +def _hull_body(file: ShipFile) -> list: + if len(file.hull.body) <= HULL_HARDPOINTS_SLOT: raise ValueError("Ship hull has an incompatible typed payload") - return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], _layout_revision(hull, msgpack) + return file.hull.body + + +def read_layout(path: str, cultlib_packages: str): + file = read(path, cultlib_packages) + hull = _hull_body(file) + return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], _layout_revision(hull, file.msgpack) def _layout_revision(hull, msgpack): @@ -95,11 +120,11 @@ def _layout_revision(hull, msgpack): def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_revision: str, shape: list[Any], hardpoints: list[list[Any]]) -> str: - store, envelope, body, msgpack = read(path, cultlib_packages) - if body[0] != expected_id: + file = read(path, cultlib_packages) + if file.ship.body[0] != expected_id: raise ValueError("The bound ship ID changed; reload its layout") - hull = body[HULL_SLOT] - if _layout_revision(hull, msgpack) != expected_revision: + hull = _hull_body(file) + if _layout_revision(hull, file.msgpack) != expected_revision: raise ValueError("The .cc layout changed since Load; reload before saving") width, height, cells = shape if not (1 <= width <= 32 and 1 <= height <= 32 and len(cells) == width * height): @@ -122,12 +147,8 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_ updated_hardpoints.append(hardpoint + previous.get(mount, [])) hull[HULL_SHAPE_SLOT] = [shape] hull[HULL_HARDPOINTS_SLOT] = updated_hardpoints - store.push(replace( - envelope, - payload=msgpack.packb(body, use_bin_type=True), - stored_at=datetime.now(timezone.utc).isoformat(), - )) - return _layout_revision(hull, msgpack) + file.store.push(_stamped(file.hull, file.msgpack)) + return _layout_revision(hull, file.msgpack) def capture_grease_pencil(obj: Any, depsgraph: Any, frame_number: int, *, evaluated: bool = True) -> list[list[Any]]: diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 2e6715822..63cc0ba64 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -1,4 +1,4 @@ -"""Blender-free tests for aetheria_ships/ship_cc.py, the Python view of a typed ShipAuthoring record. +"""Blender-free tests for aetheria_ships/ship_cc.py, the Python view of a ship file's HullData and ShipAuthoring records. Run with CultLib's cultcache-py and msgpack importable: @@ -10,6 +10,7 @@ ship_cc's own slot constants; ShipSchemaPinTests (C#) pins those constants to th import importlib.util import os +import shutil import sys import tempfile import unittest @@ -40,7 +41,7 @@ def hardpoint_row(mount="thruster.port", tail=("future-hardpoint-slot",)): return row + list(tail) -def hull_slots(): +def hull_body(): hull = [None] * (ship_cc.HULL_HARDPOINTS_SLOT + 1) hull[1] = "Skiff" hull[ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, False, True, False])] @@ -55,18 +56,28 @@ LINES = [["Hull", "White", [0.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0.01, 0.01], [1.0, 1 def ship_body(): body = [None] * (ship_cc.SCHEMATIC_LINES_SLOT + 1) body[0] = "mod.skiff" - body[ship_cc.HULL_SLOT] = hull_slots() body[2] = "skiff.glb" body[3] = [["map", "map-icon", "map", None, 0]] body[ship_cc.SCHEMATIC_LINES_SLOT] = LINES return body -CATALOG = cultcache_py.CultCacheSchemaCatalogEntry( - schema_id="ship-authoring-schema-1", schema_name=ship_cc.SCHEMA, schema_version="1", - content_hash="hash", canonical_schema_json='{"members":[{"slot":9,"name":"FutureMember"}]}', - compatible_schema_ids=("ship-authoring-schema-1",), - members=(cultcache_py.CultCacheSchemaCatalogMember(slot=9, member_name="FutureMember", type_name="string"),)) +def catalog(schema_id, schema_name): + return cultcache_py.CultCacheSchemaCatalogEntry( + schema_id=schema_id, schema_name=schema_name, schema_version="1", + content_hash="hash", canonical_schema_json='{"members":[{"slot":9,"name":"FutureMember"}]}', + compatible_schema_ids=(schema_id,), + members=(cultcache_py.CultCacheSchemaCatalogMember(slot=9, member_name="FutureMember", type_name="string"),)) + + +CATALOG = catalog("ship-authoring-schema-1", ship_cc.SCHEMA) +HULL_CATALOG = catalog("hull-schema-1", ship_cc.HULL_SCHEMA) + + +def envelope(key, schema, entry, body): + return cultcache_py.CultCacheEnvelope.create( + key=key, type=schema, payload=msgpack.packb(body, use_bin_type=True), + schema_id=entry.schema_id, catalog_entry=entry) class ShipFileCase(unittest.TestCase): @@ -74,38 +85,54 @@ class ShipFileCase(unittest.TestCase): directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) self.path = str(Path(directory.name) / "ship.cc") - self.write(ship_body()) + self.write(ship_body(), hull_body()) - def write(self, body, path=None): - cultcache_py.SingleFileMessagePackBackingStore(path or self.path).push(cultcache_py.CultCacheEnvelope.create( - key="mod.skiff", type=ship_cc.SCHEMA, payload=msgpack.packb(body, use_bin_type=True), - schema_id=CATALOG.schema_id, catalog_entry=CATALOG)) + def write(self, ship, hull, path=None): + cultcache_py.SingleFileMessagePackBackingStore(path or self.path).push_all([ + envelope("mod-ship:mod.skiff", ship_cc.SCHEMA, CATALOG, ship), + envelope("mod-hull:mod.skiff", ship_cc.HULL_SCHEMA, HULL_CATALOG, hull)]) - def body(self): - return ship_cc.read(self.path, PACKAGES)[2] + def ship(self): + return ship_cc.read(self.path, PACKAGES).ship.body + + def hull(self): + return ship_cc.read(self.path, PACKAGES).hull.body + + def envelopes(self): + return {(e.type, e.key): e for e in cultcache_py.SingleFileMessagePackBackingStore(self.path).pull_all()} def bytes(self): return Path(self.path).read_bytes() class ReadTests(ShipFileCase): - def test_reads_the_one_ship_record(self): - store, envelope, body, _ = ship_cc.read(self.path, PACKAGES) - self.assertEqual("mod.skiff", body[0]) - self.assertEqual(ship_cc.SCHEMA, envelope.type) + def test_reads_the_ship_and_its_hull(self): + file = ship_cc.read(self.path, PACKAGES) + self.assertEqual("mod.skiff", file.ship.body[0]) + self.assertEqual(ship_cc.SCHEMA, file.ship.envelope.type) + self.assertEqual("Skiff", file.hull.body[1]) + self.assertEqual(ship_cc.HULL_SCHEMA, file.hull.envelope.type) - def test_refuses_a_file_with_another_record_beside_the_ship(self): + 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))) with self.assertRaisesRegex(ValueError, "must hold exactly one"): ship_cc.read(self.path, PACKAGES) - def test_refuses_a_file_whose_only_record_is_not_a_ship(self): - other = str(Path(self.path).with_name("other.cc")) - cultcache_py.SingleFileMessagePackBackingStore(other).push(cultcache_py.CultCacheEnvelope.create( - key="other", type="aetheria.something", payload=msgpack.packb([1], use_bin_type=True))) + def test_refuses_a_file_missing_either_half_of_the_pair(self): + for name, schema, entry, body in (("hull only", ship_cc.HULL_SCHEMA, HULL_CATALOG, hull_body()), + ("ship only", ship_cc.SCHEMA, CATALOG, ship_body())): + other = str(Path(self.path).with_name(name.replace(" ", "-") + ".cc")) + cultcache_py.SingleFileMessagePackBackingStore(other).push(envelope("key", schema, entry, body)) + with self.subTest(name): + with self.assertRaisesRegex(ValueError, "must hold exactly one"): + ship_cc.read(other, PACKAGES) + + def test_refuses_a_file_with_two_hulls(self): + cultcache_py.SingleFileMessagePackBackingStore(self.path).push( + envelope("mod-hull:mod.other", ship_cc.HULL_SCHEMA, HULL_CATALOG, hull_body())) with self.assertRaisesRegex(ValueError, "must hold exactly one"): - ship_cc.read(other, PACKAGES) + ship_cc.read(self.path, PACKAGES) def test_refuses_a_file_with_no_record(self): empty = str(Path(self.path).with_name("empty.cc")) @@ -113,15 +140,19 @@ class ReadTests(ShipFileCase): with self.assertRaisesRegex(ValueError, "must hold exactly one"): ship_cc.read(empty, PACKAGES) - def test_refuses_a_payload_too_short_for_the_line_slot(self): - self.write(["mod.skiff", None]) + def test_refuses_a_ship_payload_too_short_for_the_line_slot(self): + self.write(["mod.skiff", None], hull_body()) with self.assertRaisesRegex(ValueError, "incompatible"): ship_cc.read(self.path, PACKAGES) def test_refuses_a_payload_that_is_not_an_array(self): for payload in ({"Id": "mod.skiff"}, {str(index): index for index in range(9)}, "a string long enough to fill every slot"): - with self.subTest(payload=payload): - self.write(payload) + with self.subTest(ship=payload): + self.write(payload, hull_body()) + with self.assertRaisesRegex(ValueError, "incompatible"): + ship_cc.read(self.path, PACKAGES) + with self.subTest(hull=payload): + self.write(ship_body(), payload) with self.assertRaisesRegex(ValueError, "incompatible"): ship_cc.read(self.path, PACKAGES) @@ -129,17 +160,22 @@ class ReadTests(ShipFileCase): class ReplaceLinesTests(ShipFileCase): def test_replaces_only_the_line_slot(self): new_lines = [["Detail", "Red", [0.0] * 6, [0.1, 0.1], [1.0, 1.0], True, [1.0, 0.0, 0.0, 1.0]]] - before = self.body() + before = self.ship() self.assertEqual(1, ship_cc.replace_lines(self.path, PACKAGES, new_lines)) - after = self.body() + after = self.ship() self.assertEqual(new_lines, after[ship_cc.SCHEMATIC_LINES_SLOT]) for slot in range(len(before)): if slot != ship_cc.SCHEMATIC_LINES_SLOT: self.assertEqual(before[slot], after[slot], f"slot {slot} changed") + def test_leaves_the_hull_record_byte_identical(self): + before = self.envelopes()[(ship_cc.HULL_SCHEMA, "mod-hull:mod.skiff")] + ship_cc.replace_lines(self.path, PACKAGES, LINES) + self.assertEqual(before, self.envelopes()[(ship_cc.HULL_SCHEMA, "mod-hull:mod.skiff")]) + def test_keeps_the_schema_catalog(self): ship_cc.replace_lines(self.path, PACKAGES, LINES) - envelope = cultcache_py.SingleFileMessagePackBackingStore(self.path).pull_all()[0] + envelope = self.envelopes()[(ship_cc.SCHEMA, "mod-ship:mod.skiff")] self.assertEqual(CATALOG.members, envelope.catalog_entry.members) self.assertEqual(CATALOG.canonical_schema_json, envelope.catalog_entry.canonical_schema_json) @@ -168,13 +204,14 @@ class LayoutTests(ShipFileCase): def test_the_revision_follows_the_layout_and_only_the_layout(self): _, _, revision = self.layout() - body = self.body() - body[ship_cc.SCHEMATIC_LINES_SLOT] = [] - body[ship_cc.HULL_SLOT][1] = "Renamed" - self.write(body) + ship = self.ship() + ship[ship_cc.SCHEMATIC_LINES_SLOT] = [] + hull = self.hull() + hull[1] = "Renamed" + self.write(ship, hull) self.assertEqual(revision, self.layout()[2]) - body[ship_cc.HULL_SLOT][ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, True, True, True])] - self.write(body) + hull[ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, True, True, True])] + self.write(ship, hull) self.assertNotEqual(revision, self.layout()[2]) def test_the_revision_follows_the_hardpoints_too(self): @@ -198,15 +235,16 @@ class LayoutTests(ShipFileCase): def test_preserves_every_slot_it_does_not_edit(self): _, _, revision = self.layout() - before = self.body() + ship_before, hull_before = self.ship(), self.hull() + ship_envelope = self.envelopes()[(ship_cc.SCHEMA, "mod-ship:mod.skiff")] self.edit(revision) - after = self.body() - for slot in range(len(before)): - if slot != ship_cc.HULL_SLOT: - self.assertEqual(before[slot], after[slot], f"ship slot {slot} changed") - for slot in range(len(before[ship_cc.HULL_SLOT])): + self.assertEqual(ship_before, self.ship()) + self.assertEqual(ship_envelope, self.envelopes()[(ship_cc.SCHEMA, "mod-ship:mod.skiff")]) + hull_after = self.hull() + self.assertEqual(len(hull_before), len(hull_after)) + for slot in range(len(hull_before)): if slot not in (ship_cc.HULL_SHAPE_SLOT, ship_cc.HULL_HARDPOINTS_SLOT): - self.assertEqual(before[ship_cc.HULL_SLOT][slot], after[ship_cc.HULL_SLOT][slot], f"hull slot {slot} changed") + self.assertEqual(hull_before[slot], hull_after[slot], f"hull slot {slot} changed") def test_carries_a_later_schemas_hardpoint_slots_by_mount_id(self): _, _, revision = self.layout() @@ -225,8 +263,8 @@ class LayoutTests(ShipFileCase): def test_keeps_the_schema_catalog(self): _, _, revision = self.layout() self.edit(revision) - envelope = cultcache_py.SingleFileMessagePackBackingStore(self.path).pull_all()[0] - self.assertEqual(CATALOG.members, envelope.catalog_entry.members) + envelope = self.envelopes()[(ship_cc.HULL_SCHEMA, "mod-hull:mod.skiff")] + self.assertEqual(HULL_CATALOG.members, envelope.catalog_entry.members) def test_refuses_a_ship_id_that_changed_and_writes_nothing(self): _, _, revision = self.layout() @@ -293,14 +331,55 @@ class LayoutTests(ShipFileCase): self.edit(revision, hardpoints=[row]) self.assertEqual(before, self.bytes()) - def test_refuses_a_payload_without_the_hull_hardpoint_slot(self): - body = self.body() - body[ship_cc.HULL_SLOT] = body[ship_cc.HULL_SLOT][:ship_cc.HULL_HARDPOINTS_SLOT] - self.write(body) + def test_refuses_a_hull_payload_without_the_hardpoint_slot(self): + hull = self.hull()[:ship_cc.HULL_HARDPOINTS_SLOT] + self.write(self.ship(), hull) with self.assertRaisesRegex(ValueError, "incompatible typed payload"): self.layout() +@unittest.skipUnless(os.environ.get("SHIP_FIXTURE_DIR"), "SHIP_FIXTURE_DIR names the C#-written fixture (see below)") +class CSharpWrittenFixtureTests(unittest.TestCase): + """Gate 1's Python half: a package C# wrote survives a Python edit with every untouched slot intact. + + The fixture comes from the C# suite (`AETHERIA_SHIP_FIXTURE_DIR= dotnet test ... --filter + TheFixturePackageIsCompleteAndItsGlbCarriesAScene`). Point SHIP_FIXTURE_DIR at that and, optionally, + SHIP_EDITED_DIR at an empty directory to keep the edited package; C# then reopens and validates it with + `dotnet run --project tools/AetherDb -- ship-authoring validate /mod.skiff/ship.cc`. + """ + + def test_a_layout_and_line_edit_preserves_every_slot_it_does_not_own(self): + with tempfile.TemporaryDirectory() as scratch: + path = str(Path(scratch) / "ship.cc") + shutil.copy(Path(os.environ["SHIP_FIXTURE_DIR"]) / "mod.skiff" / "ship.cc", path) + before = ship_cc.read(path, PACKAGES) + shape_triple, hardpoints, revision = ship_cc.read_layout(path, PACKAGES) + width, height, cells = shape_triple + self.assertEqual(64, len(revision)) + cells = list(cells) + cells[-1] = True + ship_cc.replace_layout(path, PACKAGES, before.ship.body[0], revision, [width, height, cells], hardpoints) + ship_cc.replace_lines(path, PACKAGES, before.ship.body[ship_cc.SCHEMATIC_LINES_SLOT]) + after = ship_cc.read(path, PACKAGES) + + self.assertEqual(before.ship.body, after.ship.body) + self.assertEqual(before.ship.envelope.key, after.ship.envelope.key) + self.assertEqual(before.ship.envelope.catalog_entry, after.ship.envelope.catalog_entry) + self.assertEqual(before.hull.envelope.key, after.hull.envelope.key) + self.assertEqual(before.hull.envelope.catalog_entry, after.hull.envelope.catalog_entry) + for slot in range(len(before.hull.body)): + if slot != ship_cc.HULL_SHAPE_SLOT: + self.assertEqual(before.hull.body[slot], after.hull.body[slot], f"hull slot {slot} changed") + self.assertTrue(after.hull.body[ship_cc.HULL_SHAPE_SLOT][0][2][-1]) + + kept = os.environ.get("SHIP_EDITED_DIR") + if kept: + target = Path(kept) / "mod.skiff" + target.mkdir(parents=True, exist_ok=True) + shutil.copy(path, target / "ship.cc") + shutil.copy(Path(os.environ["SHIP_FIXTURE_DIR"]) / "mod.skiff" / "skiff.glb", target / "skiff.glb") + + class HardpointRowTests(unittest.TestCase): def test_a_row_is_one_value_per_member_in_slot_order(self): fields = dict(Type=1, Position=[2, 3], Shape="shape", Transform="mount", Rotation=2, Armor=4.0, FiringArc=90.0) From d1648800ab6fea527803bdc9a829b143f3a11be6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 03:48:47 +0200 Subject: [PATCH 015/113] Ship authoring doc: one hull record, a hull names its visual Owner, shared-path and compose paragraphs describe the S1 shape: a ship file holds a HullData and the ShipAuthoring visual it names through HullData.Visual, and the derived catalog holds one hull per mod ship. Co-Authored-By: Claude Sonnet 5.5 --- docs/moddable-ship-authoring.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 0b6a814ea..9b6a2914e 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -28,8 +28,8 @@ footprints. `tools/blender/aetheria_ships` is a separate Blender add-on in Brokkr's sidebar. It uses Brokkr's configured CultLib Python source and edits the -line, hull shape, and hardpoint slots of an existing typed -`aetheria.ship_authoring` record. To try it, make +line slot of a ship file's `aetheria.ship_authoring` visual record and the +hull shape and hardpoint slots of the `aetheria.hulldata` record beside it. To try it, make `F:\Projects\Brokkr\surfaces\blender` and this repo's `tools\blender` visible to Blender's add-on search path, enable `brokkr_bridge` then `aetheria_ships`, and install `msgpack` into Blender's Python environment. @@ -41,7 +41,7 @@ Collection**. The collection stores `aetheria.asset_kind=ship`, the stable that bound collection and press **Capture Ship Lines**. Capture checks the collection ID against the record before writing; object and collection display names are not IDs. -**Load Ship Layout** opens the same `.cc` record's hull grid and hardpoints in +**Load Ship Layout** opens the same `.cc` file's hull grid and hardpoints in the panel. Toggle occupied grid cells, resize up to 32×32, and edit each hardpoint's type, mount ID, position, footprint, rotation, armor, and firing arc. A footprint uses top-to-bottom rows of `#` (occupied) and `.` (empty), @@ -56,8 +56,9 @@ hull, model, and anchor authoring. ## Objective -A mod author edits one typed ship record in a `.cc` file, including the hull -schematic and every hardpoint. Blender, through Aetheria tooling built on +A mod author edits one ship `.cc` file, including the hull schematic and every +hardpoint. The file holds two typed records: the ship's `HullData` and the +`ShipAuthoring` visual package that hull names through `HullData.Visual`. Blender, through Aetheria tooling built on Brokkr, edits that same record while displaying its visual source. The game assembles an instance from the record and its referenced asset package at runtime. A mod package can be installed without opening the Unity Editor. @@ -76,9 +77,11 @@ is not yet an end-to-end ship loader in the current game. ## Target authority map for the parallel lane -- **Owner:** a versioned `ShipAuthoring` document in a mod-owned `.cc` file - owns the ship's authored hull, schematic, hardpoints, structural and - presentation associations, and asset references. A ship ID is stable across +- **Owner:** a mod-owned `.cc` file holds two records. Its `HullData` owns the + ship's authored hull semantics: schematic, hardpoints, and stats. Its + `ShipAuthoring` document owns the visual package: model path, anchor-to-node + associations, and schematic lines. `HullData.Visual` is the only binding + between them; a hull names exactly one of `Prefab` and `Visual`. A ship ID is stable across display-name changes. - **Inputs:** the authoring record; a Blender visual source identified by the record; compiled mesh, texture, and material artifacts; references to gear @@ -100,10 +103,10 @@ is not yet an end-to-end ship loader in the current game. migrated hull; it does not keep two writable definitions of that hull. `ship-authoring validate`, the catalog composer, and the visual importer call -the same semantic validator; that shared path is true of the C# callers only. +the same `ShipAuthoringStore.Validate(hull, visual)`; that shared path is true of the C# callers only. The Blender side checks grid bounds and footprint shape (`ship_cc.py`) and nothing semantic. Blender edits the hull grid, hardpoints, and -captured lines in the typed source record; `inspect` reads incomplete drafts. +captured lines in the typed source records; `inspect` reads incomplete drafts. Unity's **Aetheria / Preview Mod Ship Package** imports a validated package from disk, resolves stable GLB node IDs, and draws the captured polylines directly as 3D line segments. Point opacity @@ -126,10 +129,9 @@ one-home invariant. `ship-authoring compose` instead derives one runtime catalog the shipped catalog and validated mod packages, preserving all shipped record keys. Each mod's stable `ShipAuthoring.Id` yields separate deterministic keys for its copied `HullData` (`mod-hull:`) and its runtime authoring record -(`mod-ship:`). The authoring record's key 1 is the same `HullData`, so the -derived catalog currently holds each mod hull twice. Gameplay reads only the -`mod-hull` copy; the `mod-ship` copy is dead weight inside a disposable file -until the open question on which record owns the hull is settled. The generated +(`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 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. From f470c1b775d9dec1f6b46719029d7d991f812ea8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 06:18:20 +0200 Subject: [PATCH 016/113] Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently ignored; ship_cc.py refuses the same file on read and therefore on every save. A hull naming both a Unity prefab and a visual is refused by one predicate (ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This replaces the mod-ship-only "cannot name a Unity prefab" check. The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an undefined HardpointType or ItemRotation, and an anchor Role outside the eight the schema documents. Load wraps MessagePack decode failures with the file path. Python replace_layout refuses non-boolean layout and hardpoint-footprint cells. Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like Prefab (Unity editor only; not compiled here). Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/ItemData.cs | 2 +- Assets/Scripts/ServerShared/ShipAuthoring.cs | 72 +++++++++++++- Assets/Scripts/ServerShared/ShipModCatalog.cs | 3 + .../ShipAuthoringTests.cs | 94 ++++++++++++++++++- .../ShipModCatalogTests.cs | 20 ++++ .../ShipSchemaPinTests.cs | 11 +++ .../ShipValidationTests.cs | 27 +++++- tools/blender/aetheria_ships/ship_cc.py | 15 ++- tools/blender/tests/test_ship_cc.py | 82 +++++++++++++++- 9 files changed, 316 insertions(+), 10 deletions(-) diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 10831042b..191c11148 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -532,7 +532,7 @@ public class HullData : EquippableItemData // The hull's visual: a ShipAuthoring record holding its model package, or unset for a Unity-prefab hull. A hull // names exactly one of Prefab and Visual. Keys 30 and 31 are EquippableItemData's, so 32 is the next free key. - [JsonProperty("visual"), Key(32)] + [Inspectable, JsonProperty("visual"), Key(32)] public CultRecordRef Visual; [IgnoreMember] diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index c9ea6c058..ffdc560b3 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; @@ -49,6 +50,12 @@ public sealed class ShipPolyline public static class ShipAuthoringStore { + // ShipAnchor.Role's vocabulary. + private static readonly HashSet Roles = new HashSet(StringComparer.Ordinal) + { + "map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-muzzle", "radiator-mesh", "articulation" + }; + public static CultCache Open(string path, bool writable = false) { var cache = new CultCache(); @@ -74,6 +81,49 @@ public static class ShipAuthoringStore // The structure of a ship file, without judging its content (drafts load): exactly one hull and one visual, each at // its deterministic key, the hull naming that visual by its typed ref. public static (HullData Hull, ShipAuthoring Visual) Load(string path) + { + RefuseLegacyEmbeddedHull(path); + try { return LoadRecords(path); } + catch (MessagePackSerializationException error) + { + throw new InvalidOperationException($"{path}: a record does not decode as its schema: {error.Message}", error); + } + } + + // Key 1 of ShipAuthoring held the whole hull before S1. The type no longer has that member, so a deserialized record + // shows nothing of it, and a file that still carries one would load as a ship silently ignoring its old hull. The + // raw payload is the only place the old shape is visible. + private static void RefuseLegacyEmbeddedHull(string path) + { + if (!File.Exists(path)) return; + var bytes = File.ReadAllBytes(path); + if (bytes.Length == 0) return; + CultPersistedStoreSnapshot snapshot; + try { snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); } + catch (MessagePackSerializationException error) + { + throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error); + } + var schema = typeof(ShipAuthoring).GetCustomAttribute().SchemaName; + foreach (var record in snapshot.Records) + { + if (snapshot.SchemaCatalog.FirstOrDefault(entry => entry.SchemaId == record.SchemaId)?.SchemaName != schema) continue; + var reader = new MessagePackReader(record.Payload); + if (reader.NextMessagePackType != MessagePackType.Array) continue; + var slots = reader.ReadArrayHeader(); + for (var slot = 0; slot < slots; slot++) + { + if (slot == RetiredHullKey && !reader.TryReadNil()) + throw new InvalidOperationException($"{path}: {record.Key}: legacy embedded hull at retired key {RetiredHullKey} of the ship authoring record. " + + $"Migrate it: write the hull as its own hull record at mod-hull: naming the visual, and rewrite the ship authoring record without key {RetiredHullKey}."); + if (slot != RetiredHullKey) reader.Skip(); + } + } + } + + private const int RetiredHullKey = 1; + + private static (HullData Hull, ShipAuthoring Visual) LoadRecords(string path) { using var cache = Open(path); var hulls = cache.GetAll().ToArray(); @@ -96,6 +146,15 @@ public static class ShipAuthoringStore return (hull, visual); } + // A hull names one body: a Unity prefab (shipped hulls) or a ShipAuthoring record (mod ships), never both, so no + // consumer needs a precedence rule. A whitespace-only Prefab is unset. Every path that validates or composes hulls + // calls this. + public static void RequireOneBody(HullData hull, string label) + { + if (!string.IsNullOrWhiteSpace(hull.Prefab) && hull.Visual.IsSet()) + throw new InvalidOperationException($"{label}: a hull names one body, but this one names both a Unity prefab and a visual record."); + } + // The one semantic check every path shares: the hull and its visual, judged together. public static void Validate(HullData hull, ShipAuthoring ship) { @@ -108,9 +167,7 @@ public static class ShipAuthoringStore if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required."); if (hull.Shape?.Cells == null || !hull.Shape.Cells.Cast().Any(occupied => occupied)) throw new InvalidOperationException($"{ship.Id}: schematic must contain at least one cell."); - // A hull names exactly one visual: a Unity prefab (shipped hulls) or a ShipAuthoring record (mod ships). - if (!string.IsNullOrEmpty(hull.Prefab)) - throw new InvalidOperationException($"{ship.Id}: a mod ship cannot name a Unity prefab."); + RequireOneBody(hull, ship.Id); if (!hull.Visual.Key.Equals(ShipModCatalog.AuthoringKey(ship.Id))) throw new InvalidOperationException($"{ship.Id}: the hull must name its visual record {ShipModCatalog.AuthoringKey(ship.Id).Value}."); if (string.IsNullOrWhiteSpace(ship.ModelAsset) || Path.IsPathRooted(ship.ModelAsset) || @@ -129,6 +186,9 @@ public static class ShipAuthoringStore if (!nodeIds.Add(anchor.ModelNodeId)) throw new InvalidOperationException($"{ship.Id}: model node {anchor.ModelNodeId} is claimed by more than one anchor."); } + foreach (var anchor in anchors) + if (!Roles.Contains(anchor.Role)) + throw new InvalidOperationException($"{ship.Id}: anchor {anchor.Id} has unknown role '{anchor.Role}'."); foreach (var role in new[] { "map-icon", "hull-collider", "shield", "tractor" }) if (anchors.Count(anchor => anchor.Role == role) != 1) throw new InvalidOperationException($"{ship.Id}: exactly one {role} anchor is required."); @@ -140,6 +200,12 @@ public static class ShipAuthoringStore if (hardpoint == null || string.IsNullOrWhiteSpace(hardpoint.Transform) || !mounts.Add(hardpoint.Transform)) throw new InvalidOperationException($"{ship.Id}: hardpoint IDs must be present and unique."); + if (!Enum.IsDefined(typeof(HardpointType), hardpoint.Type)) + throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has an unknown type {(int)hardpoint.Type}."); + if (!Enum.IsDefined(typeof(ItemRotation), hardpoint.Rotation)) + throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has an unknown rotation {(int)hardpoint.Rotation}."); + if (!float.IsFinite(hardpoint.Armor) || hardpoint.Armor < 0 || !float.IsFinite(hardpoint.FiringArc) || hardpoint.FiringArc < 0) + throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} armor and firing arc must be finite and not negative."); if (hardpoint.Shape?.Cells == null || !hardpoint.Shape.Cells.Cast().Any(occupied => occupied)) throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has no cells."); for (var cellX = 0; cellX < hardpoint.Shape.Width; cellX++) diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 148a3372e..011faac79 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -70,6 +70,9 @@ public static class ShipModCatalog // Verbatim: the mod store's own two records, under the keys it already holds them at. ShipAuthoringStore.Write(cache, hull, ship); } + // Shipped hulls too: the derived catalog must not hold a hull that names two bodies. + foreach (var hull in cache.GetAll()) + ShipAuthoringStore.RequireOneBody(hull, hull.Name); cache.FlushAsync().GetAwaiter().GetResult(); } if (File.Exists(output)) File.Replace(temporary, output, null); diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 54144ffe7..9e6ec129f 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; +using GameCult.Caching.MessagePack; +using MessagePack; using CultMath; using GameCult.Caching; using Xunit; @@ -83,7 +86,12 @@ public sealed class ShipAuthoringTests { var ship = Fixture(); ship.Hull.Prefab = "Djinni"; - Assert.Contains("cannot name a Unity prefab", Assert.Throws(ship.Validate).Message); + Assert.Contains("names both a Unity prefab and a visual record", Assert.Throws(ship.Validate).Message); + + // A whitespace-only prefab is unset. + ship = Fixture(); + ship.Hull.Prefab = " \t"; + ship.Validate(); ship = Fixture(); ship.Hull.Visual = default; @@ -93,9 +101,93 @@ public sealed class ShipAuthoringTests ship.Hull.Visual = new CultRecordRef(ShipModCatalog.AuthoringKey("mod.other")); Assert.Contains("the hull must name its visual record mod-ship:mod.skiff", Assert.Throws(ship.Validate).Message); + // The key is compared exactly: a differently-cased key is another record. + ship = Fixture(); + ship.Hull.Visual = new CultRecordRef(new CultRecordKey("MOD-SHIP:mod.skiff")); + Assert.Contains("the hull must name its visual record mod-ship:mod.skiff", Assert.Throws(ship.Validate).Message); + Fixture().Validate(); } + // A hull with both a prefab and a visual is refused wherever hulls are validated or composed. + // ShipModCatalogTests.ComposeRefusesAShippedHullThatNamesTwoBodies covers Compose. + [Fact] + public void RequireOneBodyRefusesBothAndAcceptsEitherAlone() + { + var hull = Fixture().Hull; + ShipAuthoringStore.RequireOneBody(hull, "skiff"); + hull.Prefab = "Djinni"; + Assert.Contains("skiff: a hull names one body", Assert.Throws(() => ShipAuthoringStore.RequireOneBody(hull, "skiff")).Message); + hull.Visual = default; + ShipAuthoringStore.RequireOneBody(hull, "skiff"); + } + + // The ship authoring record held the whole hull at key 1 before S1. + [Fact] + public async Task LoadRefusesAShipAuthoringRecordThatStillCarriesTheRetiredEmbeddedHull() + { + using var directory = new TempDirectory(); + var path = Path.Combine(directory.Path, "legacy.cc"); + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual); + await cache.FlushAsync(); + } + RewritePayload(path, "aetheria.ship_authoring", slots => { slots[1] = new object[] { "Skiff" }; }); + foreach (var load in new Action[] { () => ShipAuthoringStore.Load(path), () => ShipAuthoringStore.Read(path) }) + { + var message = Assert.Throws(load).Message; + Assert.Contains("legacy embedded hull at retired key 1", message); + Assert.Contains(path, message); + } + + // An old file has no separate hull record at all; it is named for what it is, not for a missing hull. + var oldShape = Path.Combine(directory.Path, "old.cc"); + using (var cache = ShipAuthoringStore.Open(oldShape, writable: true)) + { + cache.UpsertAsync(typeof(ShipAuthoring), Fixture().Visual, ShipModCatalog.AuthoringKey("mod.skiff")).GetAwaiter().GetResult(); + await cache.FlushAsync(); + } + RewritePayload(oldShape, "aetheria.ship_authoring", slots => { slots[1] = new object[] { "Skiff" }; }); + var oldMessage = Assert.Throws(() => ShipAuthoringStore.Load(oldShape)).Message; + Assert.Contains("legacy embedded hull", oldMessage); + Assert.DoesNotContain("exactly one hull record", oldMessage); + + // A nil in the retired slot is the shape every current file has. + RewritePayload(path, "aetheria.ship_authoring", slots => { slots[1] = null; }); + ShipAuthoringStore.Load(path); + } + + // The layout editor's Python once accepted any msgpack value in a cell; C# then failed with a bare serializer exception. + [Fact] + public async Task LoadNamesTheFileWhoseRecordDoesNotDecode() + { + using var directory = new TempDirectory(); + var path = Path.Combine(directory.Path, "cells.cc"); + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual); + await cache.FlushAsync(); + } + // ItemData.Shape is key 5: [[width, height, cells]]. + RewritePayload(path, "aetheria.hulldata", slots => ((object[])((object[])slots[5])[0])[2] = new object[] { 5, false, true, false }); + var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); + Assert.Contains(path, error.Message); + Assert.IsAssignableFrom(error.InnerException); + } + + // Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again. + private static void RewritePayload(string path, string schemaName, Action edit) + { + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var schemaId = snapshot.SchemaCatalog.Single(entry => entry.SchemaName == schemaName).SchemaId; + var record = snapshot.Records.Single(candidate => candidate.SchemaId == schemaId); + var slots = MessagePackSerializer.Deserialize(record.Payload); + edit(slots); + record.Payload = MessagePackSerializer.Serialize(slots); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + } + [Fact] public async Task LoadRefusesAFileWhoseRecordsAreNotOneHullAndOneVisualAtTheirKeys() { diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 54563348f..9969c835d 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -65,6 +65,26 @@ public sealed class ShipModCatalogTests : IDisposable Assert.Equal("mod.skiff", visual.Id); } + [Fact] + public void ComposeRefusesAShippedHullThatNamesTwoBodies() + { + WritePackage("mod.skiff"); + using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) + { + cache.Upsert(new HullData + { + Name = "Chimera", + Shape = ShipAuthoringTests.Fixture().Hull.Shape, + Prefab = "Djinni", + Visual = new CultRecordRef(ShipModCatalog.AuthoringKey("mod.skiff")) + }); + cache.FlushAsync().Wait(); + } + Assert.Contains("Chimera: a hull names one body", + Assert.Throws(() => ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); + Assert.False(File.Exists(Derived)); + } + [Fact] public void ComposeCopiesTheModStoresRecordsUnchangedAndTheHullNamesItsVisualByRef() { diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index b5dc61ffe..418c8f31a 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -31,11 +31,22 @@ public sealed class ShipSchemaPinTests { Assert.Equal(typeof(ShipAuthoring).GetCustomAttribute().SchemaName, Text("SCHEMA")); Assert.Equal(typeof(HullData).GetCustomAttribute().SchemaName, Text("HULL_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), member => member.GetCustomAttribute()?.IntKey == 1); } + // HullData.Visual is the one binding between a hull and its visual; its slot is the wire contract. + [Fact] + public void HullVisualIsKey32() + { + Assert.Equal(32, KeyOf(typeof(HullData), nameof(HullData.Visual))); + var taken = typeof(HullData).GetMembers(BindingFlags.Public | BindingFlags.Instance) + .Select(member => member.GetCustomAttribute()?.IntKey).Where(key => key != null).ToArray(); + Assert.Equal(taken.Length, taken.Distinct().Count()); + } + [Fact] public void PythonHardpointMembersAreHardpointDataMembersAtTheirOwnPositions() { diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index e7f3815af..c76df98a9 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -27,6 +27,17 @@ public sealed class ShipValidationTests Assert.Contains(Cases[name].Message, error.Message); } + [Fact] + public void HardpointsAtTheEdgesOfTheirRangesAreValid() + { + var ship = ShipAuthoringTests.Fixture(); + ship.Hull.Hardpoints[0].Armor = 5; + ship.Hull.Hardpoints[0].FiringArc = 360; + ship.Hull.Hardpoints[0].Type = HardpointType.AetherDrive; + ship.Hull.Hardpoints[0].Rotation = ItemRotation.Clockwise; + ship.Validate(); + } + [Fact] public void ValidatorRefusesANullRecord() => Assert.Contains("is null", Assert.Throws(() => ShipAuthoringStore.Validate(ShipAuthoringTests.Fixture().Hull, null)).Message); @@ -151,7 +162,7 @@ public sealed class ShipValidationTests Add("hull-width-zero", s => s.Hull.Shape.Cells = new bool[0, 2], "at least one cell"); Add("hull-height-zero", s => s.Hull.Shape.Cells = new bool[2, 0], "at least one cell"); Add("hull-no-occupied-cell", s => s.Hull.Shape.Cells = new bool[2, 2], "at least one cell"); - Add("hull-names-a-prefab", s => s.Hull.Prefab = "Djinni", "cannot name a Unity prefab"); + Add("hull-names-a-prefab-and-a-visual", s => s.Hull.Prefab = "Djinni", "names both a Unity prefab and a visual record"); Add("model-null", s => s.Visual.ModelAsset = null, "relative package path"); Add("model-blank", s => s.Visual.ModelAsset = " ", "relative package path"); @@ -169,6 +180,10 @@ public sealed class ShipValidationTests Add("role-doubled-" + role, s => s.Visual.Anchors.Add(new ShipAnchor { Id = "extra", Role = role, ModelNodeId = "extra" }), $"exactly one {role} anchor is required"); } + Add("anchor-role-unknown", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "x", Role = "engine", ModelNodeId = "x" }), "has unknown role 'engine'"); + Add("anchor-role-null", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "x", Role = null, ModelNodeId = "x" }), "has unknown role ''"); + Add("anchor-role-cased", s => s.Visual.Anchors[0].Role = "Map-Icon", "has unknown role 'Map-Icon'"); + Add("hardpoint-null", s => s.Hull.Hardpoints.Add(null), "hardpoint IDs must be present and unique"); Add("hardpoint-transform-null", s => Mount(s).Transform = null, "hardpoint IDs must be present and unique"); Add("hardpoint-transform-blank", s => Mount(s).Transform = " ", "hardpoint IDs must be present and unique"); @@ -176,6 +191,16 @@ public sealed class ShipValidationTests Add("hardpoint-shape-null", s => Mount(s).Shape = null, "has no cells"); Add("hardpoint-cells-null", s => Mount(s).Shape.Cells = null, "has no cells"); Add("hardpoint-cells-all-empty", s => Mount(s).Shape.Cells = new bool[1, 1], "has no cells"); + Add("hardpoint-type-undefined", s => Mount(s).Type = (HardpointType)99, "unknown type 99"); + Add("hardpoint-type-negative", s => Mount(s).Type = (HardpointType)(-1), "unknown type -1"); + Add("hardpoint-rotation-undefined", s => Mount(s).Rotation = (ItemRotation)4, "unknown rotation 4"); + Add("hardpoint-rotation-negative", s => Mount(s).Rotation = (ItemRotation)(-1), "unknown rotation -1"); + Add("hardpoint-armor-nan", s => Mount(s).Armor = float.NaN, "armor and firing arc must be finite and not negative"); + Add("hardpoint-armor-infinite", s => Mount(s).Armor = float.PositiveInfinity, "armor and firing arc must be finite and not negative"); + Add("hardpoint-armor-negative", s => Mount(s).Armor = -1, "armor and firing arc must be finite and not negative"); + Add("hardpoint-arc-nan", s => Mount(s).FiringArc = float.NaN, "armor and firing arc must be finite and not negative"); + Add("hardpoint-arc-infinite", s => Mount(s).FiringArc = float.NegativeInfinity, "armor and firing arc must be finite and not negative"); + Add("hardpoint-arc-negative", s => Mount(s).FiringArc = -1, "armor and firing arc must be finite and not negative"); Add("hardpoint-left-of-hull", s => Mount(s).Position = new int2(-1, 0), "outside the hull schematic"); Add("hardpoint-below-hull", s => Mount(s).Position = new int2(1, -1), "outside the hull schematic"); Add("hardpoint-right-of-hull", s => Mount(s).Position = new int2(2, 0), "outside the hull schematic"); diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 08a5b9e28..16f48dbbc 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -18,8 +18,9 @@ SCHEMA = "aetheria.ship_authoring" HULL_SCHEMA = "aetheria.hulldata" # MessagePack slots of the C# types, which own them (ShipAuthoring, ItemData.Shape, HullData.Hardpoints, -# HardpointData). ShipSchemaPinTests in tests/Aetheria.Shared.Tests fails when any number or name below +# 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 # disagrees with those types' [Key] attributes, so a renumbered or added member cannot drift silently. +RETIRED_HULL_SLOT = 1 SCHEMATIC_LINES_SLOT = 4 # ShipAuthoring.SchematicLines HULL_SHAPE_SLOT = 5 # ItemData.Shape HULL_HARDPOINTS_SLOT = 23 # HullData.Hardpoints @@ -69,6 +70,14 @@ def read(path: str, cultlib_packages: str) -> ShipFile: envelopes = store.pull_all() ships = [envelope for envelope in envelopes if envelope.type == SCHEMA] hulls = [envelope for envelope in envelopes if envelope.type == HULL_SCHEMA] + # Before S1 the hull lived inside the ship record. Saving such a file would preserve the stale hull under a ship that + # no longer reads it, so it is refused before anything else is said about its shape. + for envelope in ships: + body = msgpack.unpackb(envelope.payload, raw=False) + if isinstance(body, list) and len(body) > RETIRED_HULL_SLOT and body[RETIRED_HULL_SLOT] is not None: + raise ValueError( + f"{path}: {envelope.key}: 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"{path} must hold exactly one {SCHEMA} record and one {HULL_SCHEMA} record") bodies = [] @@ -129,6 +138,8 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_ width, height, cells = shape if not (1 <= width <= 32 and 1 <= height <= 32 and len(cells) == width * height): raise ValueError("Hull grid must be 1..32 cells wide and high with one value per cell") + if not all(type(cell) is bool for cell in cells): + raise ValueError("Hull grid cells must be booleans") if not any(cells): raise ValueError("Hull grid needs an occupied cell") known = len(HARDPOINT_MEMBERS) @@ -141,6 +152,8 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_ if len(hardpoint) != known or not isinstance(mount, str) or not mount: raise ValueError(f"Each hardpoint needs {known} typed fields and a stable mount ID") hp_width, hp_height, hp_cells = fields["Shape"][0] + if not all(type(cell) is bool for cell in hp_cells): + raise ValueError(f"Hardpoint {mount} footprint cells must be booleans") if not (1 <= hp_width <= 32 and 1 <= hp_height <= 32 and len(hp_cells) == hp_width * hp_height and any(hp_cells)): raise ValueError(f"Hardpoint {mount} has an invalid footprint") diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 63cc0ba64..0ab0c9877 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -8,12 +8,14 @@ ship_cc is loaded by path because the package __init__ imports bpy. The record l ship_cc's own slot constants; ShipSchemaPinTests (C#) pins those constants to the C# [Key] attributes. """ +import dataclasses import importlib.util import os import shutil import sys import tempfile import unittest +from datetime import datetime from pathlib import Path from types import SimpleNamespace @@ -140,11 +142,42 @@ class ReadTests(ShipFileCase): with self.assertRaisesRegex(ValueError, "must hold exactly one"): ship_cc.read(empty, PACKAGES) - def test_refuses_a_ship_payload_too_short_for_the_line_slot(self): - self.write(["mod.skiff", None], hull_body()) - with self.assertRaisesRegex(ValueError, "incompatible"): + def test_refuses_a_ship_record_that_still_carries_the_retired_embedded_hull(self): + for name, ship in (("nested hull", ["mod.skiff", hull_body()]), + ("any value", ["mod.skiff", 0])): + body = ship_body() + body[ship_cc.RETIRED_HULL_SLOT] = ship[1] + with self.subTest(name): + self.write(body, hull_body()) + with self.assertRaisesRegex(ValueError, "legacy embedded hull at retired key 1"): + ship_cc.read(self.path, PACKAGES) + # Nothing else is said about its shape: an old file has no hull record beside the ship. + cultcache_py.SingleFileMessagePackBackingStore(self.path).push_all( + [envelope("mod-ship:mod.skiff", ship_cc.SCHEMA, CATALOG, body)]) + with self.assertRaisesRegex(ValueError, "legacy embedded hull"): ship_cc.read(self.path, PACKAGES) + def test_refuses_a_save_over_a_file_with_the_retired_embedded_hull_and_writes_nothing(self): + body = ship_body() + body[ship_cc.RETIRED_HULL_SLOT] = hull_body() + self.write(body, hull_body()) + before = self.bytes() + with self.assertRaisesRegex(ValueError, "legacy embedded hull"): + ship_cc.replace_lines(self.path, PACKAGES, LINES) + with self.assertRaisesRegex(ValueError, "legacy embedded hull"): + ship_cc.replace_layout(self.path, PACKAGES, "mod.skiff", "any", [2, 2, [True] * 4], []) + self.assertEqual(before, self.bytes()) + + def test_a_nil_in_the_retired_slot_is_the_current_shape(self): + self.assertIsNone(self.ship()[ship_cc.RETIRED_HULL_SLOT]) + + def test_refuses_a_ship_payload_too_short_for_the_line_slot(self): + for body in (["mod.skiff"], ["mod.skiff", None]): + with self.subTest(slots=len(body)): + self.write(body, hull_body()) + with self.assertRaisesRegex(ValueError, "incompatible"): + ship_cc.read(self.path, PACKAGES) + def test_refuses_a_payload_that_is_not_an_array(self): for payload in ({"Id": "mod.skiff"}, {str(index): index for index in range(9)}, "a string long enough to fill every slot"): with self.subTest(ship=payload): @@ -168,6 +201,17 @@ class ReplaceLinesTests(ShipFileCase): if slot != ship_cc.SCHEMATIC_LINES_SLOT: self.assertEqual(before[slot], after[slot], f"slot {slot} changed") + def test_a_save_refreshes_the_stored_at_of_the_record_it_writes_and_only_that_one(self): + stale = "2000-01-01T00:00:00+00:00" + envelopes = self.envelopes() + cultcache_py.SingleFileMessagePackBackingStore(self.path).push_all( + [dataclasses.replace(envelopes[key], stored_at=stale) for key in envelopes]) + ship_cc.replace_lines(self.path, PACKAGES, LINES) + after = self.envelopes() + self.assertGreater(datetime.fromisoformat(after[(ship_cc.SCHEMA, "mod-ship:mod.skiff")].stored_at), + datetime.fromisoformat(stale)) + self.assertEqual(stale, after[(ship_cc.HULL_SCHEMA, "mod-hull:mod.skiff")].stored_at) + def test_leaves_the_hull_record_byte_identical(self): before = self.envelopes()[(ship_cc.HULL_SCHEMA, "mod-hull:mod.skiff")] ship_cc.replace_lines(self.path, PACKAGES, LINES) @@ -294,6 +338,38 @@ class LayoutTests(ShipFileCase): revision = self.layout()[2] self.assertNotEqual(before, self.bytes()) + def test_a_layout_save_refreshes_the_stored_at_of_the_hull_and_leaves_the_ships(self): + stale = "2000-01-01T00:00:00+00:00" + envelopes = self.envelopes() + cultcache_py.SingleFileMessagePackBackingStore(self.path).push_all( + [dataclasses.replace(envelopes[key], stored_at=stale) for key in envelopes]) + self.edit(self.layout()[2]) + after = self.envelopes() + self.assertGreater(datetime.fromisoformat(after[(ship_cc.HULL_SCHEMA, "mod-hull:mod.skiff")].stored_at), + datetime.fromisoformat(stale)) + self.assertEqual(stale, after[(ship_cc.SCHEMA, "mod-ship:mod.skiff")].stored_at) + + def test_refuses_cells_that_are_not_booleans_and_writes_nothing(self): + _, _, revision = self.layout() + before = self.bytes() + for name, cells in (("integer", [True, 1, True, False]), ("zero", [True, True, True, 0]), + ("string", [True, "x", True, False]), ("none", [True, None, True, False])): + with self.subTest(name): + with self.assertRaisesRegex(ValueError, "Hull grid cells must be booleans"): + self.edit(revision, cells=cells) + self.assertEqual(before, self.bytes()) + + def test_refuses_hardpoint_footprint_cells_that_are_not_booleans_and_writes_nothing(self): + _, _, revision = self.layout() + before = self.bytes() + for name, cells in (("integer", [1, True]), ("none", [True, None])): + row = ship_cc.encode_hardpoint(**{**ship_cc.decode_hardpoint(hardpoint_row(tail=())), + "Shape": [shape(2, 1, cells)]}) + with self.subTest(name): + with self.assertRaisesRegex(ValueError, "thruster.port footprint cells must be booleans"): + self.edit(revision, hardpoints=[row]) + self.assertEqual(before, self.bytes()) + def test_refuses_a_cell_count_that_does_not_fill_the_grid(self): _, _, revision = self.layout() before = self.bytes() From 460bb85ed04b66a3c1208383b07d4c85bd58b2ac Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 06:43:52 +0200 Subject: [PATCH 017/113] Ship S2: the game composes and loads mod ships Boot opens the derived catalog when GameData/Mods holds packages (ShipModCatalog.ResolveCatalog), preloads each hull's GLB into an assembled inactive prototype (ShipModShips), and LoadEntity clones it when the hull names a Visual. ShipModPlan decides which anchor fills which ShipInstance slot; a ShipModTemplate prefab carries the shared effects. Continue refuses a run that names a design the catalog no longer holds (RunSave.RequireDesigns). Co-Authored-By: Claude Sonnet 5.5 --- Assets/Content/Prefabs/ShipModTemplate.prefab | 51 ++++++ .../Prefabs/ShipModTemplate.prefab.meta | 7 + Assets/Scripts/Editor/ShipModPreview.cs | 49 ++++- Assets/Scripts/Gameplay/ActionGameManager.cs | 11 +- Assets/Scripts/Gameplay/ShipModShips.cs | 168 ++++++++++++++++++ Assets/Scripts/Gameplay/ShipModTemplate.cs | 17 ++ .../Scripts/Gameplay/ShipModTemplate.cs.meta | 11 ++ Assets/Scripts/Gameplay/ShipModVisual.cs | 3 +- Assets/Scripts/ServerShared/SavedGame.cs | 22 +++ Assets/Scripts/ServerShared/ShipModCatalog.cs | 29 ++- Assets/Scripts/ServerShared/ShipModPlan.cs | 68 +++++++ Assets/Scripts/UI/MainMenu.cs | 33 +++- Assets/Scripts/Zone Display/ZoneRenderer.cs | 7 +- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 74 ++++++++ .../ShipModCatalogTests.cs | 73 ++++++++ .../Aetheria.Shared.Tests/ShipModPlanTests.cs | 92 ++++++++++ 16 files changed, 702 insertions(+), 13 deletions(-) create mode 100644 Assets/Content/Prefabs/ShipModTemplate.prefab create mode 100644 Assets/Content/Prefabs/ShipModTemplate.prefab.meta create mode 100644 Assets/Scripts/Gameplay/ShipModShips.cs create mode 100644 Assets/Scripts/Gameplay/ShipModTemplate.cs create mode 100644 Assets/Scripts/Gameplay/ShipModTemplate.cs.meta create mode 100644 Assets/Scripts/ServerShared/ShipModPlan.cs create mode 100644 tests/Aetheria.Shared.Tests/ShipModPlanTests.cs diff --git a/Assets/Content/Prefabs/ShipModTemplate.prefab b/Assets/Content/Prefabs/ShipModTemplate.prefab new file mode 100644 index 000000000..7ed600cb6 --- /dev/null +++ b/Assets/Content/Prefabs/ShipModTemplate.prefab @@ -0,0 +1,51 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &4001 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 4002} + - component: {fileID: 4003} + m_Layer: 0 + m_Name: ShipModTemplate + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &4002 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4001} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_Children: [] + m_Father: {fileID: 0} + m_RootOrder: 0 + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &4003 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4001} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7c3e9a52d8b14f0e8a6d2b1f4c95e703, type: 3} + m_Name: + m_EditorClassIdentifier: + Shield: {fileID: 8402070500206635106, guid: 897fa55d98f1f7f40b889f93ce9146f4, type: 3} + TractorBeam: {fileID: 2009124798951080406, guid: 1df59848be1db8547bc662080d0d6000, type: 3} + Ping: {fileID: 3496685214803260731, guid: bfcaa66e173f6a44cb640aabbcb5e429, type: 3} + DestroyEffect: {fileID: 1121192042590650, guid: e1b08032bec16ba4eb1ae23cbd572664, type: 3} + Invisible: {fileID: 2100000, guid: c94a225de88e5ce42af01c34df3521e6, type: 2} + MapIcon: {fileID: 2100000, guid: 64d1c3f8a4422884b88363be07521fb0, type: 2} diff --git a/Assets/Content/Prefabs/ShipModTemplate.prefab.meta b/Assets/Content/Prefabs/ShipModTemplate.prefab.meta new file mode 100644 index 000000000..d2ea9f827 --- /dev/null +++ b/Assets/Content/Prefabs/ShipModTemplate.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5d1f3a8c7e2b4f60a9c4b7e1d3f2a860 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Editor/ShipModPreview.cs b/Assets/Scripts/Editor/ShipModPreview.cs index 859c0a80f..3cd6a05d0 100644 --- a/Assets/Scripts/Editor/ShipModPreview.cs +++ b/Assets/Scripts/Editor/ShipModPreview.cs @@ -1,10 +1,16 @@ using System; +using System.Linq; using UnityEditor; using UnityEngine; public static class ShipModPreview { - // Batch proof: -executeMethod ShipModPreview.Smoke -shipModPath + private const string TemplatePath = "Assets/Content/Prefabs/ShipModTemplate.prefab"; + + // Batch proof: -executeMethod ShipModPreview.Smoke -shipModPath [-assemble] + // -assemble also builds the game's ShipInstance from the package (ShipModShips.Assemble) and checks it against the + // plan and against a clone, the way Instantiate makes one. That needs a GLB with real meshes on the map-icon, + // hull-collider and emitter anchors; a nodes-only fixture fails it on purpose. public static async void Smoke() { var args = Environment.GetCommandLineArgs(); @@ -18,10 +24,12 @@ public static class ShipModPreview ShipModVisual.Instance visual = null; try { - visual = await ShipModVisual.LoadAsync(args[option + 1], null); + var package = ShipModCatalog.ReadPackage(args[option + 1]); + visual = await ShipModVisual.LoadAsync(package, null); if (visual.Anchors.Count == 0 || visual.LineMesh == null || visual.LineMesh.vertexCount == 0) throw new InvalidOperationException("Mod visual did not import anchors and line points."); Debug.Log($"SHIP_MOD_VISUAL_SMOKE anchors={visual.Anchors.Count} points={visual.LineMesh.vertexCount} segments={visual.LineMesh.GetIndexCount(0) / 2}"); + if (Array.IndexOf(args, "-assemble") >= 0) CheckAssembly(visual, package); visual.Destroy(); EditorApplication.Exit(0); } @@ -33,6 +41,41 @@ public static class ShipModPreview } } + private static void CheckAssembly(ShipModVisual.Instance visual, ShipModCatalog.Package package) + { + var template = AssetDatabase.LoadAssetAtPath(TemplatePath); + if (template == null) throw new InvalidOperationException($"{TemplatePath} is missing or has no ShipModTemplate."); + var plan = ShipModPlan.Build(package.Hull, package.Visual); + var ship = ShipModShips.Assemble(visual, package.Hull, package.Visual, template); + CheckShip(ship, plan, "assembled"); + + // Instantiate clones the assembled root, and Unity must remap every array reference into the clone. + var clone = UnityEngine.Object.Instantiate(ship.gameObject).GetComponent(); + try { CheckShip(clone, plan, "clone"); } + finally { UnityEngine.Object.DestroyImmediate(clone.gameObject); } + Debug.Log($"SHIP_MOD_ASSEMBLY_SMOKE equipment={plan.Equipment.Length} thrusters={plan.Thrusters.Length} " + + $"weapons={plan.Weapons.Length} radiators={plan.Radiators.Length}"); + } + + private static void CheckShip(ShipInstance ship, ShipModPlan plan, string what) + { + void Require(bool ok, string claim) { if (!ok) throw new InvalidOperationException($"{what}: {claim}"); } + bool Inside(Component part) => part != null && part.transform.IsChildOf(ship.transform); + Require(ship.HullColliders.Length == 1 && Inside(ship.HullColliders[0]) && + ship.HullColliders[0].GetComponent() != null, "one hull collider with a MeshCollider"); + Require(Inside(ship.MapIcon) && Inside(ship.Shield) && Inside(ship.TractorBeam), "map icon, shield and tractor inside the ship"); + Require(ship.EquipmentHardpoints.Select(node => node.name).SequenceEqual(plan.Equipment) && + ship.EquipmentHardpoints.All(Inside), "equipment hardpoints named by the plan"); + Require(ship.ThrusterHardpoints.Select(node => node.name).SequenceEqual(plan.Thrusters) && + ship.ThrusterHardpoints.All(node => Inside(node) && Inside(node.Emitter)), "thruster hardpoints with emitters"); + Require(ship.RadiatorHardpoints.Select(node => node.name).SequenceEqual(plan.Radiators) && + ship.RadiatorHardpoints.All(node => Inside(node) && Inside(node.Mesh)), "radiator hardpoints with meshes"); + Require(ship.WeaponHardpoints.Select(node => node.name).SequenceEqual(plan.Weapons.Select(weapon => weapon.Mount)) && + ship.WeaponHardpoints.Zip(plan.Weapons, (node, weapon) => + Inside(node) && node.FiringPoint.Select(point => point.name).SequenceEqual(weapon.Muzzles) && + node.FiringPoint.All(Inside)).All(ok => ok), "weapon hardpoints with their muzzles in order"); + } + [MenuItem("Aetheria/Preview Mod Ship Package")] private static async void Open() { @@ -40,7 +83,7 @@ public static class ShipModPreview if (string.IsNullOrEmpty(path)) return; try { - var visual = await ShipModVisual.LoadAsync(path, null); + var visual = await ShipModVisual.LoadAsync(ShipModCatalog.ReadPackage(path), null); Undo.RegisterCreatedObjectUndo(visual.Root, "Preview mod ship"); Selection.activeGameObject = visual.Root; SceneView.FrameLastActiveSceneView(); diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index c62d980e4..f12ea2633 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -43,6 +43,9 @@ public class ActionGameManager : MonoBehaviour private static CultCache _cultCache; private static string CatalogPath => Path.Combine(GameDataDirectory.FullName, "Aetheria.cc"); + private static string ModsPath => Path.Combine(GameDataDirectory.FullName, "Mods"); + // Disposable: composed at every boot from the shipped catalog and the mod packages, never authored. + private static string DerivedCatalogPath => Path.Combine(Application.persistentDataPath, "Aetheria.modded.cc"); public static CultCache CultCache { @@ -52,12 +55,16 @@ public class ActionGameManager : MonoBehaviour // All three stores attach once and stay attached until the process exits. The run lifecycle is record-level // inside this cache; reopening would replace the catalog instances the galaxy and live entities hold. - // The catalog is read-only in every build; capturepreset writes through its own cache (Loadouts.Commit). + // The catalog is read-only in every build; capturepreset writes through its own cache (Loadouts.Commit) to the + // shipped file. With mod packages installed the game reads the derived catalog instead. _cultCache = AetheriaStores.Open( - CatalogPath, + ShipModCatalog.ResolveCatalog(CatalogPath, DerivedCatalogPath, ModsPath), runPath: Path.Combine(GameDataDirectory.FullName, "run.cc"), playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc")); + // Mod ship prototypes import asynchronously from here; entering a game waits on ShipModShips.Loading. + ShipModShips.Preload(_cultCache, ModsPath); + return _cultCache; } } diff --git a/Assets/Scripts/Gameplay/ShipModShips.cs b/Assets/Scripts/Gameplay/ShipModShips.cs new file mode 100644 index 000000000..b0b9a0d0c --- /dev/null +++ b/Assets/Scripts/Gameplay/ShipModShips.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching; +using UnityEngine; +using Object = UnityEngine.Object; + +// Mod ships in the running game. Preload imports each catalog hull's GLB once, at boot, into an inactive assembled +// prototype keyed by the hull's record key; Instantiate then clones it synchronously, so LoadEntity never loads async. +// The prototypes are cache-only: rebuilt every boot from the catalog, the mod GLBs and the template. +public static class ShipModShips +{ + private static readonly Dictionary Prototypes = new Dictionary(); + private static CultCache _catalog; + + // Set by Preload; complete when every prototype is ready, faulted when any mod ship failed to import or assemble. + public static Task Loading { get; private set; } = Task.CompletedTask; + + public static Task Preload(CultCache catalog, string modsRoot) + { + Loading = PreloadAsync(catalog, modsRoot); + Loading.ContinueWith(task => Debug.LogException(task.Exception), TaskContinuationOptions.OnlyOnFaulted); + return Loading; + } + + private static async Task PreloadAsync(CultCache catalog, string modsRoot) + { + _catalog = catalog; + var hulls = catalog.GetAll().Where(hull => hull.Visual.IsSet()).ToArray(); + if (hulls.Length == 0) return; + var template = EngineAssets.Load(ShipModTemplate.Key); + if (template == null) + throw new InvalidOperationException($"The ship mod template prefab ({ShipModTemplate.Key}) is not loadable."); + var root = new GameObject("Mod Ship Prototypes"); + root.SetActive(false); + Object.DontDestroyOnLoad(root); + foreach (var hull in hulls) + { + var package = ShipModCatalog.PackageOf(catalog, hull, modsRoot); + var visual = await ShipModVisual.LoadAsync(package, root.transform); + try + { + visual.Root.SetActive(false); + Assemble(visual, package.Hull, package.Visual, template); + Prototypes[catalog.RefOf(hull).Key] = visual.Root; + } + catch + { + visual.Destroy(); + throw; + } + } + } + + // A live ship body for a mod hull. Refuses loudly when the prototype is missing: nothing is loaded here. + public static GameObject Instantiate(HullData hull, Transform parent) + { + if (!Loading.IsCompletedSuccessfully) + throw new InvalidOperationException($"{hull.Name}: mod ships are not preloaded ({Loading.Status})."); + if (!Prototypes.TryGetValue(_catalog.RefOf(hull).Key, out var prototype)) + throw new InvalidOperationException($"{hull.Name}: no mod ship prototype for this hull."); + var body = Object.Instantiate(prototype, parent, false); + body.SetActive(true); + return body; + } + + // Fills a ShipInstance on the visual's root from its anchors, as ShipModPlan says; the shared effects come from the + // template. Nothing here decides a slot: the plan does. The schematic lines stay in the prototype but are hidden in + // world; the in-world ship is the GLB. + public static ShipInstance Assemble(ShipModVisual.Instance visual, HullData hull, ShipAuthoring ship, ShipModTemplate template) + { + var plan = ShipModPlan.Build(hull, ship); + var minimap = LayerMask.NameToLayer("Minimap"); + var combat = LayerMask.NameToLayer("Combat"); + if (minimap < 0 || combat < 0) throw new InvalidOperationException("Minimap or Combat layer is missing"); + + // The name a catalog hardpoint matches at runtime is its anchor id. + Transform Node(string id) + { + var node = visual.Anchors[id]; + node.name = id; + return node; + } + MeshRenderer Renderer(string id) + { + var found = Node(id).GetComponentInChildren(true); + if (found == null) throw new InvalidOperationException($"{ship.Id}: anchor {id} needs a mesh in its GLB node."); + return found; + } + + var mapIcon = Renderer(plan.MapIcon); + mapIcon.sharedMaterial = template.MapIcon; + mapIcon.gameObject.layer = minimap; + + var hullNode = Node(plan.HullCollider); + var hullFilter = hullNode.GetComponent(); + var hullMesh = hullFilter != null ? hullFilter.sharedMesh : null; + if (hullMesh == null) + throw new InvalidOperationException($"{ship.Id}: hull-collider anchor {plan.HullCollider} needs a mesh in its GLB node."); + if (!hullMesh.isReadable) + throw new InvalidOperationException($"{ship.Id}: the hull-collider mesh imported unreadable, so it cannot back a MeshCollider."); + hullNode.gameObject.layer = combat; + var hullRenderer = hullNode.GetComponent(); + if (hullRenderer != null) hullRenderer.forceRenderingOff = true; + var collider = AddOrGet(hullNode.gameObject); + collider.sharedMesh = hullMesh; + collider.convex = true; + var surface = AddOrGet(hullNode.gameObject); + + var shieldMarker = Node(plan.Shield); + var scale = shieldMarker.localScale; + if (!(scale.x > 0 && scale.y > 0 && scale.z > 0)) + throw new InvalidOperationException($"{ship.Id}: the shield anchor scale must have three positive radii."); + var shield = Object.Instantiate(template.Shield.GetComponent(), shieldMarker, false); + Reset(shield.transform); + AddOrGet(shield.gameObject); + var tractor = Object.Instantiate(template.TractorBeam.GetComponent(), Node(plan.Tractor), false); + Reset(tractor.transform); + + var thrusters = plan.Thrusters.Select(id => + { + var hardpoint = AddOrGet(Node(id).gameObject); + hardpoint.Emitter = Renderer(id); + return hardpoint; + }).ToArray(); + var radiators = plan.Radiators.Select(id => + { + var hardpoint = AddOrGet(Node(id).gameObject); + hardpoint.Mesh = Renderer(id); + return hardpoint; + }).ToArray(); + var weapons = plan.Weapons.Select(weapon => + { + var hardpoint = AddOrGet(Node(weapon.Mount).gameObject); + hardpoint.FiringPoint = weapon.Muzzles.Select(Node).ToArray(); + return hardpoint; + }).ToArray(); + + var lines = visual.Root.transform.Find("Schematic Lines"); + if (lines != null) lines.gameObject.SetActive(false); + + var instance = AddOrGet(visual.Root); + instance.MapIcon = mapIcon; + instance.InfluencePrefab = null; + instance.PingPrefab = template.Ping.transform; + instance.InvisibleMaterial = template.Invisible; + instance.Shield = shield; + instance.HullColliders = new[] { surface }; + instance.EquipmentHardpoints = plan.Equipment.Select(Node).ToArray(); + instance.ThrusterHardpoints = thrusters; + instance.WeaponHardpoints = weapons; + instance.RadiatorHardpoints = radiators; + instance.ArticulationPoints = Array.Empty(); + instance.DestroyEffect = template.DestroyEffect; + instance.TractorBeam = tractor; + return instance; + } + + private static void Reset(Transform transform) + { + transform.localPosition = Vector3.zero; + transform.localRotation = Quaternion.identity; + } + + private static T AddOrGet(GameObject gameObject) where T : Component => + gameObject.TryGetComponent(out var found) ? found : gameObject.AddComponent(); +} diff --git a/Assets/Scripts/Gameplay/ShipModTemplate.cs b/Assets/Scripts/Gameplay/ShipModTemplate.cs new file mode 100644 index 000000000..a142aab98 --- /dev/null +++ b/Assets/Scripts/Gameplay/ShipModTemplate.cs @@ -0,0 +1,17 @@ +using UnityEngine; + +// The shared effect prototypes every mod ship is assembled with: the same assets ShipPrefabAuthoring wires into a +// prefab ship. It lives on one Addressable prefab, so a built player finds it by key without the Editor. +public class ShipModTemplate : MonoBehaviour +{ + // The template prefab's .meta GUID, its Addressables key (Assets/Content is an addressable folder). + public const string Key = "5d1f3a8c7e2b4f60a9c4b7e1d3f2a860"; + + // Prefab roots; the assembler takes the component it needs from each. + public GameObject Shield; + public GameObject TractorBeam; + public GameObject Ping; + public GameObject DestroyEffect; + public Material Invisible; + public Material MapIcon; +} diff --git a/Assets/Scripts/Gameplay/ShipModTemplate.cs.meta b/Assets/Scripts/Gameplay/ShipModTemplate.cs.meta new file mode 100644 index 000000000..8effd0b5a --- /dev/null +++ b/Assets/Scripts/Gameplay/ShipModTemplate.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7c3e9a52d8b14f0e8a6d2b1f4c95e703 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Gameplay/ShipModVisual.cs b/Assets/Scripts/Gameplay/ShipModVisual.cs index fd4058d83..c75bc577d 100644 --- a/Assets/Scripts/Gameplay/ShipModVisual.cs +++ b/Assets/Scripts/Gameplay/ShipModVisual.cs @@ -30,9 +30,8 @@ public static class ShipModVisual public Transform Get(uint index) => m_Nodes.TryGetValue(index, out var node) ? node.transform : null; } - public static async Task LoadAsync(string shipPath, Transform parent) + public static async Task LoadAsync(ShipModCatalog.Package package, Transform parent) { - var package = ShipModCatalog.ReadPackage(shipPath); var ship = package.Visual; var root = new GameObject("mod-ship:" + ship.Id); diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index 41cd613ac..ed5cdecf1 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -91,6 +91,28 @@ public static class RunSave return (game, zones); } + // Continue's gate: every design a saved run names (each zone's items and each minted lot) must be one this catalog + // holds. A run made with a mod that is no longer installed is refused, naming the mod ships, before any scene is + // built; the save is never edited to fit. + public static void RequireDesigns(CultCache cache, SavedGame game) + { + var named = (game.Zones ?? Array.Empty>()) + .SelectMany(zone => cache.Get(zone)?.Contents?.Entities ?? new List()) + .SelectMany(EntitySerializer.Items).Select(item => item.Data.Key) + .Concat(Lots(cache).Lots.Values.Select(lot => lot.Design.Key)) + .Where(key => key.IsSet()).Distinct() + .Where(key => cache.Get(key) == null) + .Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray(); + if (named.Length == 0) return; + const string modHull = "mod-hull:"; + var mods = named.Where(key => key.StartsWith(modHull, StringComparison.Ordinal)).Select(key => key.Substring(modHull.Length)).ToArray(); + var others = named.Where(key => !key.StartsWith(modHull, StringComparison.Ordinal)).ToArray(); + throw new InvalidOperationException("This run names designs the catalog no longer holds" + + (mods.Length > 0 ? $"; missing mod ships: {string.Join(", ", mods)}" : "") + + (others.Length > 0 ? $"; missing other designs: {string.Join(", ", others)}" : "") + + ". Reinstall them, or start a new game."); + } + // The stored ledger, or a fresh empty one when the run has minted nothing yet. public static ProvenanceLedger Lots(CultCache cache) => cache.GetGlobal() ?? new ProvenanceLedger(); diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 011faac79..d3751feb8 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -88,8 +88,35 @@ public static class ShipModCatalog public static Package ReadPackage(string shipPath) { var path = Path.GetFullPath(shipPath); - var directory = Path.GetDirectoryName(path); var (hull, ship) = ShipAuthoringStore.Read(path); + return Bind(hull, ship, Path.GetDirectoryName(path), path); + } + + // The package a catalog hull names: its two records come from the catalog, and only the GLB comes from the mod + // directory (//). Gameplay reads a mod ship through this, never through its ship.cc. + public static Package PackageOf(CultCache catalog, HullData hull, string modsRoot) + { + var ship = catalog.Get(hull.Visual) ?? + throw new InvalidOperationException($"{hull.Name}: the catalog holds no visual record {hull.Visual.Key.Value}."); + ShipAuthoringStore.Validate(hull, ship); + var directory = Path.Combine(Path.GetFullPath(modsRoot), ship.Id); + return Bind(hull, ship, directory, Path.Combine(directory, "ship.cc")); + } + + // The one game-side choice of catalog: the shipped file when the mods root holds no package, otherwise the derived + // file, composed afresh from the shipped one and the packages. Recomposed every call, so the derived file is never + // stale and never an authority. + public static string ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot) + { + if (!Directory.Exists(modsRoot) || + !Directory.GetDirectories(modsRoot).Any(directory => File.Exists(Path.Combine(directory, "ship.cc")))) + return shippedCatalog; + Compose(shippedCatalog, derivedCatalog, modsRoot); + return derivedCatalog; + } + + private static Package Bind(HullData hull, ShipAuthoring ship, string directory, string path) + { if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal)) throw new InvalidOperationException($"{path}: ship ID must match its package directory name."); var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset)); diff --git a/Assets/Scripts/ServerShared/ShipModPlan.cs b/Assets/Scripts/ServerShared/ShipModPlan.cs new file mode 100644 index 000000000..e0c84c517 --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipModPlan.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +// Which anchor fills which slot of a ship's component arrays. Pure over (hull, visual) so the Unity assembler only +// follows it: the hull's hardpoint type decides what a mount is, the anchor's role decides which node plays the part. +public sealed class ShipModPlan +{ + public string MapIcon; + public string HullCollider; + public string Shield; + public string Tractor; + // Every hull hardpoint's mount id, in hardpoint order: the anchor node each one is matched to by name. + public string[] Equipment; + // Mount ids whose anchor (role thruster-emitter / radiator-mesh) is the emitter / mesh node itself. + public string[] Thrusters; + public string[] Radiators; + // A weapon mount's muzzle anchors, by Order then id. + public (string Mount, string[] Muzzles)[] Weapons; + + // Anchors with role "articulation" become ordinary mount nodes: ArticulationPoint's limits are not authorable until + // pivots move onto HullData, so no ArticulationPoint is planned. + public static ShipModPlan Build(HullData hull, ShipAuthoring visual) + { + ShipAuthoringStore.Validate(hull, visual); + var hardpoints = hull.Hardpoints ?? new List(); + var byMount = hardpoints.ToDictionary(hardpoint => hardpoint.Transform, StringComparer.Ordinal); + var role = visual.Anchors.ToDictionary(anchor => anchor.Id, anchor => anchor.Role, StringComparer.Ordinal); + string Only(string wanted) => visual.Anchors.Single(anchor => anchor.Role == wanted).Id; + + foreach (var anchor in visual.Anchors.Where(anchor => anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh")) + { + var wanted = anchor.Role == "thruster-emitter" ? HardpointType.Thruster : HardpointType.Radiator; + if (!byMount.TryGetValue(anchor.Id, out var mount) || mount.Type != wanted) + throw new InvalidOperationException($"{visual.Id}: {anchor.Role} anchor {anchor.Id} must be a {wanted} hardpoint's mount."); + } + string[] Mounts(HardpointType type, string wantedRole) => hardpoints.Where(hardpoint => hardpoint.Type == type).Select(hardpoint => + role[hardpoint.Transform] == wantedRole ? hardpoint.Transform : + throw new InvalidOperationException($"{visual.Id}: {type} hardpoint {hardpoint.Transform} needs a {wantedRole} anchor of that id.")).ToArray(); + + var muzzles = visual.Anchors.Where(anchor => anchor.Role == "weapon-muzzle") + .OrderBy(anchor => anchor.Order).ThenBy(anchor => anchor.Id, StringComparer.Ordinal).ToArray(); + foreach (var muzzle in muzzles) + if (string.IsNullOrEmpty(muzzle.ParentId) || !IsWeapon(byMount[muzzle.ParentId].Type)) + throw new InvalidOperationException($"{visual.Id}: muzzle {muzzle.Id} must be parented to a weapon hardpoint's mount."); + var weapons = hardpoints.Where(hardpoint => IsWeapon(hardpoint.Type)).Select(hardpoint => ( + hardpoint.Transform, + muzzles.Where(muzzle => muzzle.ParentId == hardpoint.Transform).Select(muzzle => muzzle.Id).ToArray())).ToArray(); + foreach (var (mount, ids) in weapons) + if (ids.Length == 0) + throw new InvalidOperationException($"{visual.Id}: weapon hardpoint {mount} needs at least one muzzle anchor."); + + return new ShipModPlan + { + MapIcon = Only("map-icon"), + HullCollider = Only("hull-collider"), + Shield = Only("shield"), + Tractor = Only("tractor"), + Equipment = hardpoints.Select(hardpoint => hardpoint.Transform).ToArray(), + Thrusters = Mounts(HardpointType.Thruster, "thruster-emitter"), + Radiators = Mounts(HardpointType.Radiator, "radiator-mesh"), + Weapons = weapons + }; + } + + private static bool IsWeapon(HardpointType type) => + type == HardpointType.Energy || type == HardpointType.Ballistic || type == HardpointType.Launcher; +} diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 8ace9b3c3..aedc0f5dc 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -102,9 +102,11 @@ public class MainMenu : MonoBehaviour Debug.Log("run store has no SavedGame; Continue disabled"); _nextMenu.panel.AddButton("Continue", saved == null ? (Action) null : () => { + try { RunSave.RequireDesigns(cache, saved); } + catch (InvalidOperationException error) { Refuse("Cannot continue this run", error.Message); return; } ActionGameManager.IsTutorial = saved.IsTutorial; ActionGameManager.CurrentGalaxy = new Galaxy(cache, saved, Debug.Log); - SceneManager.LoadScene("ARPG"); + EnterGame(); }); } _nextMenu.panel.AddButton("New Game", @@ -139,7 +141,7 @@ public class MainMenu : MonoBehaviour Observable.NextFrame().Subscribe(_ => { ActionGameManager.CurrentGalaxy = sector; - SceneManager.LoadScene("ARPG"); + EnterGame(); }); }).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted); } @@ -169,7 +171,7 @@ public class MainMenu : MonoBehaviour Observable.NextFrame().Subscribe(_ => { ActionGameManager.CurrentGalaxy = sector; - SceneManager.LoadScene("ARPG"); + EnterGame(); }); }).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted); } @@ -183,6 +185,31 @@ public class MainMenu : MonoBehaviour _nextMenu.panel.AddButton("Quit", Application.Quit); } + // The one way into the game scene. Mod ship prototypes import asynchronously at boot, so the scene waits for them, + // and a failed import stops here with its reason rather than in the first zone. + private void EnterGame() => StartCoroutine(EnterGameWhenModShipsAreReady()); + + private IEnumerator EnterGameWhenModShipsAreReady() + { + var loading = ShipModShips.Loading; + while (!loading.IsCompleted) yield return null; + if (loading.IsFaulted) + { + Refuse("Mod ships failed to load", loading.Exception.GetBaseException().Message); + yield break; + } + SceneManager.LoadScene("ARPG"); + } + + private void Refuse(string title, string reason) + { + Debug.LogError($"{title}: {reason}"); + Dialog.Clear(); + Dialog.Title.text = title; + Dialog.AddProperty(() => reason); + Dialog.Show(onCancel: () => { }, cancelText: "OK"); + } + private void ShowSettings() { _nextMenu.panel.Clear(); diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index 0b437dcfe..b3fec3ac8 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -289,10 +289,13 @@ public class ZoneRenderer : MonoBehaviour void LoadEntity(Entity entity) { var hullData = ItemManager.GetData(entity.Hull) as HullData; + GameObject Body() => hullData.Visual.IsSet() + ? ShipModShips.Instantiate(hullData, ZoneRoot) + : Instantiate(EngineAssets.Load(hullData.Prefab), ZoneRoot); EntityInstance instance; if (entity is Ship) { - instance = Instantiate(EngineAssets.Load(hullData.Prefab), ZoneRoot).GetComponent(); + instance = Body().GetComponent(); if (instance == null) { ItemManager.Log($"Failed to instantiate {hullData.Name} ship with invalid prefab: no ShipInstance component!"); @@ -301,7 +304,7 @@ public class ZoneRenderer : MonoBehaviour } else { - instance = Instantiate(EngineAssets.Load(hullData.Prefab), ZoneRoot).GetComponent(); + instance = Body().GetComponent(); if (instance == null) { ItemManager.Log($"Failed to instantiate {hullData.Name} entity with invalid prefab: no EntityInstance component!"); diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 86ca9a77e..c69239670 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -284,6 +284,80 @@ public sealed class RunSaveTests : IDisposable } } + // MQ4: Continue refuses a run that names a design the catalog no longer holds, naming the missing mod ships, and + // never edits the save. A design can be named by an item in a zone or only by a minted lot, so each is covered. + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void RunReferencingAMissingModRefusesContinue(bool itemNamesIt, bool lotNamesIt) + { + var modHull = new CultRecordKey("mod-hull:mod.skiff"); + var missing = new CultRecordRef(modHull); + CultRecordRef present; + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.UpsertAsync(typeof(HullData), new HullData { Name = "Skiff" }, modHull).GetAwaiter().GetResult(); + present = new CultRecordRef(cache.Upsert(new HullData { Name = "Wasp" }).Key); + cache.FlushAsync().Wait(); + } + var itemDesign = itemNamesIt ? missing : present; + var lotDesign = lotNamesIt ? missing : present; + + void CommitRun() + { + using var cache = Open(); + var lots = new ProvenanceLedger { NextLot = 3 }; + lots.Lots[1] = new Lot { Design = lotDesign, Origin = new Attributed() }; + // A lot minted before designs were recorded has none; an unset design is not a missing one. + lots.Lots[2] = new Lot { Origin = new Attributed() }; + var zone = new SavedZone + { + Name = "Zone 0", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1, + Contents = new ZonePack + { + Entities = new List + { + BarePack(hull: new EquippableItem { Data = itemDesign, Lot = 1 }), + BarePack(hull: new EquippableItem { Lot = 2 }) + } + } + }; + RunSave.Commit(cache, Game(cache), new[] { zone }, lots); + } + CommitRun(); + + // Installed: nothing is missing. + using (var cache = Open()) RunSave.RequireDesigns(cache, cache.GetGlobal()); + var before = File.ReadAllBytes(Run); + + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + cache.Commit(batch => batch.Remove(modHull)); + using (var cache = Open()) + { + var refusal = Assert.Throws(() => RunSave.RequireDesigns(cache, cache.GetGlobal())); + Assert.Contains("missing mod ships: mod.skiff", refusal.Message); + Assert.DoesNotContain("other designs", refusal.Message); + } + Assert.Equal(before, File.ReadAllBytes(Run)); + } + + [Fact] + public void ARunNamingAMissingShippedDesignIsRefusedToo() + { + var gone = new CultRecordRef(new CultRecordKey("hull:gone")); + using var cache = Open(); + var zone = new SavedZone + { + Name = "Zone 0", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1, + Contents = new ZonePack { Entities = new List { BarePack(hull: new EquippableItem { Data = gone, Lot = 1 }) } } + }; + RunSave.Commit(cache, Game(cache), new[] { zone }, new ProvenanceLedger { NextLot = 2, Lots = { [1] = new Lot { Origin = new Attributed() } } }); + + var refusal = Assert.Throws(() => RunSave.RequireDesigns(cache, cache.GetGlobal())); + Assert.Contains("missing other designs: hull:gone", refusal.Message); + Assert.DoesNotContain("mod ships", refusal.Message); + } + // A minimal, valid EntityPack with every collection field non-null, for tests that build packs directly rather // than through EntitySerializer.Pack. private static ShipPack BarePack(EquippableItem hull, EntityPack[] children = null, diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 9969c835d..e474d0133 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -342,6 +342,79 @@ public sealed class ShipModCatalogTests : IDisposable Assert.Equal(new Dictionary { ["a"] = 0 }, ShipModCatalog.ReadNodeIds(Write("full.glb", bytes))); } + // A run store references a mod hull by its key, so the same packages must derive the same records on every launch, + // whatever order the directories were created in. + [Fact] + public void ComposeAtBootIsDeterministic() + { + var ids = new[] { "mod.a", "mod.b", "mod.c", "mod.d", "mod.e", "mod.f" }; + foreach (var id in ids.Reverse()) WritePackage(id, hullName: "Hull " + id); + var firstMods = Mods; + var secondMods = Path.Combine(_directory.Path, "Mods2"); + foreach (var id in ids) ShipFixture.WritePackage(secondMods, id, "Hull " + id); + var second = Path.Combine(_directory.Path, "Derived2", "Aetheria.modded.cc"); + + ShipModCatalog.Compose(Shipped, Derived, firstMods); + ShipModCatalog.Compose(Shipped, second, secondMods); + ShipModCatalog.Compose(Shipped, Path.Combine(_directory.Path, "Derived3", "Aetheria.modded.cc"), firstMods); + + Assert.Equal(Records(Derived), Records(second)); + Assert.Equal(Records(Derived), Records(Path.Combine(_directory.Path, "Derived3", "Aetheria.modded.cc"))); + Assert.Contains(ShipModCatalog.HullKey("mod.c").Value, Records(Derived).Keys); + } + + [Fact] + public void TheBootCatalogIsTheShippedOneUntilAModIsInstalled() + { + Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Path.Combine(_directory.Path, "absent"))); + Directory.CreateDirectory(Path.Combine(Mods, "notes")); + Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods)); + Assert.False(File.Exists(Derived)); + + WritePackage("mod.skiff"); + Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods)); + using (var cache = AetheriaStores.Open(Derived)) + Assert.Contains("Skiff", cache.GetAll().Select(hull => hull.Name)); + + // Recomposed on every call: a mod removed since the last boot is gone from the file the game opens next. + Directory.Delete(Path.Combine(Mods, "mod.skiff"), true); + Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods)); + WritePackage("mod.barge", hullName: "Barge"); + Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods)); + using (var cache = AetheriaStores.Open(Derived)) + Assert.Equal(new[] { "Barge", "Wasp" }, cache.GetAll().Select(hull => hull.Name).OrderBy(name => name)); + } + + // Gameplay reads a mod ship's records from the derived catalog; the package directory supplies only the GLB. + [Fact] + public void PackageOfTakesItsRecordsFromTheCatalogAndOnlyTheGlbFromTheMod() + { + WritePackage("mod.skiff"); + ShipModCatalog.Compose(Shipped, Derived, Mods); + File.Delete(Path.Combine(Mods, "mod.skiff", "ship.cc")); + + using var cache = AetheriaStores.Open(Derived); + var hull = cache.GetAll().Single(candidate => candidate.Name == "Skiff"); + var package = ShipModCatalog.PackageOf(cache, hull, Mods); + + Assert.Same(hull, package.Hull); + Assert.Same(cache.Get(hull.Visual), package.Visual); + Assert.Equal(Path.Combine(Mods, "mod.skiff", "skiff.glb"), package.ModelPath); + Assert.Equal(ShipFixture.Nodes.Length, package.NodeIndices.Count); + + // A hull whose visual the catalog does not hold, and a mod whose GLB is gone, are refused. + Assert.Contains("holds no visual record", Assert.Throws(() => + ShipModCatalog.PackageOf(cache, new HullData { Name = "Ghost" }, Mods)).Message); + File.Delete(package.ModelPath); + Assert.Contains("model asset must name an existing GLB", Assert.Throws(() => + ShipModCatalog.PackageOf(cache, hull, Mods)).Message); + } + + // Every stored record's key, schema and payload bytes. StoredAt is a write timestamp, not record content. + private static Dictionary Records(string path) => + GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records + .ToDictionary(record => record.Key, record => record.SchemaId + ":" + Convert.ToHexString(record.Payload)); + private void AssertOnlyTheDerivedFile() { var names = Directory.GetFileSystemEntries(Path.GetDirectoryName(Derived)).Select(entry => Path.GetFileName(entry)).ToArray(); diff --git a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs new file mode 100644 index 000000000..db925a42a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using Xunit; + +// ShipModPlan: which anchor fills which component slot. The Unity assembler only follows this. +public sealed class ShipModPlanTests +{ + // Thruster, turret and radiator mounts on a 3x1 hull. Muzzles are listed out of Order on purpose. + private static ShipParts Rig() + { + var ship = ShipAuthoringTests.Fixture(); + var shape = new Shape(3, 1); + for (var x = 0; x < 3; x++) shape.Cells[x, 0] = true; + ship.Hull.Shape = shape; + ship.Hull.Hardpoints[0].Position = new int2(2, 0); + ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" }); + ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Radiator, Position = new int2(1, 0), Shape = new Shape(), Transform = "fin" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "articulation", ModelNodeId = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.b", Role = "weapon-muzzle", ModelNodeId = "gun-b", ParentId = "gun", Order = 1 }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.a", Role = "weapon-muzzle", ModelNodeId = "gun-a", ParentId = "gun", Order = 0 }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "fin", Role = "radiator-mesh", ModelNodeId = "fin" }); + return ship; + } + + private static ShipModPlan Plan(ShipParts ship) => ShipModPlan.Build(ship.Hull, ship.Visual); + private static string Refusal(ShipParts ship) => Assert.Throws(() => Plan(ship)).Message; + + [Fact] + public void EachSlotIsFilledFromItsAnchor() + { + var plan = Plan(Rig()); + + Assert.Equal(("map", "collider", "shield", "tractor"), (plan.MapIcon, plan.HullCollider, plan.Shield, plan.Tractor)); + Assert.Equal(new[] { "thruster.port", "gun", "fin" }, plan.Equipment); + Assert.Equal(new[] { "thruster.port" }, plan.Thrusters); + Assert.Equal(new[] { "fin" }, plan.Radiators); + var (mount, muzzles) = Assert.Single(plan.Weapons); + Assert.Equal("gun", mount); + Assert.Equal(new[] { "gun.a", "gun.b" }, muzzles); + } + + [Fact] + public void AThrusterOrRadiatorMountMustCarryItsRole() + { + var ship = Rig(); + ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Role = "articulation"; + Assert.Contains("Thruster hardpoint thruster.port needs a thruster-emitter anchor", Refusal(ship)); + + ship = Rig(); + ship.Visual.Anchors.Single(anchor => anchor.Id == "fin").Role = "articulation"; + Assert.Contains("Radiator hardpoint fin needs a radiator-mesh anchor", Refusal(ship)); + } + + [Fact] + public void AnEmitterOrMeshAnchorMustBeItsHardpointsMount() + { + // A thruster-emitter anchor on a weapon mount, and a radiator-mesh anchor that is no mount at all. + var ship = Rig(); + ship.Visual.Anchors.Single(anchor => anchor.Id == "gun").Role = "thruster-emitter"; + Assert.Contains("thruster-emitter anchor gun must be a Thruster hardpoint's mount", Refusal(ship)); + + ship = Rig(); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "loose", Role = "radiator-mesh", ModelNodeId = "loose" }); + Assert.Contains("radiator-mesh anchor loose must be a Radiator hardpoint's mount", Refusal(ship)); + } + + [Fact] + public void MuzzlesBelongToWeaponMountsAndEveryWeaponHasOne() + { + var ship = Rig(); + ship.Visual.Anchors.Single(anchor => anchor.Id == "gun.a").ParentId = null; + Assert.Contains("muzzle gun.a must be parented to a weapon hardpoint's mount", Refusal(ship)); + + ship = Rig(); + ship.Visual.Anchors.Single(anchor => anchor.Id == "gun.a").ParentId = "thruster.port"; + Assert.Contains("muzzle gun.a must be parented to a weapon hardpoint's mount", Refusal(ship)); + + ship = Rig(); + ship.Visual.Anchors.RemoveAll(anchor => anchor.Role == "weapon-muzzle"); + Assert.Contains("weapon hardpoint gun needs at least one muzzle anchor", Refusal(ship)); + } + + [Fact] + public void ABodyThatFailsTheSharedValidatorIsNeverPlanned() + { + var ship = Rig(); + ship.Hull.Prefab = "Djinni"; + Assert.Contains("names both a Unity prefab and a visual record", Refusal(ship)); + } +} From 86318ec014535432c3bb45db38331e0cf7b937a8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 06:46:17 +0200 Subject: [PATCH 018/113] Ship S2 tests: muzzle order against ids, every weapon type, PackageOf validates Co-Authored-By: Claude Sonnet 5.5 --- .../ShipModCatalogTests.cs | 6 ++++ .../Aetheria.Shared.Tests/ShipModPlanTests.cs | 31 ++++++++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index e474d0133..195af1c98 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -402,6 +402,12 @@ public sealed class ShipModCatalogTests : IDisposable Assert.Equal(Path.Combine(Mods, "mod.skiff", "skiff.glb"), package.ModelPath); Assert.Equal(ShipFixture.Nodes.Length, package.NodeIndices.Count); + // The shared validator judges the catalog's records too, not only a ship.cc. + hull.Prefab = "Djinni"; + Assert.Contains("names both a Unity prefab and a visual record", Assert.Throws(() => + ShipModCatalog.PackageOf(cache, hull, Mods)).Message); + hull.Prefab = null; + // A hull whose visual the catalog does not hold, and a mod whose GLB is gone, are refused. Assert.Contains("holds no visual record", Assert.Throws(() => ShipModCatalog.PackageOf(cache, new HullData { Name = "Ghost" }, Mods)).Message); diff --git a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs index db925a42a..b062b8de1 100644 --- a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs @@ -7,7 +7,7 @@ using Xunit; // ShipModPlan: which anchor fills which component slot. The Unity assembler only follows this. public sealed class ShipModPlanTests { - // Thruster, turret and radiator mounts on a 3x1 hull. Muzzles are listed out of Order on purpose. + // Thruster, turret and radiator mounts on a 3x1 hull. The muzzles' Order runs against their ids on purpose. private static ShipParts Rig() { var ship = ShipAuthoringTests.Fixture(); @@ -18,8 +18,8 @@ public sealed class ShipModPlanTests ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" }); ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Radiator, Position = new int2(1, 0), Shape = new Shape(), Transform = "fin" }); ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "articulation", ModelNodeId = "gun" }); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.b", Role = "weapon-muzzle", ModelNodeId = "gun-b", ParentId = "gun", Order = 1 }); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.a", Role = "weapon-muzzle", ModelNodeId = "gun-a", ParentId = "gun", Order = 0 }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.a", Role = "weapon-muzzle", ModelNodeId = "gun-a", ParentId = "gun", Order = 1 }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.z", Role = "weapon-muzzle", ModelNodeId = "gun-z", ParentId = "gun", Order = 0 }); ship.Visual.Anchors.Add(new ShipAnchor { Id = "fin", Role = "radiator-mesh", ModelNodeId = "fin" }); return ship; } @@ -38,7 +38,7 @@ public sealed class ShipModPlanTests Assert.Equal(new[] { "fin" }, plan.Radiators); var (mount, muzzles) = Assert.Single(plan.Weapons); Assert.Equal("gun", mount); - Assert.Equal(new[] { "gun.a", "gun.b" }, muzzles); + Assert.Equal(new[] { "gun.z", "gun.a" }, muzzles); } [Fact] @@ -70,18 +70,33 @@ public sealed class ShipModPlanTests public void MuzzlesBelongToWeaponMountsAndEveryWeaponHasOne() { var ship = Rig(); - ship.Visual.Anchors.Single(anchor => anchor.Id == "gun.a").ParentId = null; - Assert.Contains("muzzle gun.a must be parented to a weapon hardpoint's mount", Refusal(ship)); + ship.Visual.Anchors.Single(anchor => anchor.Id == "gun.z").ParentId = null; + Assert.Contains("muzzle gun.z must be parented to a weapon hardpoint's mount", Refusal(ship)); ship = Rig(); - ship.Visual.Anchors.Single(anchor => anchor.Id == "gun.a").ParentId = "thruster.port"; - Assert.Contains("muzzle gun.a must be parented to a weapon hardpoint's mount", Refusal(ship)); + ship.Visual.Anchors.Single(anchor => anchor.Id == "gun.z").ParentId = "thruster.port"; + Assert.Contains("muzzle gun.z must be parented to a weapon hardpoint's mount", Refusal(ship)); ship = Rig(); ship.Visual.Anchors.RemoveAll(anchor => anchor.Role == "weapon-muzzle"); Assert.Contains("weapon hardpoint gun needs at least one muzzle anchor", Refusal(ship)); } + [Theory] + [InlineData(HardpointType.Energy)] + [InlineData(HardpointType.Ballistic)] + [InlineData(HardpointType.Launcher)] + public void EveryWeaponHardpointTypeTakesMuzzles(HardpointType type) + { + var ship = Rig(); + ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "gun").Type = type; + Assert.Equal("gun", Assert.Single(Plan(ship).Weapons).Mount); + + // Any other type refuses the same muzzles. + ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "gun").Type = HardpointType.Tool; + Assert.Contains("must be parented to a weapon hardpoint's mount", Refusal(ship)); + } + [Fact] public void ABodyThatFailsTheSharedValidatorIsNeverPlanned() { From 81abd8ef2bf45fe9737e2c7f59b3c82fd7ea7984 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 07:53:38 +0200 Subject: [PATCH 019/113] Ship authoring doc: the game reads mod ships (S2) Co-Authored-By: Claude Sonnet 5.5 --- docs/moddable-ship-authoring.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 9b6a2914e..315ff22e1 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -111,8 +111,8 @@ Unity's **Aetheria / Preview Mod Ship Package** imports a validated package from disk, resolves stable GLB node IDs, and draws the captured polylines directly as 3D line segments. Point opacity and color are retained; radius and material-specific stroke styling are not -rendered yet. The current gameplay catalog and prefab loader do not read mod -records. +rendered yet. The game reads mod records through the boot path described +under Runtime catalog seam. ## Runtime catalog seam @@ -146,8 +146,11 @@ reference before atomically replacing the derived catalog. For example: dotnet run --project tools/AetherDb -- ship-authoring compose GameData/Aetheria.cc GameData/Aetheria.modded.cc GameData/Mods ``` -This command does not yet make the game open `Aetheria.modded.cc`; the runtime -loader and a complete playable ship are still outstanding. In the Quiet probe, +The game does not need this command: at boot, when `GameData/Mods` holds a +package, it composes the derived catalog into `Application.persistentDataPath` +(`ShipModCatalog.ResolveCatalog`) and opens that file instead of +`Aetheria.cc`; with no package it opens the shipped catalog. The derived file is +recomposed on every launch. A playable ship is still outstanding. In the Quiet probe, 206 shipped records became 208 derived records: one `mod-hull:quiet` and one `mod-ship:quiet`. A missing GLB node ID was rejected without changing the previous derived file. The probe's four anchors and zero hardpoints are only @@ -157,10 +160,20 @@ The visual importer uses [Unity glTFast's runtime import](https://github.com/Unity-Technologies/com.unity.cloud.gltfast/blob/main/Packages/com.unity.cloud.gltfast/Documentation~/ImportRuntime.md) to read the package GLB asynchronously and map its stable node IDs to Unity transforms. It keeps a partially loaded object inactive and destroys it on -failure. The remaining construction boundary must wire those transforms to -`ShipInstance` and preload visual prototypes before synchronous zone loads. -It must preserve `capturepreset` writes to the shipped catalog and use the -derived catalog only for gameplay reads. +failure. `ShipModShips` preloads one assembled, inactive prototype per hull +that names a `Visual`, at boot and before the game scene loads. `ShipModPlan` +decides which anchor fills which `ShipInstance` slot: the hull's hardpoint type +says what a mount is, and its anchor (id equal to the mount id) supplies the +node; a thruster mount needs a `thruster-emitter` anchor, a radiator mount a +`radiator-mesh` anchor, a weapon mount muzzle anchors parented to it. An +`articulation` anchor is an ordinary mount node until pivots move onto +`HullData`. The shared effects (shield, tractor beam, ping, explosion, map +and invisible materials) come from the Addressable `ShipModTemplate` prefab. +`ZoneRenderer.LoadEntity` clones the prototype when the hull names a `Visual`. +Gameplay reads a mod ship's records from the derived catalog; the package +directory supplies only the GLB. `capturepreset` still writes the shipped +catalog. Continue refuses a run naming a design the catalog no longer holds +(`RunSave.RequireDesigns`), naming the missing mod ships. ## Proof gates @@ -187,8 +200,7 @@ The proof reuses `HullData` simulation semantics, CultCache `.cc` persistence, Brokkr's Blender host, and the current `ShipInstance` behavior. It adds one authoring document type and validator, Blender-facing layout and line editing, a disposable catalog composer, and one runtime GLB/line visual importer with -an Editor preview. The remaining construction work is `ShipInstance` wiring -and boot-time preloading. No daemon, second gameplay simulation, or Unity +an Editor preview, `ShipInstance` assembly and boot-time preloading. No daemon, second gameplay simulation, or Unity prefab generator is added. The existing FBX builder remains available to existing content but is not an input to this lane. Its retirement follows migration. From 7eebd21d29463a018588c9e453808ed38f64b266 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 08:20:33 +0200 Subject: [PATCH 020/113] Ship S2: commit the .meta files for ShipModShips and ShipModPlan Unity generated them on import; without them the two scripts' GUIDs are unpinned and differ per checkout. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ShipModShips.cs.meta | 2 ++ Assets/Scripts/ServerShared/ShipModPlan.cs.meta | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 Assets/Scripts/Gameplay/ShipModShips.cs.meta create mode 100644 Assets/Scripts/ServerShared/ShipModPlan.cs.meta diff --git a/Assets/Scripts/Gameplay/ShipModShips.cs.meta b/Assets/Scripts/Gameplay/ShipModShips.cs.meta new file mode 100644 index 000000000..b58e73ac4 --- /dev/null +++ b/Assets/Scripts/Gameplay/ShipModShips.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b2c0124382433447b5a5679e8b91b57 \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/ShipModPlan.cs.meta b/Assets/Scripts/ServerShared/ShipModPlan.cs.meta new file mode 100644 index 000000000..6235b5279 --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipModPlan.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b45caa5f5ad9c164b8a295528f1b4951 \ No newline at end of file From 54917bb39d3fa4e911944e475840226ffbd49c70 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 08:30:52 +0200 Subject: [PATCH 021/113] Ship S2 fixes: structural anchors are not mounts, mod presets refused, one bad mod never bricks the boot - Validate: a hardpoint's anchor role is fixed by its type (thruster-emitter, radiator-mesh, else articulation); ShipModPlan's duplicate role check is deleted. - Loadouts.Commit refuses a preset naming a mod design (mod-hull:/mod-ship:), so the shipped catalog never references a mod. - Compose quarantines each package: a bad package is excluded and named, a collision excludes every package involved; the game boots on the rest (or the shipped catalog) and the main menu lists the exclusions. - RequireDesigns refuses a save with a null item or lot instead of throwing NullReferenceException. - Tests: per structural role, muzzle id ties, every slot/zone/lot in RequireDesigns, listing order, exclusion cases. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 7 +- Assets/Scripts/ServerShared/Loadout.cs | 5 +- Assets/Scripts/ServerShared/SavedGame.cs | 23 ++- Assets/Scripts/ServerShared/ShipAuthoring.cs | 9 +- Assets/Scripts/ServerShared/ShipModCatalog.cs | 99 +++++++++---- Assets/Scripts/ServerShared/ShipModPlan.cs | 11 +- Assets/Scripts/UI/MainMenu.cs | 7 + tests/Aetheria.Shared.Tests/LoadoutTests.cs | 23 +++ tests/Aetheria.Shared.Tests/RunSaveTests.cs | 111 ++++++++++++++ .../ShipAuthoringTests.cs | 45 ++++++ .../ShipModCatalogTests.cs | 138 ++++++++++++------ .../Aetheria.Shared.Tests/ShipModPlanTests.cs | 15 +- tools/AetherDb/ShipAuthoringCommands.cs | 7 +- 13 files changed, 408 insertions(+), 92 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index f12ea2633..a0c537f66 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -42,6 +42,9 @@ public class ActionGameManager : MonoBehaviour private static CultCache _cultCache; + // Mod packages left out of this boot (a bad package, or a name collision), for the main menu to name. + public static ShipModCatalog.Exclusion[] ModExclusions { get; private set; } = Array.Empty(); + private static string CatalogPath => Path.Combine(GameDataDirectory.FullName, "Aetheria.cc"); private static string ModsPath => Path.Combine(GameDataDirectory.FullName, "Mods"); // Disposable: composed at every boot from the shipped catalog and the mod packages, never authored. @@ -57,8 +60,10 @@ public class ActionGameManager : MonoBehaviour // inside this cache; reopening would replace the catalog instances the galaxy and live entities hold. // The catalog is read-only in every build; capturepreset writes through its own cache (Loadouts.Commit) to the // shipped file. With mod packages installed the game reads the derived catalog instead. + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(CatalogPath, DerivedCatalogPath, ModsPath); + ModExclusions = excluded; _cultCache = AetheriaStores.Open( - ShipModCatalog.ResolveCatalog(CatalogPath, DerivedCatalogPath, ModsPath), + catalog, runPath: Path.Combine(GameDataDirectory.FullName, "run.cc"), playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc")); diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index d8b868cf5..2775241fd 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -64,11 +64,14 @@ public static class Loadouts // The only writer of presets. It opens its own short-lived cache over the catalog file, writable, and disposes it // after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the // file; a cache that already has the catalog open sees the preset only once it reloads. - // Throws and writes nothing when the file is missing (capture never creates a catalog), when a preset of this name + // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a preset of this name // exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional // on the record at KeyOf(Name), so a preset changed on disk since this open is not clobbered: returns false then. public static bool Commit(string catalogPath, Loadout loadout, bool replace) { + var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray(); + if (modDesigns.Length > 0) + throw new InvalidOperationException($"Preset '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a preset."); if (!File.Exists(catalogPath)) throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a preset capture never creates one."); using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index ed5cdecf1..d5db1c533 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -96,13 +96,22 @@ public static class RunSave // built; the save is never edited to fit. public static void RequireDesigns(CultCache cache, SavedGame game) { - var named = (game.Zones ?? Array.Empty>()) - .SelectMany(zone => cache.Get(zone)?.Contents?.Entities ?? new List()) - .SelectMany(EntitySerializer.Items).Select(item => item.Data.Key) - .Concat(Lots(cache).Lots.Values.Select(lot => lot.Design.Key)) - .Where(key => key.IsSet()).Distinct() - .Where(key => cache.Get(key) == null) - .Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray(); + string[] named; + try + { + named = (game.Zones ?? Array.Empty>()) + .SelectMany(zone => cache.Get(zone)?.Contents?.Entities ?? new List()) + .SelectMany(EntitySerializer.Items).Select(item => item.Data.Key) + .Concat(Lots(cache).Lots.Values.Select(lot => lot.Design.Key)) + .Where(key => key.IsSet()).Distinct() + .Where(key => cache.Get(key) == null) + .Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray(); + } + catch (NullReferenceException error) + { + // A save with a null entity, item or lot where one is required is malformed, and refused like a missing design. + throw new InvalidOperationException("This run's save is malformed: an entity, item or lot is missing where one is required. Start a new game.", error); + } if (named.Length == 0) return; const string modHull = "mod-hull:"; var mods = named.Where(key => key.StartsWith(modHull, StringComparison.Ordinal)).Select(key => key.Substring(modHull.Length)).ToArray(); diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index ffdc560b3..d7263fd42 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -219,8 +219,15 @@ public static class ShipAuthoringStore if (!occupiedHardpointCells.Add((x, y))) throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} overlaps another hardpoint."); } - if (!anchors.Any(anchor => anchor.Id == hardpoint.Transform)) + var mountAnchor = anchors.FirstOrDefault(anchor => anchor.Id == hardpoint.Transform); + if (mountAnchor == null) throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has no model anchor."); + // A mount's anchor plays the mount, so its role is fixed by the hardpoint type; the structural roles (map-icon, + // hull-collider, shield, tractor, weapon-muzzle) name other nodes and never double as a mount. + var mountRole = hardpoint.Type == HardpointType.Thruster ? "thruster-emitter" : + hardpoint.Type == HardpointType.Radiator ? "radiator-mesh" : "articulation"; + if (mountAnchor.Role != mountRole) + throw new InvalidOperationException($"{ship.Id}: {hardpoint.Type} hardpoint {hardpoint.Transform} needs a {mountRole} anchor of that id."); } foreach (var anchor in anchors.Where(anchor => !string.IsNullOrEmpty(anchor.ParentId))) if (!mounts.Contains(anchor.ParentId)) diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index d3751feb8..7b3a44a1d 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -21,7 +21,31 @@ public static class ShipModCatalog public static CultRecordKey HullKey(string id) => new CultRecordKey("mod-hull:" + id); public static CultRecordKey AuthoringKey(string id) => new CultRecordKey("mod-ship:" + id); - public static int Compose(string shippedCatalog, string outputCatalog, string modsRoot) + // A key the mods own. The shipped catalog and its authored records never reference one: a mod may be uninstalled. + public static bool IsModKey(CultRecordKey key) => + key.IsSet() && (key.Value.StartsWith("mod-hull:", StringComparison.Ordinal) || key.Value.StartsWith("mod-ship:", StringComparison.Ordinal)); + + // A package left out of the composition, named by its directory, with every reason it was left out. + public sealed class Exclusion + { + public string Package; + public string Reason; + public override string ToString() => $"{Package}: {Reason}"; + } + + public sealed class Composition + { + // Ship IDs of the packages composed into the derived catalog, in directory order. + public string[] Included; + // Packages that failed on their own or collided with the catalog or with each other, in directory order. + public Exclusion[] Excluded; + } + + // One bad package never stops the rest. Each package is read and validated on its own; one that fails is excluded and + // named. A collision (with the shipped catalog, or with another package's hull name) excludes every package involved, + // since neither has a claim to the name. The derived catalog holds the shipped catalog plus the included packages. + // Unsafe inputs and outputs (a missing catalog, a derived file that would replace a source) still throw: nothing is composed. + public static Composition Compose(string shippedCatalog, string outputCatalog, string modsRoot) { var source = Path.GetFullPath(shippedCatalog); var output = Path.GetFullPath(outputCatalog); @@ -33,13 +57,21 @@ public static class ShipModCatalog if (output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("The derived catalog cannot replace a mod source or asset."); - var packages = new List(); + var reasons = new SortedDictionary>(StringComparer.Ordinal); + void Exclude(string package, string reason) + { + if (!reasons.TryGetValue(package, out var list)) reasons[package] = list = new List(); + list.Add(reason); + } + var packages = new List<(string Directory, Package Package)>(); foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal)) { var path = Path.Combine(directory, "ship.cc"); if (!File.Exists(path)) continue; - // ReadPackage pins each ID to its directory name, so IDs within one mods root are already unique. - packages.Add(ReadPackage(path)); + var name = Path.GetFileName(directory); + // A package can fail in any way a hostile or broken file can, so its failure is quarantined whatever it is. + try { packages.Add((name, ReadPackage(path))); } + catch (Exception error) { Exclude(name, error.Message); } } Directory.CreateDirectory(Path.GetDirectoryName(output)); @@ -51,33 +83,48 @@ public static class ShipModCatalog { Directory.CreateDirectory(workspace); File.Copy(source, temporary); + Composition composition; using (var cache = AetheriaStores.Open(temporary, catalogWritable: true)) { - foreach (var package in packages) + // Collisions are judged against the shipped records alone, before any package is written, so the + // verdict does not depend on directory order. CultCache indexes names per concrete type and lets the + // last writer win, and GetByName over a base type throws on a match across subtypes: a mod hull named + // like any shipped item, or like another mod's hull, would make name lookups wrong or throw. + var keys = new HashSet(cache.AllStoredDocuments.Select(record => record.Key)); + var itemNames = new HashSet(cache.GetAll().Select(item => item.Name), StringComparer.Ordinal); + var shipIds = new HashSet(cache.GetAll().Select(existing => existing.Id), StringComparer.Ordinal); + foreach (var (directory, package) in packages) { var (hull, ship) = (package.Hull, package.Visual); - var hullKey = HullKey(ship.Id); - var authoringKey = AuthoringKey(ship.Id); - if (cache.AllStoredDocuments.Any(record => record.Key.Equals(hullKey) || record.Key.Equals(authoringKey))) - throw new InvalidOperationException($"{ship.Id}: a mod key collides with an existing catalog record."); - // CultCache indexes names per concrete type and lets the last writer win, and GetByName over a base - // type throws on a match across subtypes. A mod hull named like any shipped item, or another mod's - // hull (upserted earlier in this loop), would make name lookups wrong or throw. - if (cache.GetAll().Any(item => string.Equals(item.Name, hull.Name, StringComparison.Ordinal))) - throw new InvalidOperationException($"{ship.Id}: hull name '{hull.Name}' collides with an existing catalog item."); - if (cache.GetAll().Any(existing => string.Equals(existing.Id, ship.Id, StringComparison.Ordinal))) - throw new InvalidOperationException($"{ship.Id}: ship ID collides with an existing catalog ship authoring record."); - // Verbatim: the mod store's own two records, under the keys it already holds them at. - ShipAuthoringStore.Write(cache, hull, ship); + if (keys.Contains(HullKey(ship.Id)) || keys.Contains(AuthoringKey(ship.Id))) + Exclude(directory, $"a mod key collides with an existing catalog record."); + if (itemNames.Contains(hull.Name)) + Exclude(directory, $"hull name '{hull.Name}' collides with an existing catalog item."); + if (shipIds.Contains(ship.Id)) + Exclude(directory, $"ship ID collides with an existing catalog ship authoring record."); } + foreach (var claim in packages.GroupBy(package => package.Package.Hull.Name, StringComparer.Ordinal).Where(group => group.Count() > 1)) + foreach (var (directory, package) in claim) + Exclude(directory, $"hull name '{claim.Key}' is also claimed by " + + string.Join(", ", claim.Where(other => other.Directory != directory).Select(other => other.Directory)) + "."); + + var included = packages.Where(package => !reasons.ContainsKey(package.Directory)).Select(package => package.Package).ToArray(); + foreach (var package in included) + // Verbatim: the mod store's own two records, under the keys it already holds them at. + ShipAuthoringStore.Write(cache, package.Hull, package.Visual); // Shipped hulls too: the derived catalog must not hold a hull that names two bodies. foreach (var hull in cache.GetAll()) ShipAuthoringStore.RequireOneBody(hull, hull.Name); cache.FlushAsync().GetAwaiter().GetResult(); + composition = new Composition + { + Included = included.Select(package => package.Visual.Id).ToArray(), + Excluded = reasons.Select(entry => new Exclusion { Package = entry.Key, Reason = string.Join("; ", entry.Value) }).ToArray() + }; } if (File.Exists(output)) File.Replace(temporary, output, null); else File.Move(temporary, output); - return packages.Count; + return composition; } finally { @@ -103,16 +150,16 @@ public static class ShipModCatalog return Bind(hull, ship, directory, Path.Combine(directory, "ship.cc")); } - // The one game-side choice of catalog: the shipped file when the mods root holds no package, otherwise the derived - // file, composed afresh from the shipped one and the packages. Recomposed every call, so the derived file is never - // stale and never an authority. - public static string ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot) + // The one game-side choice of catalog: the shipped file when no package is installed or none survives composition, + // otherwise the derived file, composed afresh from the shipped one and the surviving packages. Recomposed every + // call, so the derived file is never stale and never an authority. Excluded packages come back named, for the menu. + public static (string Catalog, Exclusion[] Excluded) ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot) { if (!Directory.Exists(modsRoot) || !Directory.GetDirectories(modsRoot).Any(directory => File.Exists(Path.Combine(directory, "ship.cc")))) - return shippedCatalog; - Compose(shippedCatalog, derivedCatalog, modsRoot); - return derivedCatalog; + return (shippedCatalog, Array.Empty()); + var composition = Compose(shippedCatalog, derivedCatalog, modsRoot); + return (composition.Included.Length == 0 ? shippedCatalog : derivedCatalog, composition.Excluded); } private static Package Bind(HullData hull, ShipAuthoring ship, string directory, string path) diff --git a/Assets/Scripts/ServerShared/ShipModPlan.cs b/Assets/Scripts/ServerShared/ShipModPlan.cs index e0c84c517..6b696bcaa 100644 --- a/Assets/Scripts/ServerShared/ShipModPlan.cs +++ b/Assets/Scripts/ServerShared/ShipModPlan.cs @@ -25,18 +25,15 @@ public sealed class ShipModPlan ShipAuthoringStore.Validate(hull, visual); var hardpoints = hull.Hardpoints ?? new List(); var byMount = hardpoints.ToDictionary(hardpoint => hardpoint.Transform, StringComparer.Ordinal); - var role = visual.Anchors.ToDictionary(anchor => anchor.Id, anchor => anchor.Role, StringComparer.Ordinal); string Only(string wanted) => visual.Anchors.Single(anchor => anchor.Role == wanted).Id; foreach (var anchor in visual.Anchors.Where(anchor => anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh")) { var wanted = anchor.Role == "thruster-emitter" ? HardpointType.Thruster : HardpointType.Radiator; - if (!byMount.TryGetValue(anchor.Id, out var mount) || mount.Type != wanted) + if (!byMount.ContainsKey(anchor.Id)) throw new InvalidOperationException($"{visual.Id}: {anchor.Role} anchor {anchor.Id} must be a {wanted} hardpoint's mount."); } - string[] Mounts(HardpointType type, string wantedRole) => hardpoints.Where(hardpoint => hardpoint.Type == type).Select(hardpoint => - role[hardpoint.Transform] == wantedRole ? hardpoint.Transform : - throw new InvalidOperationException($"{visual.Id}: {type} hardpoint {hardpoint.Transform} needs a {wantedRole} anchor of that id.")).ToArray(); + string[] Mounts(HardpointType type) => hardpoints.Where(hardpoint => hardpoint.Type == type).Select(hardpoint => hardpoint.Transform).ToArray(); var muzzles = visual.Anchors.Where(anchor => anchor.Role == "weapon-muzzle") .OrderBy(anchor => anchor.Order).ThenBy(anchor => anchor.Id, StringComparer.Ordinal).ToArray(); @@ -57,8 +54,8 @@ public sealed class ShipModPlan Shield = Only("shield"), Tractor = Only("tractor"), Equipment = hardpoints.Select(hardpoint => hardpoint.Transform).ToArray(), - Thrusters = Mounts(HardpointType.Thruster, "thruster-emitter"), - Radiators = Mounts(HardpointType.Radiator, "radiator-mesh"), + Thrusters = Mounts(HardpointType.Thruster), + Radiators = Mounts(HardpointType.Radiator), Weapons = weapons }; } diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index aedc0f5dc..9d17af138 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -89,6 +89,8 @@ public class MainMenu : MonoBehaviour _fadeFromRight = fromRight; } + private static bool _exclusionsShown; + private void ShowMain() { _nextMenu.panel.Clear(); @@ -97,6 +99,11 @@ public class MainMenu : MonoBehaviour { // A run exists only as the run store's SavedGame global. var cache = ActionGameManager.CultCache; + if (ActionGameManager.ModExclusions.Length > 0 && !_exclusionsShown) + { + _exclusionsShown = true; + Refuse("Some mods were not loaded", string.Join("\n", ActionGameManager.ModExclusions.Select(exclusion => exclusion.ToString()))); + } var saved = cache.GetGlobal(); if (saved == null && cache.AllStoredDocuments.Any(stored => RunSave.IsRunRecord(stored.Descriptor.DocumentType))) Debug.Log("run store has no SavedGame; Continue disabled"); diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 48cd75cbd..24450dfaf 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -122,6 +122,29 @@ public sealed class LoadoutTests : IDisposable Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout")); } + // The shipped catalog never references a mod: a mod may be uninstalled, and a preset naming one would dangle. The + // refusal is at the only writer, so no caller (the editor command included) can get one into the file. + [Theory] + [InlineData("hull", "mod-hull:mod.skiff")] + [InlineData("slot", "mod-hull:mod.skiff")] + [InlineData("slot", "mod-ship:mod.skiff")] + public void APresetNamingAModDesignIsRefusedAndNothingIsWritten(string where, string key) + { + var mod = new CultRecordRef(new CultRecordKey(key)); + using var cache = Open(); + var loadout = HandBuilt(cache); + if (where == "hull") loadout.Hull = new CultRecordRef(new CultRecordKey(key)); + else loadout.Slots[1].Design = mod; + var before = Hash(Catalog); + + var error = Assert.Throws(() => Loadouts.Commit(Catalog, loadout, replace: false)); + + Assert.Contains("names mod designs", error.Message); + Assert.Contains(key, error.Message); + Assert.Equal(before, Hash(Catalog)); + Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false)); + } + // Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates. [Fact] public void SameNameCaptureReplacesOnlyWhenAsked() diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index c69239670..3cc495c20 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -341,6 +341,117 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(before, File.ReadAllBytes(Run)); } + // --- RequireDesigns: what it walks, what it lists --------------------------------------------------------------- + + // Commits a run whose zones the caller builds, then returns RequireDesigns' refusal message, or null when it passes. + // `present` is a gear item ("Lamp", not a hull) the catalog holds; unit(design, lot) mints an item naming `design` + // whose lot names `lot`. + private string DesignsRefused(Func, Func, CultRecordRef, EquippableItem>, SavedZone[]> build) + { + CultRecordRef present; + using (var setup = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + var lamp = setup.GetAll().SingleOrDefault(gear => gear.Name == "Lamp") ?? + setup.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape() }); + present = new CultRecordRef(setup.RefOf(lamp).Key); + setup.FlushAsync().Wait(); + } + using var cache = Open(); + var ledger = new ProvenanceLedger(); + EquippableItem Unit(CultRecordRef design, CultRecordRef lot) + { + var number = ledger.NextLot++; + ledger.Lots[number] = new Lot { Design = lot, Origin = new Attributed() }; + return new EquippableItem { Data = design, Lot = number }; + } + ledger.NextLot = 1; + RunSave.Commit(cache, Game(cache), build(present, Unit), ledger); + try { RunSave.RequireDesigns(cache, cache.GetGlobal()); return null; } + catch (InvalidOperationException refusal) { return refusal.Message; } + } + + private static CultRecordRef Key(string key) => new CultRecordRef(new CultRecordKey(key)); + + private static SavedZone ZoneOf(params EntityPack[] packs) => new SavedZone + { + Name = "Zone", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1, + Contents = new ZonePack { Entities = packs.ToList() } + }; + + // Whatever the slot, a design missing from the catalog is found, and a design the catalog holds (a gear item, not + // only a hull) is not: the gate walks every unit an entity carries, recursively, and resolves any item design. + [Theory] + [InlineData("hull")] + [InlineData("equipment")] + [InlineData("cargo bay")] + [InlineData("docking bay")] + [InlineData("cargo contents")] + [InlineData("docking contents")] + [InlineData("child hull")] + [InlineData("child equipment")] + public void RequireDesignsFindsAMissingDesignInEverySlotOfAnEntity(string slot) + { + string Refusal(bool missing) => DesignsRefused((present, unit) => + { + EquippableItem Slot(string where) => unit(missing && where == slot ? Key("hull:gone") : present, present); + var child = BarePack(Slot("child hull")); + child.Equipment = new[] { (new int2(0, 0), Slot("child equipment")) }; + var pack = BarePack(Slot("hull"), new EntityPack[] { child }); + pack.Equipment = new[] { (new int2(0, 0), Slot("equipment")) }; + pack.CargoBays = new[] { (new int2(0, 0), Slot("cargo bay")) }; + pack.DockingBays = new[] { (new int2(0, 0), Slot("docking bay")) }; + pack.CargoContents = new[] { new (int2, ItemInstance)[] { (new int2(0, 0), Slot("cargo contents")) } }; + pack.DockingBayContents = new[] { new (int2, ItemInstance)[] { (new int2(0, 0), Slot("docking contents")) } }; + return new[] { ZoneOf(pack) }; + }); + + Assert.Null(Refusal(missing: false)); + Assert.Contains("missing other designs: hull:gone. Reinstall", Refusal(missing: true)); + } + + // Every zone and every lot is read, not the first: each names its own missing design. + [Fact] + public void RequireDesignsReadsEveryZoneAndEveryLot() + { + var refusal = DesignsRefused((present, unit) => new[] + { + ZoneOf(BarePack(unit(Key("hull:zone-a"), present))), + ZoneOf(BarePack(unit(present, present))), + ZoneOf(BarePack(unit(Key("hull:zone-b"), present)), BarePack(unit(present, Key("hull:lot-a")))), + ZoneOf(BarePack(unit(present, present)), BarePack(unit(present, Key("hull:lot-b")))) + }); + + Assert.Contains("missing other designs: hull:lot-a, hull:lot-b, hull:zone-a, hull:zone-b. Reinstall", refusal); + } + + // Each missing design is listed once however many units name it, ordinally, mod ships apart from the rest. + [Fact] + public void RequireDesignsListsEachMissingDesignOnceInStableOrder() + { + var refusal = DesignsRefused((present, unit) => new[] + { + ZoneOf(BarePack(unit(Key("hull:b"), Key("mod-hull:z"))), BarePack(unit(Key("mod-hull:z"), Key("hull:b")))), + ZoneOf(BarePack(unit(Key("hull:B"), Key("mod-hull:m"))), BarePack(unit(Key("hull:a"), Key("hull:b")))) + }); + + Assert.Equal("This run names designs the catalog no longer holds; missing mod ships: m, z; " + + "missing other designs: hull:B, hull:a, hull:b. Reinstall them, or start a new game.", refusal); + } + + // A save with a null where the walk needs an item is malformed: refused with a message, never a NullReferenceException. + [Fact] + public void RequireDesignsRefusesAMalformedSaveInsteadOfThrowingNull() + { + var refusal = DesignsRefused((present, unit) => + { + var pack = BarePack(unit(present, present)); + pack.Equipment = new[] { (new int2(0, 0), (EquippableItem) null) }; + return new[] { ZoneOf(pack) }; + }); + + Assert.Contains("save is malformed", refusal); + } + [Fact] public void ARunNamingAMissingShippedDesignIsRefusedToo() { diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 9e6ec129f..2d6680933 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -230,6 +230,51 @@ public sealed class ShipAuthoringTests Assert.Contains("records must be stored under", Assert.Throws(() => ShipAuthoringStore.Load(wrongVisualKey)).Message); } + // A mount's anchor plays the mount, so a structural anchor (the hull collider, the shield, a muzzle...) is never one. + // Interim ruling until an `equipment` role exists: a mount that is not a thruster or radiator is an articulation anchor. + [Theory] + [InlineData("map-icon", "map")] + [InlineData("hull-collider", "collider")] + [InlineData("shield", "shield")] + [InlineData("tractor", "tractor")] + [InlineData("weapon-muzzle", "muzzle")] + [InlineData("thruster-emitter", "emitter")] + [InlineData("radiator-mesh", "mesh")] + public void AStructuralAnchorCannotBeAnEquipmentMount(string role, string anchorId) + { + var ship = Fixture(); + // The extra anchors exist so the role-count and parent checks pass; the Tool hardpoint then claims the anchor. + ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "emitter", Role = "thruster-emitter", ModelNodeId = "emitter" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "mesh", Role = "radiator-mesh", ModelNodeId = "mesh" }); + ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Tool, Position = new int2(0, 0), Shape = new Shape(), Transform = anchorId }); + + Assert.Contains($"Tool hardpoint {anchorId} needs a articulation anchor of that id", Assert.Throws(ship.Validate).Message); + ship = Fixture(); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "tool", Role = "articulation", ModelNodeId = "tool" }); + ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Tool, Position = new int2(0, 0), Shape = new Shape(), Transform = "tool" }); + ship.Validate(); + } + + [Fact] + public void EveryHardpointTypeHasOneAnchorRoleForItsMount() + { + foreach (HardpointType type in Enum.GetValues(typeof(HardpointType))) + { + var wanted = type == HardpointType.Thruster ? "thruster-emitter" : type == HardpointType.Radiator ? "radiator-mesh" : "articulation"; + foreach (var role in new[] { "articulation", "thruster-emitter", "radiator-mesh" }) + { + var ship = Fixture(); + ship.Hull.Hardpoints.Clear(); + ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "thruster.port"); + ship.Hull.Hardpoints.Add(new HardpointData { Type = type, Position = new int2(0, 0), Shape = new Shape(), Transform = "mount" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" }); + if (role == wanted) ship.Validate(); + else Assert.Contains($"{type} hardpoint mount needs a {wanted} anchor of that id", Assert.Throws(ship.Validate).Message); + } + } + } + internal static ShipParts Fixture() { var shape = new Shape(2, 2); diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 195af1c98..825f63553 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -37,7 +37,7 @@ public sealed class ShipModCatalogTests : IDisposable Directory.CreateDirectory(Path.Combine(Mods, "notes")); var shippedBefore = Hash(Shipped); - Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods)); + Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length); Assert.Equal(shippedBefore, Hash(Shipped)); using var cache = AetheriaStores.Open(Derived); @@ -114,7 +114,7 @@ public sealed class ShipModCatalogTests : IDisposable var json = JObject.Parse(Encoding.UTF8.GetString(glb, 20, (int)BitConverter.ToUInt32(glb, 12))); Assert.Equal(0, (int)json["scene"]); Assert.Equal(Enumerable.Range(0, ShipFixture.Nodes.Length), json["scenes"][0]["nodes"].Select(node => (int)node)); - Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods)); + Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length); var export = Environment.GetEnvironmentVariable("AETHERIA_SHIP_FIXTURE_DIR"); if (string.IsNullOrEmpty(export)) return; @@ -127,42 +127,81 @@ public sealed class ShipModCatalogTests : IDisposable public void ComposeCountsEveryPackageAndReplacesAnExistingDerivedCatalog() { WritePackage("mod.skiff"); - Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods)); + Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length); WritePackage("mod.barge", hullName: "Barge"); - Assert.Equal(2, ShipModCatalog.Compose(Shipped, Derived, Mods)); + Assert.Equal(2, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length); using var cache = AetheriaStores.Open(Derived); Assert.Equal(new[] { "Barge", "Skiff", "Wasp" }, cache.GetAll().Select(hull => hull.Name).OrderBy(name => name)); AssertOnlyTheDerivedFile(); } + // One bad package never stops the game: it is left out and named, and the rest are composed. [Fact] - public void AFailedComposeLeavesThePreviousDerivedFileAndNoTemporaryFile() + public void ABadPackageIsExcludedAndNamedWhileTheGoodOnesCompose() { - WritePackage("mod.skiff"); - ShipModCatalog.Compose(Shipped, Derived, Mods); - var before = File.ReadAllBytes(Derived); + WritePackage("mod.good", hullName: "Good"); + WritePackage("mod.nomodel", hullName: "Nomodel", nodes: FixtureNodes.Where(node => node != "shield").ToArray()); + WritePackage("mod.nofile", hullName: "Nofile"); + File.Delete(Path.Combine(Mods, "mod.nofile", "skiff.glb")); + Directory.CreateDirectory(Path.Combine(Mods, "mod.garbage")); + File.WriteAllText(Path.Combine(Mods, "mod.garbage", "ship.cc"), "not a ship file"); - WritePackage("mod.barge", hullName: "Wasp"); - Assert.Contains("collides with an existing catalog item", Assert.Throws(() => - ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); - Assert.Equal(before, File.ReadAllBytes(Derived)); + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Equal(new[] { "mod.good" }, composition.Included); + Assert.Equal(new[] { "mod.garbage", "mod.nofile", "mod.nomodel" }, composition.Excluded.Select(exclusion => exclusion.Package)); + Assert.Contains("model has no node with aetheria.id=shield", composition.Excluded.Single(e => e.Package == "mod.nomodel").Reason); + Assert.Contains("must name an existing GLB", composition.Excluded.Single(e => e.Package == "mod.nofile").Reason); + Assert.Contains("mod.nofile: ", composition.Excluded.Single(e => e.Package == "mod.nofile").ToString()); + using (var cache = AetheriaStores.Open(Derived)) + Assert.Equal(new[] { "Good", "Wasp" }, cache.GetAll().Select(hull => hull.Name).OrderBy(name => name)); AssertOnlyTheDerivedFile(); + } - Directory.Delete(Path.Combine(Mods, "mod.barge"), true); - WritePackage("mod.barge", hullName: "Barge", nodes: FixtureNodes.Where(node => node != "shield").ToArray()); - Assert.Contains("model has no node with aetheria.id=shield", Assert.Throws(() => - ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); - Assert.Equal(before, File.ReadAllBytes(Derived)); + // A name collision leaves out every package that claims the name; the verdict does not depend on directory order. + [Fact] + public void ACollisionExcludesEveryPackageInvolvedAndNoneOfTheRest() + { + WritePackage("mod.a", hullName: "Skiff"); + WritePackage("mod.b", hullName: "Skiff"); + WritePackage("mod.c", hullName: "Skiff"); + WritePackage("mod.d", hullName: "Wasp"); + WritePackage("mod.e", hullName: "Fine"); + + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Equal(new[] { "mod.e" }, composition.Included); + Assert.Equal(new[] { "mod.a", "mod.b", "mod.c", "mod.d" }, composition.Excluded.Select(exclusion => exclusion.Package)); + Assert.Equal("hull name 'Skiff' is also claimed by mod.b, mod.c.", composition.Excluded[0].Reason); + Assert.Equal("hull name 'Skiff' is also claimed by mod.a, mod.c.", composition.Excluded[1].Reason); + Assert.Equal("hull name 'Wasp' collides with an existing catalog item.", composition.Excluded[3].Reason); + using var cache = AetheriaStores.Open(Derived); + Assert.Equal(new[] { "Fine", "Wasp" }, cache.GetAll().Select(hull => hull.Name).OrderBy(name => name)); + } + + // A package that fails for two reasons names both. + [Fact] + public void APackageThatCollidesTwiceNamesBothReasons() + { + WritePackage("mod.a", hullName: "Wasp"); + WritePackage("mod.b", hullName: "Wasp"); + + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Empty(composition.Included); + Assert.Equal("hull name 'Wasp' collides with an existing catalog item.; hull name 'Wasp' is also claimed by mod.b.", + composition.Excluded[0].Reason); } [Theory] [InlineData("mod-hull:mod.skiff", false)] [InlineData("mod-ship:mod.skiff", true)] - public void ComposeRefusesAModKeyThatAlreadyExistsInTheCatalog(string key, bool authoring) + public void APackageWhoseKeyAlreadyExistsInTheCatalogIsExcluded(string key, bool authoring) { WritePackage("mod.skiff"); + WritePackage("mod.other", hullName: "Other"); using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) { if (authoring) @@ -176,15 +215,17 @@ public sealed class ShipModCatalogTests : IDisposable cache.FlushAsync().Wait(); } - Assert.Contains("a mod key collides", Assert.Throws(() => - ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); - Assert.False(File.Exists(Derived)); + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Equal(new[] { "mod.other" }, composition.Included); + Assert.Contains("a mod key collides", Assert.Single(composition.Excluded).Reason); + Assert.Equal("mod.skiff", composition.Excluded[0].Package); } [Theory] [InlineData(typeof(HullData))] [InlineData(typeof(GearData))] - public void ComposeRefusesAHullNamedLikeAShippedItem(Type shippedType) + public void APackageWithAHullNamedLikeAShippedItemIsExcluded(Type shippedType) { using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) { @@ -196,24 +237,14 @@ public sealed class ShipModCatalogTests : IDisposable } WritePackage("mod.skiff"); - Assert.Contains("hull name 'Skiff' collides with an existing catalog item", Assert.Throws(() => - ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); - Assert.False(File.Exists(Derived)); + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Empty(composition.Included); + Assert.Equal("hull name 'Skiff' collides with an existing catalog item.", Assert.Single(composition.Excluded).Reason); } [Fact] - public void ComposeRefusesTwoModsThatShareAHullName() - { - WritePackage("mod.a", hullName: "Skiff"); - WritePackage("mod.b", hullName: "Skiff"); - - Assert.Contains("mod.b: hull name 'Skiff' collides", Assert.Throws(() => - ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); - Assert.False(File.Exists(Derived)); - } - - [Fact] - public void ComposeRefusesAShipIdThatAShippedAuthoringRecordAlreadyUses() + public void APackageWhoseShipIdAShippedAuthoringRecordAlreadyUsesIsExcluded() { using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) { @@ -222,12 +253,29 @@ public sealed class ShipModCatalogTests : IDisposable } WritePackage("mod.skiff", hullName: "Different"); - Assert.Contains("ship ID collides with an existing catalog ship authoring record", Assert.Throws(() => - ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); - Assert.False(File.Exists(Derived)); + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Empty(composition.Included); + Assert.Equal("ship ID collides with an existing catalog ship authoring record.", Assert.Single(composition.Excluded).Reason); } + // The game's boot: a bad package is named and the rest boot; with none left, the shipped catalog boots. [Fact] + public void TheBootCatalogSurvivesBadPackages() + { + WritePackage("mod.good", hullName: "Good"); + WritePackage("mod.bad", hullName: "Bad", nodes: FixtureNodes.Where(node => node != "shield").ToArray()); + + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods); + Assert.Equal(Derived, catalog); + Assert.Equal("mod.bad", Assert.Single(excluded).Package); + + Directory.Delete(Path.Combine(Mods, "mod.good"), true); + (catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods); + Assert.Equal(Shipped, catalog); + Assert.Equal("mod.bad", Assert.Single(excluded).Package); + } + public void ComposeRefusesUnsafeInputsAndOutputs() { WritePackage("mod.skiff"); @@ -366,21 +414,21 @@ public sealed class ShipModCatalogTests : IDisposable [Fact] public void TheBootCatalogIsTheShippedOneUntilAModIsInstalled() { - Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Path.Combine(_directory.Path, "absent"))); + Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Path.Combine(_directory.Path, "absent")).Catalog); Directory.CreateDirectory(Path.Combine(Mods, "notes")); - Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods)); + Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog); Assert.False(File.Exists(Derived)); WritePackage("mod.skiff"); - Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods)); + Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog); using (var cache = AetheriaStores.Open(Derived)) Assert.Contains("Skiff", cache.GetAll().Select(hull => hull.Name)); // Recomposed on every call: a mod removed since the last boot is gone from the file the game opens next. Directory.Delete(Path.Combine(Mods, "mod.skiff"), true); - Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods)); + Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog); WritePackage("mod.barge", hullName: "Barge"); - Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods)); + Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog); using (var cache = AetheriaStores.Open(Derived)) Assert.Equal(new[] { "Barge", "Wasp" }, cache.GetAll().Select(hull => hull.Name).OrderBy(name => name)); } diff --git a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs index b062b8de1..c98ff3a10 100644 --- a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs @@ -59,13 +59,26 @@ public sealed class ShipModPlanTests // A thruster-emitter anchor on a weapon mount, and a radiator-mesh anchor that is no mount at all. var ship = Rig(); ship.Visual.Anchors.Single(anchor => anchor.Id == "gun").Role = "thruster-emitter"; - Assert.Contains("thruster-emitter anchor gun must be a Thruster hardpoint's mount", Refusal(ship)); + Assert.Contains("Energy hardpoint gun needs a articulation anchor of that id", Refusal(ship)); ship = Rig(); ship.Visual.Anchors.Add(new ShipAnchor { Id = "loose", Role = "radiator-mesh", ModelNodeId = "loose" }); Assert.Contains("radiator-mesh anchor loose must be a Radiator hardpoint's mount", Refusal(ship)); } + // Equal Order falls back to the id, compared ordinally, whatever order the anchors were authored in. + [Fact] + public void MuzzlesOfEqualOrderAreTiedByIdOrdinally() + { + var ship = Rig(); + ship.Visual.Anchors.RemoveAll(anchor => anchor.Role == "weapon-muzzle"); + foreach (var id in new[] { "gun.c", "gun.a", "gun.B", "gun.b" }) + ship.Visual.Anchors.Add(new ShipAnchor { Id = id, Role = "weapon-muzzle", ModelNodeId = id, ParentId = "gun", Order = 2 }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.z", Role = "weapon-muzzle", ModelNodeId = "gun.z", ParentId = "gun", Order = 1 }); + + Assert.Equal(new[] { "gun.z", "gun.B", "gun.a", "gun.b", "gun.c" }, Assert.Single(Plan(ship).Weapons).Muzzles); + } + [Fact] public void MuzzlesBelongToWeaponMountsAndEveryWeaponHasOne() { diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index e6b380ed3..52a8f75fd 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -18,9 +18,10 @@ public static class ShipAuthoringCommands { if (args[0] == "compose" && args.Length == 4) { - var count = ShipModCatalog.Compose(args[1], args[2], args[3]); - Console.WriteLine($"Composed {count} mod ships into {Path.GetFullPath(args[2])}"); - return 0; + var composition = ShipModCatalog.Compose(args[1], args[2], args[3]); + Console.WriteLine($"Composed {composition.Included.Length} mod ships into {Path.GetFullPath(args[2])}"); + foreach (var exclusion in composition.Excluded) Console.Error.WriteLine($"Excluded {exclusion}"); + return composition.Excluded.Length == 0 ? 0 : 1; } if (args[0] == "inspect" && args.Length == 2) { From 5ed3b759c4cf062c06d8270e44a72bb956747646 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 08:31:24 +0200 Subject: [PATCH 022/113] Ship S2 fixes: the RunSave designs fixture resolves its gear ref Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 3cc495c20..13f5b50d8 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -351,9 +351,10 @@ public sealed class RunSaveTests : IDisposable CultRecordRef present; using (var setup = AetheriaStores.Open(Catalog, catalogWritable: true)) { - var lamp = setup.GetAll().SingleOrDefault(gear => gear.Name == "Lamp") ?? + var existing = setup.GetAll().SingleOrDefault(gear => gear.Name == "Lamp"); + var lamp = existing != null ? setup.RefOf(existing) : setup.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape() }); - present = new CultRecordRef(setup.RefOf(lamp).Key); + present = new CultRecordRef(lamp.Key); setup.FlushAsync().Wait(); } using var cache = Open(); From 8f8b72be3c3b266facc5beefbeb834a4a631d7de Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 08:34:54 +0200 Subject: [PATCH 023/113] Ship S2 fixes: the edge-of-range hardpoint test re-roles its anchor with its type Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ShipValidationTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index c76df98a9..eb150939a 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -34,6 +34,7 @@ public sealed class ShipValidationTests ship.Hull.Hardpoints[0].Armor = 5; ship.Hull.Hardpoints[0].FiringArc = 360; ship.Hull.Hardpoints[0].Type = HardpointType.AetherDrive; + ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Role = "articulation"; ship.Hull.Hardpoints[0].Rotation = ItemRotation.Clockwise; ship.Validate(); } From 34319b0df1e67127c4937ca8e81524c4aef40f2d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 10:06:16 +0200 Subject: [PATCH 024/113] Ship S2 fixes: RequireDesigns refuses nulls explicitly, and a failing Compose falls back to the shipped catalog - RequireDesigns names the null (entity, item, zone record, lot, lot ledger) instead of catching NullReferenceException. - ResolveCatalog reports a Compose-level failure as an exclusion covering all mods and boots the shipped catalog. Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/EntitySerializer.cs | 1 + Assets/Scripts/ServerShared/SavedGame.cs | 30 +++++++------ Assets/Scripts/ServerShared/ShipModCatalog.cs | 5 ++- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 44 +++++++++++++++---- .../ShipModCatalogTests.cs | 20 +++++++++ 5 files changed, 77 insertions(+), 23 deletions(-) diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 4b727eee4..6fb749b29 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -128,6 +128,7 @@ public static class EntitySerializer // docking bay units, and their stored contents. The provenance ledger's GC root walk. public static IEnumerable Items(EntityPack pack) { + if (pack == null) throw new InvalidOperationException("This run's save is malformed: an entity is null. Start a new game."); yield return pack.Hull; foreach (var (_, item) in pack.Equipment) yield return item; foreach (var (_, item) in pack.CargoBays) yield return item; diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index d5db1c533..71bd625af 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -96,22 +96,21 @@ public static class RunSave // built; the save is never edited to fit. public static void RequireDesigns(CultCache cache, SavedGame game) { - string[] named; - try + var designs = new List(); + foreach (var zoneRef in game.Zones ?? Array.Empty>()) { - named = (game.Zones ?? Array.Empty>()) - .SelectMany(zone => cache.Get(zone)?.Contents?.Entities ?? new List()) - .SelectMany(EntitySerializer.Items).Select(item => item.Data.Key) - .Concat(Lots(cache).Lots.Values.Select(lot => lot.Design.Key)) - .Where(key => key.IsSet()).Distinct() - .Where(key => cache.Get(key) == null) - .Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray(); - } - catch (NullReferenceException error) - { - // A save with a null entity, item or lot where one is required is malformed, and refused like a missing design. - throw new InvalidOperationException("This run's save is malformed: an entity, item or lot is missing where one is required. Start a new game.", error); + var where = zoneRef.Key.Value; + var zone = cache.Get(zoneRef) ?? throw MalformedSave($"zone {where} has no record"); + // A zone never visited has no contents: legitimately null. + foreach (var pack in zone.Contents?.Entities ?? new List()) + foreach (var item in EntitySerializer.Items(pack)) + designs.Add((item ?? throw MalformedSave($"an item in {where} is null")).Data.Key); } + foreach (var (number, lot) in Lots(cache).Lots ?? throw MalformedSave("the lot ledger has no lots")) + designs.Add((lot ?? throw MalformedSave($"lot {number} is null")).Design.Key); + var named = designs.Where(key => key.IsSet()).Distinct() + .Where(key => cache.Get(key) == null) + .Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray(); if (named.Length == 0) return; const string modHull = "mod-hull:"; var mods = named.Where(key => key.StartsWith(modHull, StringComparison.Ordinal)).Select(key => key.Substring(modHull.Length)).ToArray(); @@ -122,6 +121,9 @@ public static class RunSave ". Reinstall them, or start a new game."); } + private static InvalidOperationException MalformedSave(string where) => + new InvalidOperationException($"This run's save is malformed: {where}. Start a new game."); + // The stored ledger, or a fresh empty one when the run has minted nothing yet. public static ProvenanceLedger Lots(CultCache cache) => cache.GetGlobal() ?? new ProvenanceLedger(); diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 7b3a44a1d..ee80add89 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -158,7 +158,10 @@ public static class ShipModCatalog if (!Directory.Exists(modsRoot) || !Directory.GetDirectories(modsRoot).Any(directory => File.Exists(Path.Combine(directory, "ship.cc")))) return (shippedCatalog, Array.Empty()); - var composition = Compose(shippedCatalog, derivedCatalog, modsRoot); + Composition composition; + // A failure of the composition itself (an unwritable derived path, say) is still a mod problem, never a reason not to boot. + try { composition = Compose(shippedCatalog, derivedCatalog, modsRoot); } + catch (Exception error) { return (shippedCatalog, new[] { new Exclusion { Package = "(all mods)", Reason = error.Message } }); } return (composition.Included.Length == 0 ? shippedCatalog : derivedCatalog, composition.Excluded); } diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 13f5b50d8..9384b93ed 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -439,18 +439,46 @@ public sealed class RunSaveTests : IDisposable "missing other designs: hull:B, hull:a, hull:b. Reinstall them, or start a new game.", refusal); } - // A save with a null where the walk needs an item is malformed: refused with a message, never a NullReferenceException. - [Fact] - public void RequireDesignsRefusesAMalformedSaveInsteadOfThrowingNull() + // A save can hold a null where the gate needs a value: refused with a message that says where, never a + // NullReferenceException. The records are written straight to the run store, since a save never writes them itself. + [Theory] + [InlineData("item", "an item in savedzone-0 is null")] + [InlineData("entity", "an entity is null")] + [InlineData("zone", "zone savedzone-1 has no record")] + [InlineData("lot", "lot 2 is null")] + [InlineData("lots", "the lot ledger has no lots")] + public void RequireDesignsRefusesANullWhereTheWalkNeedsAValue(string kind, string where) { - var refusal = DesignsRefused((present, unit) => + using var cache = Open(); + var pack = BarePack(new EquippableItem { Data = Key("hull:none"), Lot = 1 }); + if (kind == "item") pack.Equipment = new[] { (new int2(0, 0), (EquippableItem) null) }; + if (kind == "entity") pack.Children = new EntityPack[] { null }; + var ledger = new ProvenanceLedger { NextLot = 3, Lots = { [1] = new Lot { Origin = new Attributed() } } }; + if (kind == "lot") ledger.Lots[2] = null; + if (kind == "lots") ledger.Lots = null; + var game = Game(cache); + game.Zones = new[] { new CultRecordRef(new CultRecordKey("savedzone-0")), new CultRecordRef(new CultRecordKey("savedzone-1")) }; + cache.Commit(batch => { - var pack = BarePack(unit(present, present)); - pack.Equipment = new[] { (new int2(0, 0), (EquippableItem) null) }; - return new[] { ZoneOf(pack) }; + batch.Upsert(typeof(SavedZone), ZoneOf(pack), new CultRecordKey("savedzone-0")); + if (kind != "zone") batch.Upsert(typeof(SavedZone), ZoneOf(), new CultRecordKey("savedzone-1")); + batch.Upsert(game); + batch.Upsert(ledger); }); - Assert.Contains("save is malformed", refusal); + var refusal = Assert.Throws(() => RunSave.RequireDesigns(cache, cache.GetGlobal())); + Assert.Contains("save is malformed: " + where, refusal.Message); + } + + // The same run with nothing null is not malformed: null contents (a zone never visited) and unset designs are legitimate. + [Fact] + public void RequireDesignsAcceptsAZoneNeverVisitedAndAnUnsetDesign() + { + Assert.Null(DesignsRefused((present, unit) => new[] + { + new SavedZone { Name = "Never", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1 }, + ZoneOf(BarePack(unit(default, default))) + })); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 825f63553..7db843d30 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -433,6 +433,26 @@ public sealed class ShipModCatalogTests : IDisposable Assert.Equal(new[] { "Barge", "Wasp" }, cache.GetAll().Select(hull => hull.Name).OrderBy(name => name)); } + // A failure of the composition itself is a mod problem too: the game boots on the shipped catalog and names it. + [Fact] + public void TheBootCatalogSurvivesAComposeThatFails() + { + WritePackage("mod.skiff"); + var blocker = Path.Combine(_directory.Path, "blocker"); + File.WriteAllText(blocker, "a file where the derived catalog's directory should be"); + + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Path.Combine(blocker, "Aetheria.modded.cc"), Mods); + + Assert.Equal(Shipped, catalog); + var exclusion = Assert.Single(excluded); + Assert.Equal("(all mods)", exclusion.Package); + Assert.False(string.IsNullOrWhiteSpace(exclusion.Reason)); + // An invalid output (the shipped catalog itself) is refused the same way, with its reason. + (catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Shipped, Mods); + Assert.Equal(Shipped, catalog); + Assert.Contains("cannot replace the shipped catalog", Assert.Single(excluded).Reason); + } + // Gameplay reads a mod ship's records from the derived catalog; the package directory supplies only the GLB. [Fact] public void PackageOfTakesItsRecordsFromTheCatalogAndOnlyTheGlbFromTheMod() From 54dcf5214c9b7fbba0edd9204d4a537a3a35e428 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 10:52:47 +0200 Subject: [PATCH 025/113] Ship S2 smoke fixture: the generated GLB carries a tetrahedron mesh on the map, collider and thruster anchors, plus a weapon mount and muzzle The fixture package now has real vertices (BIN chunk written by the generator, no committed bytes), an energy hardpoint "gun" with a muzzle anchor, and the thruster moved outside the schematic, so ShipModShips.Assemble accepts it. Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ShipFixture.cs | 54 +++++++++++++++---- .../ShipModCatalogTests.cs | 27 ++++++++++ 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipFixture.cs b/tests/Aetheria.Shared.Tests/ShipFixture.cs index eb8945780..6f32b6389 100644 --- a/tests/Aetheria.Shared.Tests/ShipFixture.cs +++ b/tests/Aetheria.Shared.Tests/ShipFixture.cs @@ -2,33 +2,62 @@ using System; using System.IO; using System.Linq; using System.Text; +using CultMath; -// Writes the mod package the tests, the Python round-trip check and the Unity smoke (ShipModPreview.Smoke) share. It is -// generated from ShipAuthoringTests.Fixture() so no opaque bytes are committed. +// Writes the mod package the tests, the Python round-trip check and the Unity smokes (ShipModPreview.Smoke and +// ShipModPlaySmoke) share. It is generated from ShipAuthoringTests.Fixture() so no opaque bytes are committed. internal static class ShipFixture { - public static readonly string[] Nodes = { "map", "collider", "shield", "tractor", "thruster-port" }; + public static readonly string[] Nodes = { "map", "collider", "shield", "tractor", "thruster-port", "gun", "gun-muzzle" }; + + // The nodes that carry the fixture mesh: the anchors the assembler reads a renderer or a collider mesh from. + public static readonly string[] MeshNodes = { "map", "collider", "thruster-port" }; + + // One tetrahedron, non-indexed (12 vertices), so a convex MeshCollider can cook it. Written by the generator. + private static readonly float[] Tetrahedron = + { + 0, 0, 0, 0, 1, 0, 1, 0, 0, + 0, 0, 0, 1, 0, 0, 0, 0, 1, + 0, 0, 0, 0, 0, 1, 0, 1, 0, + 1, 0, 0, 0, 1, 0, 0, 0, 1 + }; + + public static byte[] MeshBytes() => Tetrahedron.SelectMany(BitConverter.GetBytes).ToArray(); // The GLB carries a glTF scene naming every node: glTFast instantiates a scene, and a scene-less file has none. - public static string GlbJson(string[] nodes) => - @"{""asset"":{""version"":""2.0""},""scene"":0,""scenes"":[{""nodes"":[" + string.Join(",", nodes.Select((_, index) => index)) + @"]}],""nodes"":[" + - string.Join(",", nodes.Select(node => $@"{{""name"":""{node}"",""extras"":{{""aetheria.id"":""{node}""}}}}")) + "]}"; + // Nodes in MeshNodes get the shared tetrahedron mesh, whose vertices live in the BIN chunk (MeshBytes). + public static string GlbJson(string[] nodes) + { + var length = MeshBytes().Length; + var mesh = nodes.Any(MeshNodes.Contains) + ? @",""meshes"":[{""primitives"":[{""attributes"":{""POSITION"":0}}]}]," + + @"""accessors"":[{""bufferView"":0,""componentType"":5126,""count"":12,""type"":""VEC3"",""min"":[0,0,0],""max"":[1,1,1]}]," + + $@"""bufferViews"":[{{""buffer"":0,""byteLength"":{length}}}],""buffers"":[{{""byteLength"":{length}}}]" + : ""; + return @"{""asset"":{""version"":""2.0""},""scene"":0,""scenes"":[{""nodes"":[" + string.Join(",", nodes.Select((_, index) => index)) + @"]}]" + mesh + @",""nodes"":[" + + string.Join(",", nodes.Select(node => + $@"{{""name"":""{node}""{(MeshNodes.Contains(node) ? @",""mesh"":0" : "")},""extras"":{{""aetheria.id"":""{node}""}}}}")) + "]}"; + } - public static byte[] Glb(string json) + public static byte[] Glb(string json, byte[] bin = null) { var body = Encoding.UTF8.GetBytes(json); var padded = new byte[(body.Length + 3) / 4 * 4]; Array.Fill(padded, (byte)' '); body.CopyTo(padded, 0); - var bytes = new byte[20 + padded.Length + 8]; + bin ??= Array.Empty(); + var binPadded = new byte[(bin.Length + 3) / 4 * 4]; + bin.CopyTo(binPadded, 0); + var bytes = new byte[28 + padded.Length + binPadded.Length]; BitConverter.GetBytes(0x46546C67u).CopyTo(bytes, 0); BitConverter.GetBytes(2u).CopyTo(bytes, 4); BitConverter.GetBytes((uint)bytes.Length).CopyTo(bytes, 8); BitConverter.GetBytes((uint)padded.Length).CopyTo(bytes, 12); BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16); padded.CopyTo(bytes, 20); - BitConverter.GetBytes(0u).CopyTo(bytes, 20 + padded.Length); + BitConverter.GetBytes((uint)binPadded.Length).CopyTo(bytes, 20 + padded.Length); BitConverter.GetBytes(0x004E4942u).CopyTo(bytes, 24 + padded.Length); + binPadded.CopyTo(bytes, 28 + padded.Length); return bytes; } @@ -37,6 +66,11 @@ internal static class ShipFixture string modelAsset = "skiff.glb", Action tweak = null) { var ship = ShipAuthoringTests.Fixture(); + // A playable rig: the thruster outside the schematic, one energy weapon mount inside it, with its muzzle. + ship.Hull.Hardpoints[0].Position = new int2(2, 0); + ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "articulation", ModelNodeId = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.muzzle", Role = "weapon-muzzle", ModelNodeId = "gun-muzzle", ParentId = "gun" }); tweak?.Invoke(ship); ship.WithId(id); ship.Hull.Name = hullName; @@ -48,7 +82,7 @@ internal static class ShipFixture ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual); cache.FlushAsync().Wait(); } - File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(GlbJson(nodes ?? Nodes))); + File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(GlbJson(nodes ?? Nodes), MeshBytes())); return directory; } } diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 7db843d30..7598ba4cf 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -123,6 +123,33 @@ public sealed class ShipModCatalogTests : IDisposable foreach (var name in new[] { "ship.cc", "skiff.glb" }) File.Copy(Path.Combine(directory, name), Path.Combine(target, name), true); } + [Fact] + public void TheFixtureGlbCarriesARealMeshOnEveryAnchorTheAssemblerReadsOneFrom() + { + var package = ShipModCatalog.ReadPackage(Path.Combine(ShipFixture.WritePackage(Mods, "mod.skiff"), "ship.cc")); + var glb = File.ReadAllBytes(package.ModelPath); + var jsonLength = (int)BitConverter.ToUInt32(glb, 12); + var json = JObject.Parse(Encoding.UTF8.GetString(glb, 20, jsonLength)); + var binLength = (int)BitConverter.ToUInt32(glb, 20 + jsonLength); + Assert.Equal(0x004E4942u, BitConverter.ToUInt32(glb, 24 + jsonLength)); + Assert.Equal(glb.Length, 28 + jsonLength + binLength); + Assert.Equal((int)json["buffers"][0]["byteLength"], ShipFixture.MeshBytes().Length); + Assert.True(binLength >= ShipFixture.MeshBytes().Length); + + var roles = new[] { "map-icon", "hull-collider", "thruster-emitter", "radiator-mesh" }; + var needed = package.Visual.Anchors.Where(anchor => roles.Contains(anchor.Role)).ToArray(); + Assert.Contains(needed, anchor => anchor.Role == "thruster-emitter"); + foreach (var anchor in needed) + { + var node = json["nodes"][(int)package.NodeIndices[anchor.ModelNodeId]]; + var mesh = json["meshes"][(int)node["mesh"]]; + var accessor = json["accessors"][(int)mesh["primitives"][0]["attributes"]["POSITION"]]; + Assert.True((int)accessor["count"] >= 4, anchor.Id); + } + Assert.Contains(package.Visual.Anchors, anchor => anchor.Role == "weapon-muzzle" && anchor.ParentId == "gun"); + Assert.Contains(package.Hull.Hardpoints, hardpoint => hardpoint.Type == HardpointType.Energy && hardpoint.Transform == "gun"); + } + [Fact] public void ComposeCountsEveryPackageAndReplacesAnExistingDerivedCatalog() { From 5b58b947bde91c252f067f7994fb2d7fddfc820c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 10:53:26 +0200 Subject: [PATCH 026/113] Ship S2 smoke fixture: the weapon mount takes the hull other cell instead of moving the thruster off the schematic Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ShipFixture.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipFixture.cs b/tests/Aetheria.Shared.Tests/ShipFixture.cs index 6f32b6389..eb4f5c01e 100644 --- a/tests/Aetheria.Shared.Tests/ShipFixture.cs +++ b/tests/Aetheria.Shared.Tests/ShipFixture.cs @@ -66,8 +66,7 @@ internal static class ShipFixture string modelAsset = "skiff.glb", Action tweak = null) { var ship = ShipAuthoringTests.Fixture(); - // A playable rig: the thruster outside the schematic, one energy weapon mount inside it, with its muzzle. - ship.Hull.Hardpoints[0].Position = new int2(2, 0); + // A playable rig: the fixture thruster plus one energy weapon mount on the hull's other cell, with its muzzle. ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" }); ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "articulation", ModelNodeId = "gun" }); ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.muzzle", Role = "weapon-muzzle", ModelNodeId = "gun-muzzle", ParentId = "gun" }); From 4f6bbbb8da783e39ffaffc9edbf5ceb97e61c5b1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 10:54:02 +0200 Subject: [PATCH 027/113] Ship S2 smoke fixture: the node index test names the weapon nodes Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 7598ba4cf..82cca60f5 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -328,7 +328,7 @@ public sealed class ShipModCatalogTests : IDisposable var package = ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc")); Assert.Equal("mod.skiff", package.Visual.Id); Assert.Equal(Path.GetFullPath(Path.Combine(Mods, "mod.skiff", "skiff.glb")), package.ModelPath); - Assert.Equal(new Dictionary { ["map"] = 0, ["collider"] = 1, ["shield"] = 2, ["tractor"] = 3, ["thruster-port"] = 4 }, package.NodeIndices); + Assert.Equal(new Dictionary { ["map"] = 0, ["collider"] = 1, ["shield"] = 2, ["tractor"] = 3, ["thruster-port"] = 4, ["gun"] = 5, ["gun-muzzle"] = 6 }, package.NodeIndices); } [Fact] From 910b51c3f8cacfc595e4dd67900180957fde5893 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 10:57:47 +0200 Subject: [PATCH 028/113] Ship S2: an Editor smoke that composes, preloads and instantiates a mod ship the way ZoneRenderer.LoadEntity does ShipModPlaySmoke.Run asserts thruster and weapon hardpoints, the hull MeshCollider and the map icon, prints SHIP_MOD_PLAY_SMOKE and exits explicitly. The authoring doc gains a Unity batch smokes section. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/Editor/ShipModPlaySmoke.cs | 71 +++++++++++++++++++++++ docs/moddable-ship-authoring.md | 16 +++++ 2 files changed, 87 insertions(+) create mode 100644 Assets/Scripts/Editor/ShipModPlaySmoke.cs diff --git a/Assets/Scripts/Editor/ShipModPlaySmoke.cs b/Assets/Scripts/Editor/ShipModPlaySmoke.cs new file mode 100644 index 000000000..a4fb708b5 --- /dev/null +++ b/Assets/Scripts/Editor/ShipModPlaySmoke.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; + +// Batch proof that a mod ship survives the game's own path into a zone, without a scene: the derived catalog composed +// with one package, ShipModShips.Preload over it, then ShipModShips.Instantiate, which is what ZoneRenderer.LoadEntity's +// Body() calls for a hull with a Visual. A plain parent transform stands in for ZoneRoot; nothing else in LoadEntity +// touches the body before it takes its ShipInstance. +// +// Unity -batchmode -nographics -projectPath -executeMethod ShipModPlaySmoke.Run +// -shipModPath //ship.cc [-shippedCatalog GameData/Aetheria.cc] -logFile +// +// Do not pass -quit: the method is async and exits the Editor itself, 0 on success and 1 on any failure. +public static class ShipModPlaySmoke +{ + public static async void Run() + { + var args = Environment.GetCommandLineArgs(); + string Option(string name, string fallback = null) + { + var at = Array.IndexOf(args, name); + return at >= 0 && at + 1 < args.Length ? args[at + 1] : fallback; + } + var packagePath = Option("-shipModPath"); + var derived = Path.Combine(Path.GetTempPath(), $"ship-mod-play-smoke-{Guid.NewGuid():N}.cc"); + GameObject zone = null; + try + { + if (packagePath == null) throw new InvalidOperationException("-shipModPath is required"); + var package = ShipModCatalog.ReadPackage(packagePath); + var modsRoot = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(packagePath))); + var composition = ShipModCatalog.Compose(Path.GetFullPath(Option("-shippedCatalog", "GameData/Aetheria.cc")), derived, modsRoot); + if (!composition.Included.Contains(package.Visual.Id)) + throw new InvalidOperationException($"{package.Visual.Id} was not composed into the catalog: " + + string.Join("; ", composition.Excluded.Select(excluded => $"{excluded.Package}: {excluded.Reason}"))); + + using (var catalog = AetheriaStores.Open(derived)) + { + await ShipModShips.Preload(catalog, modsRoot); + var hull = catalog.GetAll().Single(candidate => candidate.Visual.IsSet() && candidate.Name == package.Hull.Name); + + zone = new GameObject("Play Smoke Zone"); + var ship = ShipModShips.Instantiate(hull, zone.transform).GetComponent(); + if (ship == null) throw new InvalidOperationException("The mod ship body has no ShipInstance."); + var thrusters = ship.ThrusterHardpoints?.Length ?? 0; + var weapons = ship.WeaponHardpoints?.Length ?? 0; + if (thrusters == 0) throw new InvalidOperationException("ThrusterHardpoints is empty."); + if (weapons == 0 || ship.WeaponHardpoints.Any(node => node.FiringPoint == null || node.FiringPoint.Length == 0)) + throw new InvalidOperationException("WeaponHardpoints is empty or a weapon has no muzzle."); + if (ship.HullColliders == null || ship.HullColliders.Length != 1) + throw new InvalidOperationException("The ship needs exactly one hull collider."); + var collider = ship.HullColliders[0].GetComponent(); + if (collider == null || collider.sharedMesh == null || !collider.sharedMesh.isReadable || collider.sharedMesh.vertexCount == 0) + throw new InvalidOperationException("The hull collider is not a MeshCollider over a readable mesh."); + if (ship.MapIcon == null || ship.MapIcon.sharedMaterial == null || ship.MapIcon.gameObject.layer != LayerMask.NameToLayer("Minimap")) + throw new InvalidOperationException("The map icon is missing its renderer, material or Minimap layer."); + Debug.Log($"SHIP_MOD_PLAY_SMOKE thrusters={thrusters} weapons={weapons} collider=ok mapicon=ok"); + } + if (zone != null) UnityEngine.Object.DestroyImmediate(zone); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + if (zone != null) UnityEngine.Object.DestroyImmediate(zone); + EditorApplication.Exit(1); + } + } +} diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 315ff22e1..5a69f06de 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -194,6 +194,22 @@ catalog. Continue refuses a run naming a design the catalog no longer holds 5. Spawn, equip, thrust, fire, shield, damage, save/reload, and schematic UI work for the mod ship. Only then is a shipped hull eligible for migration. +### Unity batch smokes + +Both run in batchmode against a package the tests generate: set `AETHERIA_SHIP_FIXTURE_DIR` while running +`ShipModCatalogTests.TheFixturePackageIsCompleteAndItsGlbCarriesAScene` and it leaves `mod.skiff/ship.cc` and +`skiff.glb` there (the GLB carries a tetrahedron mesh on the map, collider and thruster anchors, plus a weapon mount +and muzzle). Neither takes `-quit`: both are `async void` and end the Editor themselves. + +- `-executeMethod ShipModPreview.Smoke -shipModPath /mod.skiff/ship.cc [-assemble]` imports the GLB and checks + the anchors; `-assemble` also builds the `ShipInstance` from the template. +- `-executeMethod ShipModPlaySmoke.Run -shipModPath /mod.skiff/ship.cc [-shippedCatalog GameData/Aetheria.cc]` + composes a derived catalog with the package, runs `ShipModShips.Preload`, instantiates the ship through + `ShipModShips.Instantiate` (the call `ZoneRenderer.LoadEntity` makes) under a bare parent transform, and requires + thruster and weapon hardpoints (each weapon with a muzzle), one `MeshCollider` over a readable mesh, and a map icon. + It prints `SHIP_MOD_PLAY_SMOKE thrusters=N weapons=N collider=ok mapicon=ok` and exits 0, or logs the exception and + exits 1. It uses no scene and no zone. + ## Structural and build budget The proof reuses `HullData` simulation semantics, CultCache `.cc` persistence, From e9195763b6a6d6c1019a25bcd90df31b0faeefe6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 11:13:10 +0200 Subject: [PATCH 029/113] Ship S2: the Unity-generated meta for ShipModPlaySmoke.cs Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/ShipModPlaySmoke.cs.meta | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Assets/Scripts/Editor/ShipModPlaySmoke.cs.meta diff --git a/Assets/Scripts/Editor/ShipModPlaySmoke.cs.meta b/Assets/Scripts/Editor/ShipModPlaySmoke.cs.meta new file mode 100644 index 000000000..c6bb5936a --- /dev/null +++ b/Assets/Scripts/Editor/ShipModPlaySmoke.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a1f2b276e4d6582479c3216cc97a5164 \ No newline at end of file From 48cabc5f859868eb6fa1559dc39e61a46a8357c0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 11:14:00 +0200 Subject: [PATCH 030/113] Ship S2: the prototype root is kept across scenes only in play mode DontDestroyOnLoad throws outside play mode, so the edit-mode play smoke failed before it reached assembly. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ShipModShips.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/Gameplay/ShipModShips.cs b/Assets/Scripts/Gameplay/ShipModShips.cs index b0b9a0d0c..26761ad11 100644 --- a/Assets/Scripts/Gameplay/ShipModShips.cs +++ b/Assets/Scripts/Gameplay/ShipModShips.cs @@ -34,7 +34,9 @@ public static class ShipModShips throw new InvalidOperationException($"The ship mod template prefab ({ShipModTemplate.Key}) is not loadable."); var root = new GameObject("Mod Ship Prototypes"); root.SetActive(false); - Object.DontDestroyOnLoad(root); + // DontDestroyOnLoad is play-mode only; the edit-mode play smoke builds the same prototypes + // without it, and nothing there loads scenes. + if (Application.isPlaying) Object.DontDestroyOnLoad(root); foreach (var hull in hulls) { var package = ShipModCatalog.PackageOf(catalog, hull, modsRoot); From 6f5ce55138300ea03fa7301b948465d74bdcf8b6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 11:28:10 +0200 Subject: [PATCH 031/113] Ships map: S2 status (landed, Unity smokes pass, operator checks owed) Co-Authored-By: Claude Opus 5.5 --- docs/moddable-ships-cut.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/moddable-ships-cut.md b/docs/moddable-ships-cut.md index 1b9e428fc..72bde88a6 100644 --- a/docs/moddable-ships-cut.md +++ b/docs/moddable-ships-cut.md @@ -335,6 +335,17 @@ resolve the package on first open, like the CultMath tag already does (`docs/mer - **Subtraction estimate:** 0 removed, about 150 added (loader, template, preload). This is the capability gate 4 buys. +**S2 status (2026-09-30, Self):** landed on `codex/moddable-ships` through `48cabc5f`. Soul passes on S2 and its +fixes (bad packages excluded and named, never fatal; mount anchors typed by hardpoint; `capturepreset` refuses mod +designs; explicit null checks at Continue; compose failure falls back to the shipped catalog). Headless 684/684. +Unity 6000.3.24f1: compile clean; `ShipModPlaySmoke.Run` (edit mode, real preload and `Instantiate`) prints +`SHIP_MOD_PLAY_SMOKE thrusters=1 weapons=1 collider=ok mapicon=ok`; `ShipModPreview.Smoke -assemble` passes against +the generated mesh fixture. Owed: the operator's play-mode checks (menu dialog for excluded mods, New Game/Continue +with a mod, zone entry, wormhole, Continue after removal). Recorded, not fixed: two processes sharing +`Aetheria.modded.cc`; preload running inside the `CultCache` getter; a mod updated in place that changes a hull +passes the gate; the `equipment` role (operator question). Interim ruling: a non-thruster/radiator/weapon mount +must be an `articulation` anchor. + ### S3. Gate 4: a built player A player build loads a composed mod ship without the Editor or Blender. Only Self (the batchmode build) and the From a93625c2f36158357ba71563c495a22dabef42be Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:14:27 +0200 Subject: [PATCH 032/113] Ships map: mount anchors only for weapons, radiators and thrusters Operator ruling 2026-09-30: mount points exist only for externally visible hardpoints. No equipment role, no plain mount. Articulation points stay; they are not mounts. Co-Authored-By: Claude Opus 5.5 --- docs/moddable-ships-cut.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/moddable-ships-cut.md b/docs/moddable-ships-cut.md index 72bde88a6..70b59e696 100644 --- a/docs/moddable-ships-cut.md +++ b/docs/moddable-ships-cut.md @@ -343,8 +343,17 @@ Unity 6000.3.24f1: compile clean; `ShipModPlaySmoke.Run` (edit mode, real preloa the generated mesh fixture. Owed: the operator's play-mode checks (menu dialog for excluded mods, New Game/Continue with a mod, zone entry, wormhole, Continue after removal). Recorded, not fixed: two processes sharing `Aetheria.modded.cc`; preload running inside the `CultCache` getter; a mod updated in place that changes a hull -passes the gate; the `equipment` role (operator question). Interim ruling: a non-thruster/radiator/weapon mount -must be an `articulation` anchor. +passes the gate. The interim ruling that a non-thruster/radiator/weapon mount must be an `articulation` anchor is +superseded by the ruling below. + +**Mount-point ruling (operator, 2026-09-30):** "Regarding mount points, they're only really needed for hardpoints +that are externally visible in some way. That's just weapons, radiators, thrusters right now." So a mount anchor +exists only for the `weapon`, `radiator` and `thruster` roles. There is no `equipment` role and no plain mount. +Other hardpoints are internal and carry no anchor. Articulation points (turret pivots, `ArticulationPoint.Group`) +are a separate concept and are not mounts. They stay. Follow-up for S3 prep: find out whether +`EquipmentHardpoints` (line 314) has a consumer outside anchor wiring. If it has none, delete it, and have the +validator refuse a mount with any other role. Also: the operator said "Missing design refusal on catalog entries +is good": keep it. ### S3. Gate 4: a built player From 702b454beaf02a7f7908bfb01b01c7d8264bf100 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:14:40 +0200 Subject: [PATCH 033/113] Scenarios map: record Q1-Q4 rulings; Cut 0 cancelled Q1 A, Q2 B, Q3 A. Q4: every console command stays ("never toss a single one"), so Cut 0 is cancelled and give/spawnturret admit through Zone.Admit in Cut 1. Self restores the smoke weapons in the operator tree when Cut 4 lands. Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index d7354ecbc..388745737 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -2,7 +2,7 @@ Date: 2026-09-30, 02:10 CEST. Imagination pass. -Status: cut map. Nothing has landed. The target lives in section 0 until Self splits it out into +Status: cut map, ruled 2026-09-30 (see "Rulings" below). Nothing has landed. The target lives in section 0 until Self splits it out into `docs/scenarios-target.md`; this document owns the means. Anchors are against Aetheria `origin/master` `dcd7bbc5` (the fire-control merge), read in the clone @@ -12,6 +12,23 @@ claim measured against a real file. --- +## Rulings (operator, 2026-09-30) + +- **Q1 A.** The arena is the entrance zone of a small prelude galaxy. Operator: "A is fine, I guess, since some of + the stuff we'll be testing won't work in an isolated zone with no galaxy." +- **Q2 B.** The agent records results with `AetherDb record`; the menu shows them read-only. +- **Q3 A.** Test designs are committed catalog designs with no product. +- **Q4: none of A-C.** "Debug console commands are hella useful in general and fun for sandbox gamers, never toss a + single one." Cut 0 is cancelled: `give` (with its hull route), `spawnturret` and every other console command stay. + The neutral wanderers are not a console command and no ruling retires them, so they stay too. Scenarios coexist + with the console; they are not its replacement. +- **Smoke weapons.** The operator will not restore their tree's uncommitted `GameData/Aetheria.cc` themselves: + "No I won't, but you may." When Cut 4 lands, Self runs `git checkout -- GameData/Aetheria.cc` in + `F:\Projects\Aetheria`. That one file is the only exception to the rule that agents do not touch the + operator's tree. + +--- + ## 0. Target ### 0.1 The request, in the operator's words (2026-09-30) @@ -226,7 +243,8 @@ It holds no Unity type. - **Derived state:** `ActionGameManager.CurrentEntity` is bound to the returned player. `PendingScenario` is transport only. - **Forbidden writers:** `StartGame` may not generate, unpack or admit the player ship. `MainMenu` may not stage. - No console command admits entities (`give` and `spawnturret` die, Q4). + Console commands stay (Q4 ruling), but `give` and `spawnturret` admit through `Zone.Admit`, the same primitive + as scenario staging. They get no admission path of their own. Cut 1 repoints them. - **Shared paths:** New Game and scenario launch both run menu, galaxy, `StartGame`, `RunStart`. Continue does not stage. - **Deletion line:** `StartGame` `:805-814` (generator, unpack, add, activate). The only surviving line is the @@ -290,10 +308,13 @@ Build budget, every cut: must refresh that pin, or its content tests read the old catalog. Cut 4's tests that read the ledger mount it the same way. Yggdrasil's disk is saturated: one job per cut, `--artifacts-path` in the container. -Order: 0, 1, 2, 3, 4. Cut 0 is subtraction alone, so Soul can falsify it alone. Cuts 1 and 3 are headless. Cut 2 +Order: 1, 2, 3, 4. Cut 0 is cancelled (Q4 ruling). Cuts 1 and 2 branch from `master` directly. Cuts 1 and 3 are headless. Cut 2 is the only Unity-side cut. Cut 4 is content. -### Cut 0. Retire the ad hoc test affordances (Q4) +### Cut 0. Retire the ad hoc test affordances (Q4). CANCELLED + +**Cancelled 2026-09-30 by the Q4 ruling ("never toss a single one").** Nothing below runs. It is kept as history. +Cut 1 branches from `master`. - **Repo/branch:** Aetheria, `codex/scenarios` from `master` `dcd7bbc5`. - **Deletes first** (per Q4 A; B keeps the item branch of `give` and the wanderers): @@ -318,7 +339,7 @@ is the only Unity-side cut. Cut 4 is content. ### Cut 1. `RunStart`, `Zone.Admit`, and the scenario type (headless) -- **Repo/branch:** Aetheria `codex/scenarios`, on Cut 0. Re-anchor `Zone.cs` if mining has merged. +- **Repo/branch:** Aetheria `codex/scenarios`, from `master` (Cut 0 cancelled). Re-anchor `Zone.cs` if mining has merged. - **First:** with `AetherDb hardpoint-fit` and a scratch `Materialize` against the live catalog, confirm that `Djinni`, `LonginusX` and one turret hull each have a product and materialize bare. If one has no product, stop and report: the Q3 rule would make it unbranded, which may not be intended for a real hull. @@ -327,6 +348,10 @@ is the only Unity-side cut. Cut 4 is content. calling `RunStart` with no scenario, so behaviour is unchanged). - The four hand-written joins: `ActionGameManager.cs:735-736`, `Entity.cs:1006-1007`, `Zone.cs:124-125`, and the agent add at `Zone.cs:128`. Each becomes `Admit`. +- **Repoints (Q4 ruling, console commands stay):** `give`'s hull route (`ActionGameManager.cs:515-544`) and + `spawnturret` (`:564-589`) admit through `Zone.Admit`. If either already reaches one of the four joins above, + that repoint covers it. A test pins that `give` of a hull and `spawnturret` both land in `Entities` through + `Admit`, and its mutation (restoring a direct add) must fail it. - **Adds:** `Scenario.cs`, `RunStart.cs`, `Zone.Admit`, the `ambient` parameter on `ZoneGenerator.GenerateZone` (`:38-43`; the ship loop `:334-340` and `PlaceTurrets` calls `:309,331` honour it), `Scenario` in `AetheriaStores.CatalogTypes` (`:9`), and the two `Materialize` rules (`Loadout.cs:103-115,131`). @@ -421,8 +446,9 @@ is the only Unity-side cut. Cut 4 is content. - **Repo/branch:** Aetheria `codex/scenarios`, on Cuts 2 and 3. Hands authors on the branch clone, never in `F:\Projects\Aetheria`. -- **First:** tell the operator that the uncommitted smoke weapons in their tree's `GameData/Aetheria.cc` must be - restored (`git checkout -- GameData/Aetheria.cc`, their own command) before they pull. This cut recreates them as +- **First:** Self restores the uncommitted smoke weapons in the operator's tree (`git checkout -- + GameData/Aetheria.cc` in `F:\Projects\Aetheria`, that file only, per the smoke-weapons ruling) before the + operator pulls. This cut recreates them as committed designs. - **Adds:** - A transient `AetherDb scenario-seed [apply]` command, with the never-clobber rule of section 2.4. @@ -469,7 +495,7 @@ is the only Unity-side cut. Cut 4 is content. anything editing a result; any catalog write at runtime. - **Shared paths:** New Game and every scenario launch: one launch body, `StartGame`, `RunStart`, `Admit`. Warp, undock, construction and staging: `Admit`. -- **Deletion line:** Cut 0's affordances; `StartGame` `:805-814`; the four hand-written joins; the duplicated New +- **Deletion line:** `StartGame` `:805-814`; the four hand-written joins; the duplicated New Game branches; the transient seed command. --- From fbda4c74fb0c1fc911be37acac2826eee16a20e7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:32:59 +0200 Subject: [PATCH 034/113] Scenarios Cut 1: Zone.Admit is the one admission into a zone Zone.Admit(entity, piloted) adds the entity to Entities, activates it and, when piloted, gives it the zone's Minion. It is the only writer of Entities.Add and the only creator of agents (docs/scenarios-cut.md, 2.2). The constructor's pack loop admits every packed ship but the player's as piloted and keeps its own origin-scatter rule, which is generation placement, not admission. Entity.TryUndock admits the undocked ship unpiloted. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 3 +-- Assets/Scripts/ServerShared/Zone.cs | 26 +++++++++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 6ad751e01..c2d1dbdca 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1003,8 +1003,7 @@ public abstract class Entity bay.DockedShip = null; ship.RemoveParent(); - Zone.Entities.Add(ship); - ship.Activate(); + Zone.Admit(ship, piloted: false); return true; } diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 9b34e6687..ec8aa304c 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -88,7 +88,7 @@ public class Zone // Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7; operator ruling Q3): death removes the ship, // in the simulation, not only in Unity's own loot-drop subscription. One subscription point covers - // every join, whichever call site adds the entity (deserialization below, a jump, a spawned turret) -- + // every join, whichever caller admits the entity (deserialization below, a jump, a spawned turret) -- // ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone. Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); })); @@ -121,19 +121,27 @@ public class Zone foreach (var entityPack in pack.Entities) { var entity = EntitySerializer.Unpack(_itemManager, this, entityPack); - Entities.Add(entity); - entity.Activate(); - if (entity is Ship {IsPlayerShip: false} ship) - { - Agents.Add(CreateAgent(ship)); - if (lengthsq(ship.Position) < 1) - ship.Position = _itemManager.Random.NextFloat3(float3(-pack.Radius * .5f), float3(pack.Radius * .5f)); - } + var ship = entity as Ship; + Admit(entity, piloted: ship is {IsPlayerShip: false}); + // Generation placement, not admission: a ship packed at the origin is scattered across the zone. + if (ship is {IsPlayerShip: false} && lengthsq(ship.Position) < 1) + ship.Position = _itemManager.Random.NextFloat3(float3(-pack.Radius * .5f), float3(pack.Radius * .5f)); } // TODO: Associate planets with stored entities for planetary colonies } + // The one admission into this zone (docs/scenarios-cut.md, 2.2): the only writer of Entities.Add and the only + // creator of agents. Construction, new-run staging (RunStart), warp arrival, undock and the console's spawned + // turret all join through here. A piloted entity gets the zone's Minion; an unpiloted one gets no agent at all. + // Only a ship can be piloted. The Death subscription above covers every join, whichever caller admits it. + public void Admit(Entity entity, bool piloted) + { + Entities.Add(entity); + entity.Activate(); + if (piloted) Agents.Add(CreateAgent((Ship) entity)); + } + private Agent CreateAgent(Ship ship) { var agent = new Minion(ship); From 5706b657e312f3f6ed48c0f7fa17c88744fade91 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:33:00 +0200 Subject: [PATCH 035/113] Scenarios Cut 1: Materialize builds product-less designs unbranded; the hull picks the entity Two rules on Loadouts.Materialize (docs/scenarios-cut.md, 2.2, Q3): - A design no product makes is outside the economy and is built unbranded, with no maker, at Loadouts.UnbrandedQuality (.95, the console give's). A design whose products are all unavailable still fails, as before. - The hull's type picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit; a station hull is refused. Materialize now returns Entity. Loadouts.Resolve is the one rule for how a design is built; RunStart uses it for scenario cargo. LoadoutTests.MissingDesignReports no longer uses the product-less "Orphan" as a failure (it now builds); two tests pin the Q3 rule and the unavailable-product contract it does not loosen. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 102 +++++++++++++------- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 34 ++++++- 2 files changed, 98 insertions(+), 38 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index d8b868cf5..d4a7262b1 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -27,7 +27,7 @@ public class LoadoutSlot [Key(2)] public CultRecordRef Design; } -// The owner of loadouts: capture from a live ship, the only writer, and the only builder of a ship from a loadout. +// The owner of loadouts: capture from a live ship, the only writer, and the only builder of an entity from a loadout. public static class Loadouts { // Records the hull design and one slot per equipped item (gear, then cargo bays, then docking bays) with its cell, @@ -86,36 +86,30 @@ public static class Loadouts }); } - // All-or-nothing: returns a ship only when every design resolved, had an available product and fitted, the loadout - // has at most WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on - // any failure nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from - // itemManager.Random. A design is built by its first available product in record-key order. A game spawner is to - // pass LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer. The ship always gets - // exactly WeaponGroupCount groups; a loadout with fewer is padded with empty groups. A failed materialization - // leaves any minted lots in the live ledger; they are not roots, so the next commit drops them. - public static Ship Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout, + // The quality a design no manufacturer makes is built at: the console give's. + public const float UnbrandedQuality = .95f; + + // All-or-nothing: returns an entity only when every design resolved and fitted, the loadout has at most + // WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on any failure + // nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from + // itemManager.Random. How each design is built is Resolve's rule. A game spawner is to pass + // LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer; RunStart does. The hull's type + // picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is + // put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is + // padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots, + // so the next commit drops them. + public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout, Predicate isAvailable, List failures) { var cache = itemManager.ItemData; - var products = cache.GetAll().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray(); + var products = ProductsInKeyOrder(cache); var reported = failures.Count; - FactionProductData Resolve(CultRecordRef design, string where) where T : EquippableItemData - { - var data = cache.Get(design); - if (data == null) - { - failures.Add($"{where}: design {design.Key} is not in the catalog"); - return null; - } - - var product = products.FirstOrDefault(p => p.Design.Key.Equals(design.Key) && isAvailable(p)); - if (product == null) failures.Add($"{where}: no available product of {data.Name}"); - return product; - } - - var hullProduct = Resolve(loadout.Hull, "hull"); - var slotProducts = loadout.Slots.Select(slot => Resolve(slot.Design, Cell(slot))).ToArray(); + var hullBuild = Resolve(itemManager, products, loadout.Hull, isAvailable, "hull", failures); + var hullData = cache.Get(loadout.Hull); + if (hullData is { HullType: HullType.Station }) + failures.Add($"hull: {hullData.Name} is a station hull; a preset builds a ship or a turret"); + var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, products, slot.Design, isAvailable, Cell(slot), failures)).ToArray(); var groups = loadout.WeaponGroups ?? new int[0][]; var groupCount = itemManager.GameplaySettings.WeaponGroupCount; if (groups.Length > groupCount) failures.Add($"weapon groups: {groups.Length} groups, at most {groupCount}"); @@ -127,23 +121,26 @@ public static class Loadouts } if (failures.Count > reported) return null; - var hull = (EquippableItem) itemManager.CreateInstance(hullProduct); - var ship = new Ship(itemManager, liveZone, hull, itemManager.GameplaySettings.DefaultEntitySettings); + var hull = hullBuild(); + var settings = itemManager.GameplaySettings.DefaultEntitySettings; + Entity entity = hullData.HullType == HullType.Turret + ? new OrbitalEntity(itemManager, liveZone, hull, default, settings) + : new Ship(itemManager, liveZone, hull, settings); var equipped = new EquippedItem[loadout.Slots.Count]; for (var i = 0; i < loadout.Slots.Count; i++) { var slot = loadout.Slots[i]; - var unit = (EquippableItem) itemManager.CreateInstance(slotProducts[i]); + var unit = slotBuilds[i](); unit.Rotation = slot.Rotation; - if (!ship.TryEquip(unit, slot.Position)) + if (!entity.TryEquip(unit, slot.Position)) { failures.Add($"{Cell(slot)}: {cache.Get(slot.Design).Name} does not fit"); continue; } - equipped[i] = ship.Equipment - .Concat(ship.CargoBays) - .Concat(ship.DockingBays) + equipped[i] = entity.Equipment + .Concat(entity.CargoBays) + .Concat(entity.DockingBays) .First(item => item.EquippableItem == unit); } if (failures.Count > reported) return null; @@ -154,9 +151,44 @@ public static class Loadouts var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List(); built[g] = (items.Select(item => item.GetBehavior()).ToList(), items); } - ship.WeaponGroups = built; - return ship; + entity.WeaponGroups = built; + return entity; } + // How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A + // design is built by its first available product in record-key order. A design with no product at all is outside + // the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through products) and is built + // unbranded, with no maker, at UnbrandedQuality. A design whose products are all unavailable is not built. + public static Func Resolve(ItemManager itemManager, CultRecordRef design, + Predicate isAvailable, string where, List failures) where T : EquippableItemData => + Resolve(itemManager, ProductsInKeyOrder(itemManager.ItemData), design, isAvailable, where, failures); + + private static Func Resolve(ItemManager itemManager, FactionProductData[] products, + CultRecordRef design, Predicate isAvailable, string where, List failures) + where T : EquippableItemData + { + var data = itemManager.ItemData.Get(design); + if (data == null) + { + failures.Add($"{where}: design {design.Key} is not in the catalog"); + return null; + } + + var made = products.Where(p => p.Design.Key.Equals(design.Key)).ToArray(); + if (made.Length == 0) + return () => (EquippableItem) itemManager.CreateInstance(itemManager.CreateLot(data, default, UnbrandedQuality)); + + var product = made.FirstOrDefault(p => isAvailable(p)); + if (product == null) + { + failures.Add($"{where}: no available product of {data.Name}"); + return null; + } + return () => (EquippableItem) itemManager.CreateInstance(product); + } + + private static FactionProductData[] ProductsInKeyOrder(CultCache cache) => + cache.GetAll().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray(); + private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}"; } diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 48cd75cbd..03ea71364 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -264,13 +264,13 @@ public sealed class LoadoutTests : IDisposable Assert.Contains("1,1", failure); Assert.Contains("absent-design", failure); - missing.Slots.Add(new LoadoutSlot { Position = new int2(2, 2), Design = cache.RefOf(cache.GetByName("Orphan")) }); + missing.Slots.Add(new LoadoutSlot { Position = new int2(2, 2), Design = new CultRecordRef(new CultRecordKey("another-absent-design")) }); failures.Clear(); Assert.Null(Build(items, missing, failures)); Assert.Equal(2, failures.Count); Assert.Contains("slot 1,1", failures[0]); Assert.Contains("slot 2,2", failures[1]); - Assert.Contains("no available product of Orphan", failures[1]); + Assert.Contains("another-absent-design", failures[1]); } Assert.Equal(before, new[] { Catalog, Run, Player }.Select(Hash).ToArray()); @@ -321,6 +321,34 @@ public sealed class LoadoutTests : IDisposable Assert.Contains("slot 0,0: no available product of Lamp", Assert.Single(failures)); } + // Scenarios Q3 (docs/scenarios-cut.md): a design no product makes is outside the economy, and a preset builds it + // unbranded -- no maker -- at the one named quality. "Orphan" has no product in this catalog. + [Fact] + public void ADesignWithNoProductBuildsUnbranded() + { + using var cache = Open(); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var failures = new List(); + var ship = Build(items, WithDesign(HandBuilt(cache), 0, cache, "Orphan"), failures); + Assert.Empty(failures); + var unit = ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem; + Assert.Equal("Orphan", items.GetData(unit).Name); + Assert.Null(items.Brand(unit).Maker); + Assert.Equal(Loadouts.UnbrandedQuality, items.GetLot(unit).Quality); + } + + // The existing contract Q3 does not loosen: a design that has a product, none of them available, is not built, + // unbranded or otherwise. + [Fact] + public void ADesignWhoseOnlyProductIsUnavailableStillFails() + { + using var cache = Open(); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var failures = new List(); + Assert.Null(Loadouts.Materialize(items, null, HandBuilt(cache), p => p.Name != "Lamp by Maker", failures)); + Assert.Equal("slot 0,0: no available product of Lamp", Assert.Single(failures)); + } + // Census's duplicate (maker, design) detector exists precisely because this should never happen in an // authored catalog; Brand()'s tie-break is what a runtime item falls back on if it ever does, so it must stay // deterministic rather than however the cache happens to enumerate. @@ -895,7 +923,7 @@ public sealed class LoadoutTests : IDisposable } // Everything available: for tests about placement and failure lists. - private static Ship Build(ItemManager items, Loadout loadout, List failures) => + private static Entity Build(ItemManager items, Loadout loadout, List failures) => Loadouts.Materialize(items, null, loadout, _ => true, failures); private static (int2, ItemRotation, CultRecordKey) Describe(LoadoutSlot slot) => (slot.Position, slot.Rotation, slot.Design.Key); From 85ebe036e1c97d29b2be8f047dc6397bd97c5f98 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:33:02 +0200 Subject: [PATCH 036/113] Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type RunStart (ServerShared, no Unity type) owns what a new run starts with (docs/scenarios-cut.md, 2.2): - GenerateArena: the galaxy's entrance pack, generated under the scenario's Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps planets, orbits and stations and generates no ship, turret or wanderer. - Check: every failure staging a scenario would hit, found with no zone, touching no store. - Stage: all-or-nothing. With no scenario, the generated starting-hull ship at the origin, as StartGame built it. With one, the player preset and the entities at their authored place and heading with cargo, stance set both ways through SetIff, a Minion for each piloted one; all through Zone.Admit. Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes. RunStartTests stage into a real prelude arena from the shipped catalog and authored settings. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 2 +- .../Scripts/ServerShared/LoadoutGenerator.cs | 4 +- Assets/Scripts/ServerShared/RunStart.cs | 131 ++++++ Assets/Scripts/ServerShared/RunStart.cs.meta | 2 + Assets/Scripts/ServerShared/Scenario.cs | 42 ++ Assets/Scripts/ServerShared/Scenario.cs.meta | 2 + Assets/Scripts/ServerShared/ZoneGenerator.cs | 11 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 394 ++++++++++++++++++ 8 files changed, 582 insertions(+), 6 deletions(-) create mode 100644 Assets/Scripts/ServerShared/RunStart.cs create mode 100644 Assets/Scripts/ServerShared/RunStart.cs.meta create mode 100644 Assets/Scripts/ServerShared/Scenario.cs create mode 100644 Assets/Scripts/ServerShared/Scenario.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/RunStartTests.cs diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index bee8a4c9f..8d9c2dfb2 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -6,7 +6,7 @@ using GameCult.Caching.MessagePack; // Aetheria's stores: one home store per type, composed here and nowhere else. public static class AetheriaStores { - public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) }; + public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout), typeof(Scenario) }; public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) }; public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 11e277f7f..044b282df 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -149,8 +149,8 @@ public class LoadoutGenerator // No galaxy means no availability to filter by: every product is on offer. A fixture generates loadouts that // way, so a test can exercise placement and products without standing up a whole galaxy; no game path does. - // Loadouts.Materialize takes availability as a predicate; a game spawner materializing presets is to pass this, so - // presets and generation share one availability rule. No game path materializes a preset yet. + // Loadouts.Materialize takes availability as a predicate; RunStart, the game's one preset spawner, passes this, so + // presets and generation share one availability rule. public bool IsAvailable(FactionProductData product) => Galaxy == null || Galaxy.IsPrelude || Galaxy.ContainsFaction(product.Manufacturer) && (Faction == null || Faction.Allegiance.ContainsKey(product.Manufacturer)); diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs new file mode 100644 index 000000000..53807b562 --- /dev/null +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// The owner of what a new run starts with (docs/scenarios-cut.md, 2.2): how the arena zone is generated, the player +// ship, and a scenario's other entities with their stance, pilot and cargo. A plain New Game is the absent-scenario +// case of the same path. Nothing else generates, unpacks or admits a new run's player; everything it stages joins the +// arena through Zone.Admit. It holds no Unity type. +public static class RunStart +{ + public sealed class Staged + { + public Ship Player; + public List Entities; // the scenario's entities, in its order; empty with no scenario + } + + // The arena is the galaxy's entrance zone, generated under the scenario's Ambient rule; with no scenario, ambient. + public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, Galaxy galaxy, Scenario scenario) => + galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone( + itemManager, zoneSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario?.Ambient ?? true); + + // The check entry: every failure staging this scenario would hit, found with no zone. With no galaxy there is no + // availability to filter by (LoadoutGenerator.IsAvailable), and a scenario's arena is a prelude, which offers every + // product anyway. It touches no store, so the menu can call it before it clears the saved run. + public static List Check(ItemManager itemManager, Scenario scenario) + { + var failures = new List(); + if (scenario != null) Build(itemManager, null, scenario, _ => true, failures); + return failures; + } + + // Stages a new run into its arena, all-or-nothing: null, with every failure listed and the zone unchanged, unless + // everything builds. With no scenario: a generated ship of the startingHull (any ship hull when it is empty) for + // defaultFaction, at the origin. With one: its player and entities, each at its authored position and heading with + // its cargo, each entity's stance set both ways with the player, and a Minion for each piloted one. + public static Staged Stage(ItemManager itemManager, Zone arena, Scenario scenario, string startingHull, + Faction defaultFaction, List failures) + { + if (scenario == null) + { + var generator = new LoadoutGenerator(ref itemManager.Random, itemManager, arena.Galaxy, arena.GalaxyZone, defaultFaction, 2); + var pack = generator.GenerateShipLoadout(data => string.IsNullOrEmpty(startingHull) || data.Name == startingHull); + if (pack == null) + { + failures.Add($"player: no ship hull named {startingHull}"); + return null; + } + + var ship = (Ship) EntitySerializer.Unpack(itemManager, arena, pack); + ship.IsPlayerShip = true; + ship.Position = float3.zero; + arena.Admit(ship, piloted: false); + return new Staged { Player = ship, Entities = new List() }; + } + + // Presets carry no faction, so availability is the galaxy's alone. + var availability = new LoadoutGenerator(ref itemManager.Random, itemManager, arena.Galaxy, arena.GalaxyZone, null, 2); + var built = Build(itemManager, arena, scenario, availability.IsAvailable, failures); + if (built == null) return null; + + var (player, entities) = built.Value; + player.IsPlayerShip = true; + arena.Admit(player, piloted: false); + for (var i = 0; i < entities.Count; i++) + { + var hostile = scenario.Entities[i].Stance == ScenarioStance.Hostile; + entities[i].SetIff(player, hostile); + player.SetIff(entities[i], hostile); + arena.Admit(entities[i], scenario.Entities[i].Piloted); + } + return new Staged { Player = player, Entities = entities }; + } + + // Everything a scenario places, built and positioned but admitted nowhere; null when anything failed. + private static (Ship player, List entities)? Build(ItemManager itemManager, Zone zone, Scenario scenario, + Predicate isAvailable, List failures) + { + var reported = failures.Count; + Entity player = null; + if (scenario.Player == null) failures.Add("player: the scenario places no player"); + else + { + player = Place(itemManager, zone, scenario.Player, "player", isAvailable, failures); + if (player != null && !(player is Ship)) failures.Add($"player: {player.Name} is not a ship hull"); + } + + var entities = new List(); + for (var i = 0; i < scenario.Entities.Count; i++) + { + var where = $"entity {i}"; + var entity = Place(itemManager, zone, scenario.Entities[i], where, isAvailable, failures); + if (entity != null && scenario.Entities[i].Piloted && !(entity is Ship)) + failures.Add($"{where}: {entity.Name} is not a ship hull, so it cannot be piloted"); + entities.Add(entity); + } + return failures.Count > reported ? null : ((Ship) player, entities); + } + + private static Entity Place(ItemManager itemManager, Zone zone, ScenarioShip placed, string where, + Predicate isAvailable, List failures) + { + var loadout = itemManager.ItemData.Get(placed.Loadout); + if (loadout == null) + { + failures.Add($"{where}: preset {placed.Loadout.Key} is not in the catalog"); + return null; + } + + var reported = failures.Count; + var materialized = new List(); + var entity = Loadouts.Materialize(itemManager, zone, loadout, isAvailable, materialized); + failures.AddRange(materialized.Select(failure => $"{where} ({loadout.Name}): {failure}")); + var cargo = placed.Cargo.Select(design => Loadouts.Resolve(itemManager, design, isAvailable, $"{where} cargo", failures)).ToList(); + if (failures.Count > reported) return null; + + entity.Position.xz = placed.Position; + if (lengthsq(placed.Direction) > 0) entity.Direction = normalize(placed.Direction); + var bay = entity.CargoBays.FirstOrDefault(); + foreach (var build in cargo) + { + var item = build(); + if (bay == null || !bay.TryStore(item)) + failures.Add($"{where} cargo: no room for {itemManager.GetData(item).Name}"); + } + return failures.Count > reported ? null : entity; + } +} diff --git a/Assets/Scripts/ServerShared/RunStart.cs.meta b/Assets/Scripts/ServerShared/RunStart.cs.meta new file mode 100644 index 000000000..2ccf2e506 --- /dev/null +++ b/Assets/Scripts/ServerShared/RunStart.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 28412ed9a0fc4f95b157846b0b132c27 diff --git a/Assets/Scripts/ServerShared/Scenario.cs b/Assets/Scripts/ServerShared/Scenario.cs new file mode 100644 index 000000000..d2d0da48f --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenario.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using GameCult.Caching; +using MessagePack; +using CultMath; + +// A scenario is an arena whose conditions are present at spawn (docs/scenarios-cut.md): the player's ship and every +// other entity already fitted and placed. Authored catalog data, read at runtime; RunStart stages it into a new run. +// It sets conditions and nothing else: once staged, its entities are ordinary run state. +[CultDocument("aetheria.scenario", "1"), MessagePackObject] +public class Scenario +{ + [CultName, Key(0)] public string Name; + [Key(1)] public string Brief; // the conditions in a line or two, shown in the menu + [Key(2)] public uint Seed; // the arena galaxy derives from it, so every launch has one layout + [Key(3)] public bool Ambient; // false: planets, orbits and stations, but no generated ship or turret + [Key(4)] public ScenarioShip Player; + [Key(5)] public List Entities = new List(); +} + +// A preset placed in the arena. A ship hull stages a Ship; a turret hull an orbital entity with no orbit, which stays +// where it is put. +[MessagePackObject] +public class ScenarioShip +{ + [Key(0)] public CultRecordRef Loadout; + [Key(1)] public float2 Position; // zone xz + [Key(2)] public float2 Direction; // zero keeps the entity's default heading + [Key(3)] public List> Cargo = new List>(); // first cargo bay +} + +[MessagePackObject] +public class ScenarioEntity : ScenarioShip +{ + [Key(4)] public ScenarioStance Stance; // applied both ways between this entity and the player + [Key(5)] public bool Piloted; // a Minion flies it; otherwise it has no agent and stays put +} + +public enum ScenarioStance +{ + Neutral, + Hostile +} diff --git a/Assets/Scripts/ServerShared/Scenario.cs.meta b/Assets/Scripts/ServerShared/Scenario.cs.meta new file mode 100644 index 000000000..bea3a0eb2 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenario.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: da26f6b6c5f34deaa1057177e2dd3262 diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 2144f718c..d57939168 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -35,12 +35,15 @@ public static class ZoneGenerator private const int MaximumPlacementSamples = 32; + // ambient: false keeps the planets, orbits and stations but generates no ship and no turret, so nothing wanders + // into a scenario's authored conditions (docs/scenarios-cut.md, 2.1). The rule is applied here, not by pruning. public static ZonePack GenerateZone( ItemManager itemManager, ZoneGenerationSettings zoneSettings, Galaxy galaxy, GalaxyZone galaxyZone, - bool isTutorial = false) + bool isTutorial = false, + bool ambient = true) { var pack = new ZonePack(); var cache = itemManager.ItemData; @@ -306,7 +309,7 @@ public static class ZoneGenerator station.Story = i; pack.Entities.Add(station); - PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(story.Faction), story.Turrets); + if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(story.Faction), story.Turrets); } for (var i = storyStations.Length; i < selectedStationOrbits.Length; i++) @@ -328,9 +331,11 @@ public static class ZoneGenerator station.SecurityRadius = pack.Radius; pack.Entities.Add(station); - PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2); + if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2); } + if (!ambient) return pack; + var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount; for (int i = 0; i < enemyCount; i++) { diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs new file mode 100644 index 000000000..03945ae78 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -0,0 +1,394 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Scenarios Cut 1 (docs/scenarios-cut.md, 2.2): RunStart owns what a new run starts with, and Zone.Admit is the one +// admission into a zone. Every test stages into a real arena: the entrance zone of a prelude galaxy at a fixed seed, +// built from the shipped catalog and the authored settings (Assets/Resources/Settings.asset). The presets and +// scenarios live in a scratch copy of the catalog, because the shipped one holds none. +public sealed class RunStartTests : IDisposable +{ + private const uint GalaxySeed = 1; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-runstart-" + Guid.NewGuid().ToString("N")); + private readonly CultCache _cache; + private readonly ItemManager _items; + private readonly Galaxy _galaxy; + private readonly ZoneGenerationSettings _zoneSettings; + private readonly PlanetSettings _planetSettings; + private readonly string _startingHull; + private readonly Faction _protagonist; + + public RunStartTests() + { + var repo = FindRepoRoot(); + Directory.CreateDirectory(_root); + var catalog = Path.Combine(_root, "Aetheria.cc"); + File.Copy(Path.Combine(repo, "GameData", "Aetheria.cc"), catalog); + + // A registry scoped to the shipped assembly's own [CultDocument] types, as RestoredHullsTests composes it, so + // this test assembly's own documents never reach the real catalog's validation. + _cache = new CultCache(Registry()); + _cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalog), AetheriaStores.CatalogTypes); + _cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(_root, "run.cc")), AetheriaStores.RunTypes); + + var authored = AuthoredSettings.Load(repo); + _zoneSettings = authored.Read("ZoneSettings"); + _planetSettings = authored.Read("PlanetSettings"); + var tutorial = authored.Read("TutorialGenerationSettings"); + _galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), + authored.Read("NameGeneratorSettings"), _cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, GalaxySeed); + _items = new ItemManager(_cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + _protagonist = _galaxy.ResolveFaction(tutorial.ProtagonistFaction); + + // GameSettings.StartingHullName is a plain field of the Unity settings object, not a ServerShared subtree + _startingHull = File.ReadLines(Path.Combine(repo, "Assets", "Resources", "Settings.asset")) + .Select(line => Regex.Match(line, @"^ StartingHullName: (.+)$")) + .First(match => match.Success).Groups[1].Value.Trim(); + } + + public void Dispose() + { + _cache.Dispose(); + Directory.Delete(_root, true); + } + + private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null)); + + private static string FindRepoRoot() + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) + if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) + return dir.FullName; + throw new DirectoryNotFoundException("Run from inside the Aetheria repository."); + } + + private Zone Arena(Scenario scenario) => + new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, _galaxy, scenario), _galaxy.Entrance, _galaxy); + + private HullData Hull(string name) => _cache.GetAll().Single(hull => hull.Name == name); + + // The first turret hull, by name, that a manufacturer makes: a real turret, not a fixture. + private HullData TurretHull() => _cache.GetAll() + .Where(hull => hull.HullType == HullType.Turret) + .Where(hull => _cache.GetAll().Any(p => p.Design.Key.Equals(_cache.RefOf(hull).Key))) + .OrderBy(hull => hull.Name, StringComparer.Ordinal) + .First(); + + private CultRecordRef Preset(Loadout loadout) + { + var existing = _cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name); + if (existing != null) return _cache.RefOf(existing); + _cache.Upsert(loadout); + return _cache.RefOf(loadout); + } + + private CultRecordRef Bare(HullData hull) => + Preset(new Loadout { Name = "bare " + hull.Name, Hull = _cache.RefOf(hull), WeaponGroups = new int[0][] }); + + // A fully fitted preset: a generated ship of this hull, captured. + private Loadout Fitted(string hullName) + { + var generator = new LoadoutGenerator(ref _items.Random, _items, null, null, null, 2); + var ship = (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull.Name == hullName)); + Assert.NotEmpty(ship.CargoBays); + return Loadouts.Capture(_items, ship, "fitted " + hullName); + } + + private static ScenarioShip At(CultRecordRef preset, float x, float z) => + new ScenarioShip { Loadout = preset, Position = float2(x, z) }; + + private static ScenarioEntity Placed(CultRecordRef preset, float x, float z, ScenarioStance stance, bool piloted) => + new ScenarioEntity { Loadout = preset, Position = float2(x, z), Stance = stance, Piloted = piloted }; + + private Scenario Quiet(ScenarioShip player, params ScenarioEntity[] entities) => + new Scenario { Name = "test", Seed = GalaxySeed, Ambient = false, Player = player, Entities = entities.ToList() }; + + private RunStart.Staged Stage(Zone zone, Scenario scenario) + { + var failures = new List(); + var staged = RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures); + Assert.Empty(failures); + Assert.NotNull(staged); + return staged; + } + + private static int AgentsOf(Zone zone, Entity entity) => zone.Agents.Count(agent => agent.Ship == entity); + + // New Game is the absent-scenario case: the authored starting hull, flagged as the player's, admitted at the + // origin, for the protagonist faction, with no agent. The hull name is an input: staging honours another one too. + [Fact] + public void WithNoScenarioThePlayerIsTheStartingHullAtTheOrigin() + { + Assert.Equal("LonginusX", _startingHull); + foreach (var hullName in new[] { _startingHull, "Djinni" }) + { + var zone = Arena(null); + var failures = new List(); + var staged = RunStart.Stage(_items, zone, null, hullName, _protagonist, failures); + Assert.Empty(failures); + var player = staged.Player; + Assert.Equal(hullName, player.HullData.Name); + Assert.True(player.IsPlayerShip); + Assert.Contains(player, zone.Entities); + Assert.Same(zone, player.Zone); + Assert.Equal(float3.zero, player.Position); + Assert.Same(_protagonist, player.Faction); + Assert.Equal(0, AgentsOf(zone, player)); + Assert.Empty(staged.Entities); + } + } + + // A scenario's player is its preset, fitted slot for slot, at the authored position and heading, carrying its cargo. + [Fact] + public void AScenarioPlayerIsItsPresetAtItsPlaceWithItsCargo() + { + var fitted = Fitted("Djinni"); + var preset = Preset(fitted); + // Two one-cell designs a manufacturer makes, by name, so the cargo is ordinary branded stock + var cargo = _cache.GetAll() + .Where(design => design.Shape.Coordinates.Length == 1) + .Where(design => _cache.GetAll().Any(p => p.Design.Key.Equals(_cache.RefOf(design).Key))) + .OrderBy(design => design.Name, StringComparer.Ordinal) + .Take(2) + .Select(design => new CultRecordRef(_cache.RefOf(design).Key)) + .ToList(); + Assert.Equal(2, cargo.Count); + var zone = Arena(null); + var scenario = Quiet(new ScenarioShip { Loadout = preset, Position = float2(120, -40), Direction = float2(3, 4), Cargo = cargo }); + + var player = Stage(zone, scenario).Player; + + Assert.Equal("Djinni", player.HullData.Name); + Assert.True(player.IsPlayerShip); + Assert.Contains(player, zone.Entities); + Assert.Equal(float3(120, 0, -40), player.Position); + Assert.True(length(player.Direction - float2(.6f, .8f)) < 1e-5f, $"heading {player.Direction}"); + Assert.Equal( + fitted.Slots.Select(slot => (slot.Position, slot.Design.Key)).OrderBy(s => s.ToString()), + player.Equipment.Concat(player.CargoBays).Concat(player.DockingBays) + .Where(item => item != player.EquippedHull) + .Select(item => (item.Position, _cache.RefOf(_items.GetData(item.EquippableItem)).Key)) + .OrderBy(s => s.ToString())); + Assert.Equal(cargo.Select(design => design.Key.Value).OrderBy(k => k), + player.CargoBays.First().Cargo.Keys.Select(item => item.Data.Key.Value).OrderBy(k => k)); + } + + // A piloted entity gets exactly one agent; an unpiloted one gets none and stays where it was put. + [Fact] + public void APilotedEntityHasOneAgentAndAnUnpilotedOneNoneAndStaysPut() + { + var zone = Arena(null); + var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0), + Placed(Preset(Fitted("LonginusX")), 300, 0, ScenarioStance.Neutral, piloted: true), + Placed(Bare(Hull("LonginusX")), -300, 0, ScenarioStance.Neutral, piloted: false))); + var piloted = staged.Entities[0]; + var unpiloted = staged.Entities[1]; + + Assert.Equal(1, AgentsOf(zone, piloted)); + Assert.Equal(0, AgentsOf(zone, unpiloted)); + Assert.Equal(0, AgentsOf(zone, staged.Player)); + Assert.Contains(piloted, zone.Entities); + Assert.Contains(unpiloted, zone.Entities); + + var start = unpiloted.Position; + for (var tick = 0; tick < 50; tick++) zone.Update(.1f); + Assert.True(length(unpiloted.Position - start) < .01f, $"the unpiloted ship moved to {unpiloted.Position}"); + } + + // A stance holds both ways between the entity and the player. + [Fact] + public void AStanceIsSetBothWays() + { + var longinus = Bare(Hull("LonginusX")); + var staged = Stage(Arena(null), Quiet(At(Bare(Hull("Djinni")), 0, 0), + Placed(longinus, 300, 0, ScenarioStance.Hostile, piloted: false), + Placed(longinus, -300, 0, ScenarioStance.Neutral, piloted: false))); + var player = staged.Player; + var hostile = staged.Entities[0]; + var neutral = staged.Entities[1]; + + Assert.True(player.IsHostileTo(hostile)); + Assert.True(hostile.IsHostileTo(player)); + Assert.False(player.IsHostileTo(neutral)); + Assert.False(neutral.IsHostileTo(player)); + } + + // Ambient: false keeps the stations and drops every generated ship and turret; ambient, and a plain New Game, keep + // them. The arena is the galaxy's entrance pack. + [Fact] + public void AQuietArenaHasNoGeneratedShipOrTurretButKeepsItsStations() + { + (int ships, int turrets, int stations) Census(ZonePack pack) => ( + pack.Entities.OfType().Count(), + pack.Entities.OfType().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Turret), + pack.Entities.OfType().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Station)); + + var plain = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, null)); + var ambient = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scenario { Ambient = true })); + var quietPack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scenario { Ambient = false }); + var quiet = Census(quietPack); + + Assert.Same(quietPack, _galaxy.Entrance.PackedContents); + Assert.True(plain.ships > 0 && plain.turrets > 0, $"a plain arena generated {plain}"); + Assert.True(ambient.ships > 0 && ambient.turrets > 0, $"an ambient arena generated {ambient}"); + Assert.Equal(0, quiet.ships); + Assert.Equal(0, quiet.turrets); + Assert.True(quiet.stations > 0); + Assert.Equal(ambient.stations, quiet.stations); + } + + // A turret preset stages as an orbital entity with no orbit, which stays where it is put. + [Fact] + public void ATurretPresetStagesAsAStationaryOrbitalEntity() + { + var zone = Arena(null); + var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0), + Placed(Bare(TurretHull()), 200, 50, ScenarioStance.Hostile, piloted: false))); + var turret = Assert.IsType(staged.Entities[0]); + Assert.False(turret.OrbitData.IsSet()); + Assert.Contains(turret, zone.Entities); + for (var tick = 0; tick < 20; tick++) zone.Update(.1f); + Assert.Equal(float2(200, 50), turret.Position.xz); + } + + // A station preset is refused, and so is a turret as the player or a piloted turret: each is a staging failure + // naming the hull, with nothing admitted. + [Fact] + public void AStationPresetOrAMisplacedTurretIsAStagingFailure() + { + var station = _cache.GetAll().First(hull => hull.HullType == HullType.Station); + var turret = TurretHull(); + var djinni = Bare(Hull("Djinni")); + foreach (var (scenario, expected) in new[] + { + (Quiet(At(djinni, 0, 0), Placed(Bare(station), 200, 0, ScenarioStance.Neutral, false)), $"{station.Name} is a station hull"), + (Quiet(At(Bare(turret), 0, 0)), $"player: {turret.Name} is not a ship hull"), + (Quiet(At(djinni, 0, 0), Placed(Bare(turret), 200, 0, ScenarioStance.Neutral, true)), "cannot be piloted"), + }) + { + var zone = Arena(null); + var before = zone.Entities.ToList(); + var failures = new List(); + Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures)); + Assert.Contains(failures, failure => failure.Contains(expected)); + Assert.Equal(before, zone.Entities); + Assert.Contains(RunStart.Check(_items, scenario), failure => failure.Contains(expected)); + } + } + + // A scenario naming a missing design or preset fails with each named; the check entry finds the same failures with + // no zone and writes no document, and staging admits nothing, not even the parts that built. + [Fact] + public void AMissingDesignFailsNamedAndChangesNothing() + { + var djinni = Bare(Hull("Djinni")); + var scenario = Quiet( + new ScenarioShip { Loadout = djinni, Cargo = { new CultRecordRef(new CultRecordKey("absent-design")) } }, + Placed(Bare(Hull("LonginusX")), 300, 0, ScenarioStance.Hostile, piloted: true), + Placed(new CultRecordRef(new CultRecordKey("absent-preset")), -300, 0, ScenarioStance.Neutral, false)); + var zone = Arena(null); + var entities = zone.Entities.ToList(); + var agents = zone.Agents.ToList(); + var documents = _cache.AllStoredDocuments.Count(); + + var checkFailures = RunStart.Check(_items, scenario); + Assert.Equal(2, checkFailures.Count); + Assert.Contains("absent-design", checkFailures[0]); + Assert.Contains("absent-preset", checkFailures[1]); + Assert.Equal(documents, _cache.AllStoredDocuments.Count()); + + var failures = new List(); + Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures)); + Assert.Equal(checkFailures, failures); + Assert.Equal(entities, zone.Entities); + Assert.Equal(agents, zone.Agents); + Assert.Empty(RunStart.Check(_items, null)); + } + + // The Q3 rule must not reach a real hull: the hulls scenarios rely on each have a product, and materialize bare, + // branded, under the prelude's availability. + [Fact] + public void TheRealHullsMaterializeBareFromABrandedProduct() + { + var available = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, null, 2); + foreach (var hull in new[] { Hull("Djinni"), Hull("LonginusX"), TurretHull() }) + { + var failures = new List(); + var entity = Loadouts.Materialize(_items, null, _cache.Get(Bare(hull)), available.IsAvailable, failures); + Assert.Empty(failures); + Assert.NotNull(_items.Brand(entity.Hull).Maker); + } + } + + // Zone.Admit is the only creator of agents: construction pilots every packed ship but the player's, and an + // unpiloted admission (a warp arrival, an undock, a spawned turret) gets none. + [Fact] + public void AdmitIsTheOnlyAgentCreator() + { + var pack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, null); + var packedPlayer = pack.Entities.OfType().First(); + packedPlayer.IsPlayerShip = true; + var zone = new Zone(_items, _planetSettings, pack, _galaxy.Entrance, _galaxy); + var ships = zone.Entities.OfType().ToList(); + Assert.True(ships.Count > 1); + foreach (var ship in ships) Assert.Equal(ship.IsPlayerShip ? 0 : 1, AgentsOf(zone, ship)); + + var arriving = (Ship) Loadouts.Materialize(_items, zone, _cache.Get(Bare(Hull("Djinni"))), _ => true, new List()); + var agents = zone.Agents.Count; + zone.Admit(arriving, piloted: false); + Assert.Contains(arriving, zone.Entities); + Assert.Equal(agents, zone.Agents.Count); + } + + // The scenario type is catalog data: it round-trips through the catalog file. + [Fact] + public void AScenarioRoundTripsThroughTheCatalog() + { + var djinni = Bare(Hull("Djinni")); + var cargo = new CultRecordRef(_cache.RefOf(Hull("LonginusX")).Key); + var scenario = new Scenario + { + Name = "Round trip", Brief = "one of everything", Seed = 42, Ambient = true, + Player = new ScenarioShip { Loadout = djinni, Position = float2(1, 2), Direction = float2(0, -1), Cargo = { cargo } }, + Entities = { Placed(djinni, 3, 4, ScenarioStance.Hostile, piloted: true) } + }; + // Written through its own cache over a second copy of the catalog, disposed, then read back fresh + var copy = Path.Combine(_root, "roundtrip.cc"); + File.Copy(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), copy); + using (var writer = new CultCache(Registry())) + { + writer.AddBackingStore(new SingleFileMessagePackBackingStore(copy), AetheriaStores.CatalogTypes); + writer.Commit(batch => batch.Upsert(typeof(Loadout), _cache.Get(djinni), djinni.Key)); + writer.Upsert(scenario); + writer.FlushAsync().Wait(); + } + + using var reopened = new CultCache(Registry()); + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy, true), AetheriaStores.CatalogTypes); + var loaded = Assert.Single(reopened.GetAll()); + Assert.Equal(("Round trip", "one of everything", 42u, true), (loaded.Name, loaded.Brief, loaded.Seed, loaded.Ambient)); + Assert.Equal(djinni.Key, loaded.Player.Loadout.Key); + Assert.Equal((float2(1, 2), float2(0, -1)), (loaded.Player.Position, loaded.Player.Direction)); + Assert.Equal(cargo.Key, Assert.Single(loaded.Player.Cargo).Key); + var entity = Assert.Single(loaded.Entities); + Assert.Equal((djinni.Key, float2(3, 4), ScenarioStance.Hostile, true), (entity.Loadout.Key, entity.Position, entity.Stance, entity.Piloted)); + } +} From f527154e265651d65f8fb84e8727f3bce0a26d83 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:33:04 +0200 Subject: [PATCH 037/113] Scenarios Cut 1: StartGame stages through RunStart; the game's joins admit StartGame's new-run branch no longer generates, unpacks or admits the player: RunStart generates the arena and stages the run, and StartGame enters the zone and binds the returned player (docs/scenarios-cut.md, 2.2, deletion line). PopulateLevel's warp arrival and the spawnturret console command admit through Zone.Admit, unpiloted. The console commands stay (Q4); give's hull route moors a ship at the docked entity and joins a zone only through Entity.TryUndock, which already admits. Needs the Unity batchmode compile; Assembly-CSharp is not built headless. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 25 +++++++------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index c62d980e4..41165eae2 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -583,9 +583,7 @@ public class ActionGameManager : MonoBehaviour var turret = EntitySerializer.Unpack(ItemManager, Zone, loadoutGenerator.GenerateTurretLoadout()); turret.Position.xz = _currentEntity.Position.xz + ItemManager.Random.NextFloat2Direction() * ItemManager.Random.NextFloat(50, 500); - turret.Zone = Zone; - Zone.Entities.Add(turret); - turret.Activate(); + Zone.Admit(turret, piloted: false); }); //Temporary, or not ConsoleController.AddCommand("tow", _ => TowShip()); @@ -732,8 +730,7 @@ public class ActionGameManager : MonoBehaviour CurrentEntity.Deactivate(); CurrentEntity.Zone.Entities.Remove(CurrentEntity); CurrentEntity.Zone = Zone; - Zone.Entities.Add(CurrentEntity); - CurrentEntity.Activate(); + Zone.Admit(CurrentEntity, piloted: false); } ZoneRenderer.LoadZone(Zone); @@ -801,18 +798,14 @@ public class ActionGameManager : MonoBehaviour if (saved == null) { SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance)); + // RunStart owns what a new run starts with (docs/scenarios-cut.md, 2.2); this only enters and binds. + RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, CurrentGalaxy, null); PopulateLevel(CurrentGalaxy.Entrance); - var loadoutGenerator = new LoadoutGenerator(ref ItemManager.Random, ItemManager, CurrentGalaxy, Zone.GalaxyZone, IsTutorial ? CurrentGalaxy.ResolveFaction(Settings.TutorialGenerationSettings.ProtagonistFaction) : null, 2); - var ship = EntitySerializer.Unpack( - ItemManager, - Zone, - loadoutGenerator.GenerateShipLoadout(data => string.IsNullOrEmpty(Settings.StartingHullName) || data.Name==Settings.StartingHullName )); - ((Ship) ship).IsPlayerShip = true; - ship.Position = float3.zero; - ship.Zone = Zone; - Zone.Entities.Add(ship); - ship.Activate(); - BindToEntity(ship); + var failures = new List(); + var staged = RunStart.Stage(ItemManager, Zone, null, Settings.StartingHullName, + IsTutorial ? CurrentGalaxy.ResolveFaction(Settings.TutorialGenerationSettings.ProtagonistFaction) : null, failures) + ?? throw new InvalidOperationException($"The new run could not stage: {string.Join("; ", failures)}"); + BindToEntity(staged.Player); } else { From 72c58d365f85173962ae4353ea476c42b5c542de Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:52:44 +0200 Subject: [PATCH 038/113] Scenarios Cut 1: a bare hull updates Entity.Update walks _orderedEquipment, which only TryEquip and TryUnequip set, so an entity nothing was ever equipped on threw a NullReferenceException on its first tick. Scenario presets stage bare hulls (a turret, an unpiloted target), and RunStartTests found it: MapEntity now sets the ordered equipment to the hull alone when it adds the hull. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index c2d1dbdca..078f2ebda 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -601,6 +601,8 @@ public abstract class Entity var hullData = ItemManager.GetData(Hull) as HullData; EquippedHull = new EquippedItem(ItemManager, Hull, int2.zero, this); Equipment.Add(EquippedHull); + // A bare hull, one TryEquip never ran on, still updates: its ordered equipment is the hull alone. + _orderedEquipment = Equipment.ToArray(); Mass = hullData.Mass; Temperature = new float[hullData.Shape.Width, hullData.Shape.Height]; NewTemperature = new float[hullData.Shape.Width, hullData.Shape.Height]; From 2724a0d8a7f35cc262c5f16d8215d29dcc799d7f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 13:10:03 +0200 Subject: [PATCH 039/113] Scenarios Cut 1: observe an unpiloted ship's steering, not its position The zone's gravity moves every ship, so an unpiloted scenario ship drifts (about 14 m over 5 s at the test's spot) with nothing flying it. The rule is "no agent steers it": the test now checks its movement and look inputs stay untouched every tick, and that it still has no agent afterwards. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunStartTests.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 03945ae78..426b4654e 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -191,7 +191,8 @@ public sealed class RunStartTests : IDisposable player.CargoBays.First().Cargo.Keys.Select(item => item.Data.Key.Value).OrderBy(k => k)); } - // A piloted entity gets exactly one agent; an unpiloted one gets none and stays where it was put. + // A piloted entity gets exactly one agent; an unpiloted one gets none, and nothing steers it. (It is not frozen: + // the zone's gravity still moves it, as it moves any ship, so the rule is observed on its steering inputs.) [Fact] public void APilotedEntityHasOneAgentAndAnUnpilotedOneNoneAndStaysPut() { @@ -208,9 +209,14 @@ public sealed class RunStartTests : IDisposable Assert.Contains(piloted, zone.Entities); Assert.Contains(unpiloted, zone.Entities); - var start = unpiloted.Position; - for (var tick = 0; tick < 50; tick++) zone.Update(.1f); - Assert.True(length(unpiloted.Position - start) < .01f, $"the unpiloted ship moved to {unpiloted.Position}"); + var look = unpiloted.LookDirection; + for (var tick = 0; tick < 50; tick++) + { + zone.Update(.1f); + Assert.Equal(float2.zero, ((Ship) unpiloted).MovementDirection); + Assert.Equal(look, unpiloted.LookDirection); + } + Assert.Equal(0, AgentsOf(zone, unpiloted)); } // A stance holds both ways between the entity and the player. From 7e7c08bc73e27d47c915b2b3289328d38ef0e008 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 13:59:43 +0200 Subject: [PATCH 040/113] Scenarios Cut 1: pin that admission activates An admitted entity that was never activated tracks no hostility, so targeting and grudges never see the stance. The stance test now also reads each side's live EntityHostility, which only activation populates; dropping Activate from Zone.Admit survived the suite before this. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunStartTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 426b4654e..31030a7e8 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -235,6 +235,12 @@ public sealed class RunStartTests : IDisposable Assert.True(hostile.IsHostileTo(player)); Assert.False(player.IsHostileTo(neutral)); Assert.False(neutral.IsHostileTo(player)); + + // Admission activates: the live hostility each side tracks (what targeting and grudges read) agrees. + Assert.True(player.EntityHostility[hostile]); + Assert.True(hostile.EntityHostility[player]); + Assert.False(player.EntityHostility[neutral]); + Assert.False(neutral.EntityHostility[player]); } // Ambient: false keeps the stations and drops every generated ship and turret; ambient, and a plain New Game, keep From de2c659c554ddb9663b65da5a8b814d0a90d34bf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 14:42:45 +0200 Subject: [PATCH 041/113] Scenarios map: the ten product-less designs (industrial deleted, weapons get products) Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index 388745737..cd160e988 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -26,6 +26,15 @@ claim measured against a real file. "No I won't, but you may." When Cut 4 lands, Self runs `git checkout -- GameData/Aetheria.cc` in `F:\Projects\Aetheria`. That one file is the only exception to the rule that agents do not touch the operator's tree. +- **The ten real designs with no product (operator, 2026-09-30).** Soul's census of Cut 1 found that "no product" + was not a clean marker for Q3 test designs, because ten shipped designs have none. The operator ruled: + - **Industrial gear:** Assembly Line, Deep Ore Extractor, Surface Ore Extractor, Industrial Thermostatic Heater, + Refinery, Shipyard. "An old carryover from this Aetheria repo's early history as an RTS game. We'll want those + when we eventually bring production back, but it's also fine to just delete them since the new economy won't + look anything like the old one." **Self: delete them.** Git history keeps them. + - **Weapons:** Autocannon, LRMM72, SRMM72, Tractor Beam. "yep that's a content gap". They get products. + - After both, "no product" means exactly "Q3 test design". A census test pins that: every product-less design in + the shipped catalog is a scenario test design. --- From 48e638b8a8006b2b9df9a7356aab38de65c6e1d2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 14:44:46 +0200 Subject: [PATCH 042/113] Scenarios map: the Industrial Thermostatic Heater stays (stations need it) Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index cd160e988..9cc953069 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -32,6 +32,10 @@ claim measured against a real file. Refinery, Shipyard. "An old carryover from this Aetheria repo's early history as an RTS game. We'll want those when we eventually bring production back, but it's also fine to just delete them since the new economy won't look anything like the old one." **Self: delete them.** Git history keeps them. + **Correction, same day:** the **Industrial Thermostatic Heater stays**. Operator: "that industrial heater is sort + of needed, though, because currently stations are mostly idle so there's nothing to keep them above freezing + and they often cool to near invisibility." Only the other five are deleted. The census test allows product-less + gear that stations actually equip, and that set is derived from station generation, not from a hand list. - **Weapons:** Autocannon, LRMM72, SRMM72, Tractor Beam. "yep that's a content gap". They get products. - After both, "no product" means exactly "Q3 test design". A census test pins that: every product-less design in the shipped catalog is a scenario test design. From b7ba40198155e8b3d41c086fbf705613bc94468f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 14:48:49 +0200 Subject: [PATCH 043/113] Scenarios map: stations spawn the heater (ruling A); heater is generic gear Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index 9cc953069..bdedfa231 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -36,6 +36,14 @@ claim measured against a real file. of needed, though, because currently stations are mostly idle so there's nothing to keep them above freezing and they often cool to near invisibility." Only the other five are deleted. The census test allows product-less gear that stations actually equip, and that set is derived from station generation, not from a hand list. + **Superseded, same day.** Stations have never equipped the heater. Operator: "Stations freezing is an existing + defect, I don't think I ever made those heaters spawn." Station-heater ruling A: "Yep, A. The thermostatic + behavior is the same one used in ship cockpits to keep the player from freezing, and the industrial version + should fit anywhere as a generic equippable tool." + - Every generated station spawns with one heater, through station generation. + - The heater shares the cockpit thermostat's owner. + - It is equippable on any hull. + - Self default: as generic gear it gets a product, so the census test needs no station exception. - **Weapons:** Autocannon, LRMM72, SRMM72, Tractor Beam. "yep that's a content gap". They get products. - After both, "no product" means exactly "Q3 test design". A census test pins that: every product-less design in the shipped catalog is a scenario test design. From 178897ce7a879c6c397e8ef46983aaadf47c4114 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 14:59:50 +0200 Subject: [PATCH 044/113] Scenarios Cut 1: a failed staging reports what it admitted, not a crash "Zone unchanged on failure" was defended only by xunit formatting an entity sequence until the test host overflowed its stack. SafeAssert.Unchanged compares the zone's entities and agents by identity and order and names what changed by hull name; every Assert.Contains over zone.Entities in RunStartTests goes through SafeAssert.In for the same reason. AMissingDesignFailsNamedAndChangesNothing gains the partial-admission case: a player and a piloted entity that build cleanly, then a later entity whose preset is missing. Admitting the built player before the all-or-nothing return now fails by name ("the zone's entities changed, 5 -> 6: added [Djinni]"). Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunStartTests.cs | 33 +++++++++++++------- tests/Aetheria.Shared.Tests/SafeAssert.cs | 17 ++++++++++ 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 31030a7e8..3ae6ead30 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -147,8 +147,8 @@ public sealed class RunStartTests : IDisposable var player = staged.Player; Assert.Equal(hullName, player.HullData.Name); Assert.True(player.IsPlayerShip); - Assert.Contains(player, zone.Entities); - Assert.Same(zone, player.Zone); + SafeAssert.In(zone, player, "the player is admitted"); + Assert.True(zone == player.Zone, "the player's zone is the arena"); Assert.Equal(float3.zero, player.Position); Assert.Same(_protagonist, player.Faction); Assert.Equal(0, AgentsOf(zone, player)); @@ -178,7 +178,7 @@ public sealed class RunStartTests : IDisposable Assert.Equal("Djinni", player.HullData.Name); Assert.True(player.IsPlayerShip); - Assert.Contains(player, zone.Entities); + SafeAssert.In(zone, player, "the player is admitted"); Assert.Equal(float3(120, 0, -40), player.Position); Assert.True(length(player.Direction - float2(.6f, .8f)) < 1e-5f, $"heading {player.Direction}"); Assert.Equal( @@ -206,8 +206,8 @@ public sealed class RunStartTests : IDisposable Assert.Equal(1, AgentsOf(zone, piloted)); Assert.Equal(0, AgentsOf(zone, unpiloted)); Assert.Equal(0, AgentsOf(zone, staged.Player)); - Assert.Contains(piloted, zone.Entities); - Assert.Contains(unpiloted, zone.Entities); + SafeAssert.In(zone, piloted, "the piloted entity is admitted"); + SafeAssert.In(zone, unpiloted, "the unpiloted entity is admitted"); var look = unpiloted.LookDirection; for (var tick = 0; tick < 50; tick++) @@ -276,7 +276,7 @@ public sealed class RunStartTests : IDisposable Placed(Bare(TurretHull()), 200, 50, ScenarioStance.Hostile, piloted: false))); var turret = Assert.IsType(staged.Entities[0]); Assert.False(turret.OrbitData.IsSet()); - Assert.Contains(turret, zone.Entities); + SafeAssert.In(zone, turret, "the turret is admitted"); for (var tick = 0; tick < 20; tick++) zone.Update(.1f); Assert.Equal(float2(200, 50), turret.Position.xz); } @@ -297,17 +297,19 @@ public sealed class RunStartTests : IDisposable }) { var zone = Arena(null); - var before = zone.Entities.ToList(); + var entities = zone.Entities.ToList(); + var agents = zone.Agents.ToList(); var failures = new List(); Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures)); Assert.Contains(failures, failure => failure.Contains(expected)); - Assert.Equal(before, zone.Entities); + SafeAssert.Unchanged(zone, entities, agents); Assert.Contains(RunStart.Check(_items, scenario), failure => failure.Contains(expected)); } } // A scenario naming a missing design or preset fails with each named; the check entry finds the same failures with - // no zone and writes no document, and staging admits nothing, not even the parts that built. + // no zone and writes no document, and staging admits nothing, not even the parts that built: a player and a piloted + // entity that build cleanly stay out when a later entity fails. [Fact] public void AMissingDesignFailsNamedAndChangesNothing() { @@ -330,8 +332,15 @@ public sealed class RunStartTests : IDisposable var failures = new List(); Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures)); Assert.Equal(checkFailures, failures); - Assert.Equal(entities, zone.Entities); - Assert.Equal(agents, zone.Agents); + SafeAssert.Unchanged(zone, entities, agents); + + var lateFailure = Quiet(At(djinni, 0, 0), + Placed(Bare(Hull("LonginusX")), 300, 0, ScenarioStance.Hostile, piloted: true), + Placed(new CultRecordRef(new CultRecordKey("absent-preset")), -300, 0, ScenarioStance.Neutral, false)); + failures.Clear(); + Assert.Null(RunStart.Stage(_items, zone, lateFailure, _startingHull, _protagonist, failures)); + Assert.Contains("absent-preset", Assert.Single(failures)); + SafeAssert.Unchanged(zone, entities, agents); Assert.Empty(RunStart.Check(_items, null)); } @@ -366,7 +375,7 @@ public sealed class RunStartTests : IDisposable var arriving = (Ship) Loadouts.Materialize(_items, zone, _cache.Get(Bare(Hull("Djinni"))), _ => true, new List()); var agents = zone.Agents.Count; zone.Admit(arriving, piloted: false); - Assert.Contains(arriving, zone.Entities); + SafeAssert.In(zone, arriving, "the arrival is admitted"); Assert.Equal(agents, zone.Agents.Count); } diff --git a/tests/Aetheria.Shared.Tests/SafeAssert.cs b/tests/Aetheria.Shared.Tests/SafeAssert.cs index 2ab52d6bd..ca29f071a 100644 --- a/tests/Aetheria.Shared.Tests/SafeAssert.cs +++ b/tests/Aetheria.Shared.Tests/SafeAssert.cs @@ -3,6 +3,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ using System.Collections.Generic; +using System.Linq; using Xunit; // Assertions over collections of entities and shots. xunit formats a collection's contents into its failure @@ -34,4 +35,20 @@ internal static class SafeAssert public static void NotIn(Zone zone, Entity entity, string why = "entity should have left the zone") => Assert.False(zone.Entities.Contains(entity), why); + + // The zone holds exactly the entities and agents it held before, in the same order: nothing admitted, nothing + // lost. A failure names what changed by hull name. + public static void Unchanged(Zone zone, List entities, List agents) + { + Same(entities, zone.Entities.ToList(), entity => entity.Name, "entities"); + Same(agents, zone.Agents, agent => agent.Ship.Name, "agents"); + } + + private static void Same(List before, List now, System.Func name, string what) where T : class + { + var added = now.Where(x => !before.Contains(x)).Select(name).ToList(); + var lost = before.Where(x => !now.Contains(x)).Select(name).ToList(); + var inOrder = now.Count == before.Count && now.Zip(before, ReferenceEquals).All(same => same); + Assert.True(inOrder, $"the zone's {what} changed, {before.Count} -> {now.Count}: added [{string.Join(", ", added)}], lost [{string.Join(", ", lost)}]"); + } } From 929020e7df46bf4d76b3333e72d4268c668ab694 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 15:00:44 +0200 Subject: [PATCH 045/113] Scenarios Cut 1: a quiet arena drops a story station's turrets too The quiet-arena test ran with an empty Narrative directory, so the story-station branch of ZoneGenerator never ran under Ambient: false and keeping its turrets went unnoticed (Soul's S06). The test now places a story station in the entrance, as the narrative loader would, and checks it is generated. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunStartTests.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 3ae6ead30..5065e4398 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -244,7 +244,8 @@ public sealed class RunStartTests : IDisposable } // Ambient: false keeps the stations and drops every generated ship and turret; ambient, and a plain New Game, keep - // them. The arena is the galaxy's entrance pack. + // them. The arena is the galaxy's entrance pack. The entrance holds a story station, placed as the narrative would + // place one, so a story station's own turrets are dropped too. [Fact] public void AQuietArenaHasNoGeneratedShipOrTurretButKeepsItsStations() { @@ -253,6 +254,10 @@ public sealed class RunStartTests : IDisposable pack.Entities.OfType().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Turret), pack.Entities.OfType().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Station)); + _galaxy.Entrance.Locations.Add(new LocationStory + { + Zone = _galaxy.Entrance, Name = "story", Type = LocationType.Station, Faction = _protagonist, Turrets = 2 + }); var plain = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, null)); var ambient = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scenario { Ambient = true })); var quietPack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scenario { Ambient = false }); @@ -265,6 +270,7 @@ public sealed class RunStartTests : IDisposable Assert.Equal(0, quiet.turrets); Assert.True(quiet.stations > 0); Assert.Equal(ambient.stations, quiet.stations); + Assert.Contains(quietPack.Entities.OfType(), station => station.Story == 0); } // A turret preset stages as an orbital entity with no orbit, which stays where it is put. From ff75b8ea34181c62d252d9c32d9b77832f2159e1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 15:09:12 +0200 Subject: [PATCH 046/113] Scenarios Cut 1: a stance survives docking; it goes only with an entity gone for good Every entity's zone-removal subscription dropped its IFF override for whatever left the zone, and TryDock removes the docking ship, so a scenario stance was lost one way (the other entity forgot; the docked ship, deactivated, kept its own) as soon as the player docked. The removal event already tells the two apart: TryDock parents the ship to its dock before removing it, and every leaving-for-good path (death, warp, unload) leaves with no parent. The override is now dropped only for an entity that leaves unparented. Tests: a hostile stance holds both ways, including the live hostility each side tracks, across dock and undock; a destroyed entity takes the player's stance toward it with it. UndockingAdmitsWithNoAgent commits Soul's probe: undock admits unpiloted, active, with no agent. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 4 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 50 ++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 078f2ebda..162848b9b 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -204,7 +204,9 @@ public abstract class Entity VisibleEntities.Remove(remove.Value); VisibleEnemies.Remove(remove.Value); VisibleFriendlies.Remove(remove.Value); - _iffOverrides.Remove(remove.Value); + // A stance lasts the sitting: it goes with an entity gone for good (destroyed, warped away, unloaded), not + // with a ship docking, which leaves the zone parented to its dock (TryDock) and rejoins it on undock. + if (remove.Value.Parent == null) _iffOverrides.Remove(remove.Value); if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs)) { foreach (var s in grudgeSubs) s.Dispose(); diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 5065e4398..0f93591ad 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -243,6 +243,56 @@ public sealed class RunStartTests : IDisposable Assert.False(neutral.EntityHostility[player]); } + // A stance lasts the sitting: the player docking and undocking leaves it standing both ways. It goes with an entity + // gone for good: once an entity is destroyed, the player holds no stance toward it. + [Fact] + public void AStanceSurvivesDockingButNotDestruction() + { + var zone = Arena(null); + var longinus = Bare(Hull("LonginusX")); + var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0), + Placed(longinus, 300, 0, ScenarioStance.Hostile, piloted: false), + Placed(longinus, -300, 0, ScenarioStance.Hostile, piloted: false))); + var player = staged.Player; + var hostile = staged.Entities[0]; + var doomed = staged.Entities[1]; + var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); + + Assert.NotNull(station.TryDock(player)); + SafeAssert.NotIn(zone, player, "a docked ship is out of the zone"); + Assert.True(station.TryUndock(player)); + Assert.True(player.IsHostileTo(hostile), "the player's stance was lost over docking"); + Assert.True(hostile.IsHostileTo(player), "the entity's stance was lost over docking"); + Assert.True(player.EntityHostility[hostile], "the player's live hostility was lost over docking"); + Assert.True(hostile.EntityHostility[player], "the entity's live hostility was lost over docking"); + + // Presets carry no faction, so without its stance the derived rule is not hostile + Assert.True(player.Faction == null && doomed.Faction == null, "the fixture's ships are factionless"); + Assert.True(player.IsHostileTo(doomed)); + doomed.Hull.Durability = 0; + doomed.HullDamage.OnNext(1); + SafeAssert.NotIn(zone, doomed, "a destroyed entity leaves the zone"); + Assert.False(player.IsHostileTo(doomed), "the stance toward a destroyed entity outlived it"); + } + + // Undocking admits the ship unpiloted: back in the zone, active (its live hostility is tracked), with no agent. + [Fact] + public void UndockingAdmitsWithNoAgent() + { + var zone = Arena(null); + var player = Stage(zone, null).Player; + var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); + var agents = zone.Agents.Count; + + Assert.NotNull(station.TryDock(player)); + SafeAssert.NotIn(zone, player, "a docked ship is out of the zone"); + Assert.True(station.TryUndock(player)); + SafeAssert.In(zone, player, "an undocked ship is back in the zone"); + Assert.Equal(agents, zone.Agents.Count); + Assert.Equal(0, AgentsOf(zone, player)); + Assert.True(player.EntityHostility.ContainsKey(station), "an undocked ship is active"); + } + // Ambient: false keeps the stations and drops every generated ship and turret; ambient, and a plain New Game, keep // them. The arena is the galaxy's entrance pack. The entrance holds a story station, placed as the narrative would // place one, so a story station's own turrets are dropped too. From 27598a464a24cdd0a293d7ad6af362777375095f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 15:12:41 +0200 Subject: [PATCH 047/113] BaseState: an includeChildren walk keeps its state in locals AddTransition's graph walk kept its visited, leaf and ignore sets in static lists, so two Minions built at once on different threads (zones are built in parallel) walked and cleared each other's state. Soul hit it as "collection was modified" during parallel test runs. The sets are now the call's own. BaseStateTests starts sixteen walks together behind a barrier, fifty rounds, over prebuilt 200-state chains; with the statics restored every run failed (several hundred of 800 walks wrong or throwing); with locals, clean. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Agents/States/BaseState.cs | 39 +++++----- tests/Aetheria.Shared.Tests/BaseStateTests.cs | 72 +++++++++++++++++++ 2 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/BaseStateTests.cs diff --git a/Assets/Scripts/ServerShared/Agents/States/BaseState.cs b/Assets/Scripts/ServerShared/Agents/States/BaseState.cs index f3e4c2bcf..98f303094 100644 --- a/Assets/Scripts/ServerShared/Agents/States/BaseState.cs +++ b/Assets/Scripts/ServerShared/Agents/States/BaseState.cs @@ -12,45 +12,40 @@ public class BaseState _agent = agent; } - private static List _lastVisitedStates = new List(); - private static List _leafStates = new List(); - private static List _visitedStates = new List(); - private static HashSet _ignoreStates = new HashSet(); + // The walk's state is the call's own: zones (and so their Minions) are built on more than one thread. public void AddTransition(BaseState targetState, Func condition, Action onTransition = null, bool includeChildren = false, params BaseState[] ignoreStates) { Transitions.Add(new StateTransition(targetState, condition, onTransition)); - + // Traverse the state graph, adding every state that is reachable from this state without traversing over ignored nodes if (includeChildren) { - _visitedStates.Clear(); - _ignoreStates.Clear(); + var visitedStates = new List(); + var ignored = new HashSet(); if(ignoreStates!=null) foreach (var ignoreState in ignoreStates) - _ignoreStates.Add(ignoreState); - _leafStates.Clear(); - _leafStates.Add(this); - _ignoreStates.Add(this); - _ignoreStates.Add(targetState); - while (_leafStates.Count > 0) + ignored.Add(ignoreState); + var leafStates = new List { this }; + ignored.Add(this); + ignored.Add(targetState); + while (leafStates.Count > 0) { - _lastVisitedStates.Clear(); - _lastVisitedStates.AddRange(_leafStates); - _leafStates.Clear(); - foreach (var state in _lastVisitedStates) + var lastVisitedStates = leafStates; + leafStates = new List(); + foreach (var state in lastVisitedStates) { foreach (var transition in state.Transitions) { - if(!_ignoreStates.Contains(transition.TargetState)) + if(!ignored.Contains(transition.TargetState)) { - _visitedStates.Add(transition.TargetState); - _ignoreStates.Add(transition.TargetState); - _leafStates.Add(transition.TargetState); + visitedStates.Add(transition.TargetState); + ignored.Add(transition.TargetState); + leafStates.Add(transition.TargetState); } } } } - foreach (var state in _visitedStates) + foreach (var state in visitedStates) { state.AddTransition(targetState, condition, onTransition); } diff --git a/tests/Aetheria.Shared.Tests/BaseStateTests.cs b/tests/Aetheria.Shared.Tests/BaseStateTests.cs new file mode 100644 index 000000000..34eb409f0 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/BaseStateTests.cs @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Xunit; + +// A transition added with includeChildren reaches every state its source can reach. Zones, and so their Minions' +// state graphs, are built on more than one thread at once, so each walk must see only its own graph. +public sealed class BaseStateTests +{ + private const int Depth = 200; + + // A chain root -> s1 -> ... -> sN, and the exit a transition from root will lead to. + private static (BaseState root, List chain, BaseState exit) Chain() + { + var root = new BaseState(null); + var chain = new List(); + var previous = root; + for (var i = 0; i < Depth; i++) + { + var state = new BaseState(null); + previous.AddTransition(state, () => false); + chain.Add(state); + previous = state; + } + return (root, chain, new BaseState(null)); + } + + // Every state on the chain gains exactly one transition to the exit, and the exit gains none. + private static bool ExitReachesTheWholeChain((BaseState root, List chain, BaseState exit) graph) => + graph.chain.Prepend(graph.root).All(state => state.Transitions.Count(t => t.TargetState == graph.exit) == 1) + && graph.exit.Transitions.Count == 0; + + private static void AddExit((BaseState root, List chain, BaseState exit) graph) => + graph.root.AddTransition(graph.exit, () => false, null, true, graph.root); + + [Fact] + public void AnIncludeChildrenTransitionReachesTheWholeGraph() + { + var graph = Chain(); + AddExit(graph); + Assert.True(ExitReachesTheWholeChain(graph)); + } + + // Graphs built beforehand, then their walks started together on parallel threads, round after round. + [Fact] + public void GraphWalksOnParallelThreadsSeeOnlyTheirOwnGraph() + { + const int rounds = 50, width = 16; + var wrong = 0; + var threw = 0; + for (var round = 0; round < rounds; round++) + { + var graphs = Enumerable.Range(0, width).Select(_ => Chain()).ToArray(); + using var start = new Barrier(width); + var threads = graphs.Select(graph => new Thread(() => + { + start.SignalAndWait(); + try { AddExit(graph); } + catch (Exception) { Interlocked.Increment(ref threw); } + })).ToArray(); + foreach (var thread in threads) thread.Start(); + foreach (var thread in threads) thread.Join(); + wrong += graphs.Count(graph => !ExitReachesTheWholeChain(graph)); + } + Assert.True(wrong == 0 && threw == 0, $"of {rounds * width} parallel walks, {wrong} left the wrong graph and {threw} threw"); + } +} From 653ba490a7a01f5b343c4ec40a1762ad97c395f8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 15:45:18 +0200 Subject: [PATCH 048/113] Scenarios content: delete five industrial placeholders; products for four weapons and the heater Operator rulings, 2026-09-30 (docs/scenarios-cut.md, "the ten real designs with no product"), landed through a transient `AetherDb product-gap [apply]`: - Deleted, nothing referencing them: Assembly Line, Deep Ore Extractor, Surface Ore Extractor, Refinery, Shipyard (RTS-era placeholders, price and durability 0, three with unreadable behaviour entries). - Products, maker taken from the nearest analogue's product: Autocannon -> Autocannon by Zhestokost (DeathCluster: Ballistic, 4 cells, AutoWeapon) LRMM72 -> LRMM72 by Rossum & Douglas (GT 3K: the only other unguided launcher) SRMM72 -> SRMM72 by Death Monkey Explosives (scorched void policy: guided, nearest size and price) Tractor Beam -> Space Tractor by Ewan Hart Inc (no analogue; the wishlist's "Space Tractor: It's a tractor. In space.", content-batch-one section 10) Industrial Thermostatic Heater -> same name by Lightsail Express (Cockpit 2x2, the same Thermotoggle/EnergyDraw/Heat group) Each product carries its design's roles at the default quality (0.5 +- 0.15). - The heater design kept (operator), with Price 0 -> 100,000 (between Cockpit 2x2 and Fire Control Array) and Durability 0 -> 100 (Cockpit 2x2's): at zero price generation never offers it, at zero durability it never comes online. The rewrite stores every weapon record at the current schema, so FireControlCut124's shipped-catalog check now expects an Exact migration report instead of drift with slot 32 defaulted. `AetherDb census` reports no unsold design. Tractor Beam keeps price 0, durability 0 and no behaviour. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 +- .../FireControlCut124Tests.cs | 7 +- tools/AetherDb/Program.cs | 83 ++++++++++++++++++- 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 200c93e22..675434aba 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2aa79c0adca4e50b608f1c7154ad847777d62af83b918b6de19eb881e8fdd7e9 -size 12698108 +oid sha256:56b4875145c5dc2552a94318a593d11bbcf35db4fabdd7cbfda71552bcedd834 +size 12698071 diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index d55bc6df6..5811508d3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -2584,7 +2584,8 @@ public sealed class FireControlCut124Tests : IDisposable } // Every WeaponItemData in the shipped catalog has a null BlastRadius and a null Fuse, and the weapon - // schema's own migration report is CompatibleDrift with only slot 32 defaulted and no other slots touched. + // schema's own migration report is Exact. (Until the scenarios product-gap pass rewrote the catalog on + // 2026-09-30 it was CompatibleDrift with only slot 32 defaulted: the file predated BlastRadius and Fuse.) [Fact] public void ShippedCatalogIsNeutralOnBlastRadiusAndFuse() { @@ -2604,8 +2605,8 @@ public sealed class FireControlCut124Tests : IDisposable Assert.NotEmpty(reports); foreach (var report in reports) { - Assert.Equal(CultSchemaMigrationKind.CompatibleDrift, report.Kind); - Assert.Equal(new[] { 32 }, report.DefaultedMissingSlots); + Assert.Equal(CultSchemaMigrationKind.Exact, report.Kind); + Assert.Empty(report.DefaultedMissingSlots); Assert.Empty(report.IgnoredExtraSlots); } } diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 46aae9575..4635800a6 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,8 +36,9 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); + case "product-gap": return ProductGap(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], product-gap [apply]"); return 1; } } @@ -1474,4 +1475,84 @@ public static class Program Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); return 0; } + + // Scenarios map, operator rulings 2026-09-30 ("the ten product-less designs"): after this, a design with no + // product is a Q3 scenario test design and nothing else. The five RTS-era industrial placeholders are deleted + // (nothing references them). The four weapons are a content gap and get a product each, from the maker of their + // nearest analogue: same hardpoint and firing behaviour, then nearest size. The Industrial Thermostatic Heater is + // generic gear every generated station carries; it gets a product from the maker of the cockpit whose thermostat + // it shares (Thermotoggle, EnergyDraw, Heat in one group), and the price and durability without which generation + // cannot offer it (RandomProducts skips a zero price) and it can never come online (zero durability). + private static int ProductGap(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + var cache = db.Cache; + + T Design(string name) where T : EquippableItemData => + cache.GetAll().SingleOrDefault(d => d.Name == name) ?? throw new InvalidOperationException($"No design named \"{name}\"."); + Faction MakerOf(string productName) => + cache.Get(cache.GetAll().Single(p => p.Name == productName).Manufacturer); + Faction Named(string factionName) => cache.GetAll().Single(f => f.Name == factionName); + bool HasProduct(EquippableItemData design) => + cache.GetAll().Any(p => p.Design.Key.Equals(cache.RefOf(design).Key)); + + var industrial = new[] { "Assembly Line", "Deep Ore Extractor", "Surface Ore Extractor", "Refinery", "Shipyard" } + .Select(Design).ToArray(); + foreach (var design in industrial) + if (HasProduct(design)) throw new InvalidOperationException($"{design.Name} has a product; it is not a placeholder."); + + var heater = Design("Industrial Thermostatic Heater"); + var products = new (EquippableItemData Design, string Name, string Description, Faction Maker, string Analogue)[] + { + (Design("Autocannon"), "Autocannon", "", MakerOf("DeathCluster"), + "DeathCluster: Ballistic, 4 cells, AutoWeapon"), + (Design("LRMM72"), "LRMM72", "", MakerOf("GT 3K"), + "GT 3K: the only other unguided LauncherData"), + (Design("SRMM72"), "SRMM72", "", MakerOf("scorched void policy"), + "scorched void policy: GuidedWeapon launcher, nearest in size and price"), + (Design("Tractor Beam"), "Space Tractor", "It's a tractor. In space.", Named("Ewan Hart Inc"), + "none (no behaviours); Ewan Hart's wishlist Space Tractor, docs/content-batch-one.md section 10"), + (heater, "Industrial Thermostatic Heater", "", MakerOf("Cockpit 2x2"), + "Cockpit 2x2: the same thermostat behaviours"), + }; + foreach (var (design, _, _, _, _) in products) + if (HasProduct(design)) throw new InvalidOperationException($"{design.Name} already has a product."); + const int heaterPrice = 100000; // between Cockpit 2x2 (50,000) and Fire Control Array (150,000) + const float heaterDurability = 100; // Cockpit 2x2's + + Console.WriteLine("Delete (no product, no reference):"); + foreach (var design in industrial) Console.WriteLine($" {design.Name}"); + Console.WriteLine("Products:"); + foreach (var (design, name, _, maker, analogue) in products) + Console.WriteLine($" {design.Name,-32} -> {name,-32} by {maker.Name,-24} ({analogue})"); + Console.WriteLine($"Heater design: Price {heater.Price} -> {heaterPrice}, Durability {heater.Durability} -> {heaterDurability}"); + + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to land it."); + return 0; + } + + heater.Price = heaterPrice; + heater.Durability = heaterDurability; + CultRecordRefs.Validate(heater); + var industrialKeys = industrial.Select(design => cache.RefOf(design).Key).ToArray(); + var heaterKey = cache.RefOf(heater).Key; + var made = products.Select(entry => new FactionProductData + { + Name = entry.Name, + Description = entry.Description, + Design = new CultRecordRef(cache.RefOf(entry.Design).Key), + Manufacturer = cache.RefOf(entry.Maker), + Roles = (entry.Design.Roles ?? new List()).Select(role => new ProductRole { Role = role.Name }).ToList() + }).ToArray(); + cache.Commit(batch => + { + foreach (var key in industrialKeys) batch.Remove(key); + batch.Upsert(typeof(GearData), heater, heaterKey); + foreach (var product in made) batch.Upsert(typeof(FactionProductData), product, new CultRecordKey(Guid.NewGuid().ToString("N"))); + }); + Console.WriteLine($"\nLanded {industrial.Length} deletions, {made.Length} products and the heater's price and durability in Aetheria.cc"); + return 0; + } } From f4703e82889a0c708783c26c0daf4afb9acd2acb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 15:45:30 +0200 Subject: [PATCH 049/113] Scenarios Cut 1: every unsold design is a scenario test design Q3 with the 2026-09-30 ruling: a design no product sells is a scenario test design and nothing else. A test design is recognised as one a scenario places: its preset's hull or a slot of it, or cargo it carries. The census holds for the shipped catalog (today there is no unsold design at all), and in a scratch copy a new unsold design is flagged until a scenario places it, once through a preset slot and once as an entity's cargo. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunStartTests.cs | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 0f93591ad..a1425b251 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -400,6 +400,59 @@ public sealed class RunStartTests : IDisposable Assert.Empty(RunStart.Check(_items, null)); } + // Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a + // scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of + // it, or cargo it carries. These are the unsold designs no scenario places, by name. + private static List UnsoldAndUnplaced(CultCache cache) + { + var sold = cache.GetAll().Select(product => product.Design.Key).ToHashSet(); + var placed = new HashSet(); + foreach (var scenario in cache.GetAll()) + foreach (var ship in scenario.Entities.Prepend(scenario.Player).Where(ship => ship != null)) + { + foreach (var cargo in ship.Cargo) placed.Add(cargo.Key); + var preset = cache.Get(ship.Loadout); + if (preset == null) continue; + placed.Add(preset.Hull.Key); + foreach (var slot in preset.Slots) placed.Add(slot.Design.Key); + } + return cache.GetAll() + .Where(design => !sold.Contains(cache.RefOf(design).Key) && !placed.Contains(cache.RefOf(design).Key)) + .Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToList(); + } + + // The shipped catalog holds no unsold design outside a scenario (today, none unsold at all). A new unsold design is + // flagged until a scenario places it, in a preset's slot or as an entity's cargo. + [Fact] + public void EveryUnsoldDesignIsAScenarioTestDesign() + { + Assert.Empty(UnsoldAndUnplaced(_cache)); + + GearData Probe(string name) + { + var design = new GearData { Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1 }; + _cache.Upsert(design); + return design; + } + var fitted = Probe("Census Fitted"); + var carried = Probe("Census Carried"); + Assert.Equal(new[] { "Census Carried", "Census Fitted" }, UnsoldAndUnplaced(_cache)); + + var preset = Preset(new Loadout + { + Name = "census preset", Hull = _cache.RefOf(Hull("Djinni")), WeaponGroups = new int[0][], + Slots = { new LoadoutSlot { Design = new CultRecordRef(_cache.RefOf(fitted).Key) } } + }); + var scenario = Quiet(At(Bare(Hull("Djinni")), 0, 0), Placed(preset, 100, 0, ScenarioStance.Neutral, false)); + scenario.Name = "census"; + _cache.Upsert(scenario); + Assert.Equal(new[] { "Census Carried" }, UnsoldAndUnplaced(_cache)); + + scenario.Entities[0].Cargo.Add(new CultRecordRef(_cache.RefOf(carried).Key)); + _cache.Upsert(scenario); + Assert.Empty(UnsoldAndUnplaced(_cache)); + } + // The Q3 rule must not reach a real hull: the hulls scenarios rely on each have a product, and materialize bare, // branded, under the prelude's availability. [Fact] From 89ef80d298c9df8606156f397538b0cb58d27dd6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 15:45:32 +0200 Subject: [PATCH 050/113] Stations: every generated station carries a heater Operator ruling, 2026-09-30: an idle station has no heat source and cools toward freezing and invisibility, so station generation (LoadoutGenerator, the one owner of station loadouts) equips one heater after the docking bay, from the products whose design is Tool gear with a low-pass Thermotoggle and Heat, the cockpit's thermostat composition. It is required, like the docking bay. Not yet warm: generated stations have no reactor (no reactor product fits Zenith's 16-cell Reactor hardpoint), so the heater's EnergyDraw gets no supply and the station still cools (280 K to ~247 K over ten idle minutes). The test that an idle station stays above freezing waits on that. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/LoadoutGenerator.cs | 18 ++++++++++++++++++ tests/Aetheria.Shared.Tests/RunStartTests.cs | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 044b282df..e78dc9a4f 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -92,6 +92,19 @@ public class LoadoutGenerator throw new InvalidLoadoutException("Failed to equip selected docking bay!"); } + // Every station carries a heater (operator, 2026-09-30): an idle station has no other heat source and cools + // toward freezing and invisibility. A heater is general gear whose thermostat runs its heat only below a + // target, the composition a cockpit uses: a low-pass Thermotoggle, then Heat, in its behaviours. + emptyShape = entity.UnoccupiedSpace; + var (heaterProduct, heaterData) = RandomProduct(2, item => IsHeater(item) && item.Shape.FitsWithin(emptyShape, out _, out _), required: true); + if (heaterData == null) throw new InvalidLoadoutException("No compatible heater found for station!"); + + heaterData.Shape.FitsWithin(emptyShape, out var heaterRotation, out var heaterPosition); + var heater = ItemManager.CreateInstance(heaterProduct) as EquippableItem; + heater.Rotation = heaterRotation; + if (!entity.TryEquip(heater, heaterPosition)) + throw new InvalidLoadoutException("Failed to equip selected heater!"); + FillInterior(entity); var cargo = entity.CargoBays.First(); @@ -109,6 +122,11 @@ public class LoadoutGenerator return EntitySerializer.Pack(entity) as OrbitalEntityPack; } + private static bool IsHeater(GearData item) => + item.HardpointType == HardpointType.Tool && + item.Behaviors.Any(b => b is ThermotoggleData { HighPass: false }) && + item.Behaviors.Any(b => b is HeatData); + // Every entity needs a hull, so hulls are always required public (FactionProductData product, HullData design) RandomHull(HullType type, Predicate hullFilter = null) { diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index a1425b251..fd0356ef9 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -400,6 +400,23 @@ public sealed class RunStartTests : IDisposable Assert.Empty(RunStart.Check(_items, null)); } + // A heater is gear whose thermostat runs its heat only below a target: a low-pass thermotoggle, then heat. + private static bool IsHeater(EquippedItem item) => + item.Behaviors.OfType().Any(t => !t.ThermotoggleData.HighPass) && item.Behaviors.OfType().Any(); + + // Every generated station carries exactly one heater. + [Fact] + public void EveryGeneratedStationCarriesOneHeater() + { + var zone = Arena(null); + var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); + for (var i = 0; i < 8; i++) + { + var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + Assert.True(station.Equipment.Count(IsHeater) == 1, $"station {i} carries {station.Equipment.Count(IsHeater)} heaters"); + } + } + // Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a // scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of // it, or cargo it carries. These are the unsold designs no scenario places, by name. From eff52bbd974138311fc286147e3670ca77e2a68c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 16:03:49 +0200 Subject: [PATCH 051/113] Scenarios map: station reactor, heater is tool gear, no save retrofit, pickup needs a tractor Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index bdedfa231..b56f98ad1 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -44,6 +44,15 @@ claim measured against a real file. - The heater shares the cockpit thermostat's owner. - It is equippable on any hull. - Self default: as generic gear it gets a product, so the census test needs no station exception. + **Station power and the Tractor Beam (operator, 2026-09-30):** + - "Yep, we need a station reactor." Author a reactor that fits Zenith's 16-cell reactor slot, with a product, + so the station heater has power. + - "Nope, ships don't need these, 'fits anywhere' was a description of tool-type gear, which doesn't require a + specific hardpoint." The heater keeps its 12-cell shape. The ship-heater test is dropped. + - "Screw old saves." No retrofit of heaters onto stations loaded from existing saves. + - "Tractor Beam is supposed to be for item pickup. A ship without a pickup behavior shouldn't be able to pick + things up, but there is currently no such limitation AFAIK." Picking up items requires a pickup Behavior, + which the Tractor Beam carries. - **Weapons:** Autocannon, LRMM72, SRMM72, Tractor Beam. "yep that's a content gap". They get products. - After both, "no product" means exactly "Q3 test design". A census test pins that: every product-less design in the shipped catalog is a scenario test design. From a1564c7d03400656b543a41361731d44f3941638 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 16:08:22 +0200 Subject: [PATCH 052/113] Scenarios map: everyone gets a tractor beam; pickup follows the shield/pickup presentation contract Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index b56f98ad1..008f0cd20 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -53,6 +53,11 @@ claim measured against a real file. - "Tractor Beam is supposed to be for item pickup. A ship without a pickup behavior shouldn't be able to pick things up, but there is currently no such limitation AFAIK." Picking up items requires a pickup Behavior, which the Tractor Beam carries. + - Starting loadouts (operator): "Yep, everyone gets tractor beams. Pickup behavior was previously discussed as + part of the field shield map, as one of the behaviors which we'll have various visual effects for. The tractor + beam item would use the existing pickup VFX but with the standardized pickup animation." Every generated ship + and the player's starting ship carry a Tractor Beam. The pickup Behavior and its presentation follow + `docs/shield-presentation-contract.md` ("Shield and Pickup Presentation Contract"). - **Weapons:** Autocannon, LRMM72, SRMM72, Tractor Beam. "yep that's a content gap". They get products. - After both, "no product" means exactly "Q3 test design". A census test pins that: every product-less design in the shipped catalog is a scenario test design. From 555f8815ce7d006fa1a661139727175d0c00fd37 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 16:57:56 +0200 Subject: [PATCH 053/113] Scenarios map: autocannon stays unsold; unfittable gear is never stocked Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index 008f0cd20..18ed0a1be 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -58,6 +58,10 @@ claim measured against a real file. beam item would use the existing pickup VFX but with the standardized pickup animation." Every generated ship and the player's starting ship carry a Tractor Beam. The pickup Behavior and its presentation follow `docs/shield-presentation-contract.md` ("Shield and Pickup Presentation Contract"). + - Autocannon (operator): "Leave the autocannon unsold, we'll need to author a bunch more hulls before all the + gear variety in the game has a home." Self's implementation: generation and stock never offer gear that no + hull's hardpoint can fit. The rule is derived from hardpoint fit, not a hand list. The Autocannon keeps its + product and becomes sold automatically once a hull fits it. The census test is unaffected. - **Weapons:** Autocannon, LRMM72, SRMM72, Tractor Beam. "yep that's a content gap". They get products. - After both, "no product" means exactly "Q3 test design". A census test pins that: every product-less design in the shipped catalog is a scenario test design. From b3e7995cebac63a186bd6672f97e61f97019639d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 18:01:32 +0200 Subject: [PATCH 054/113] Stations: a station reactor, so the heater has power Operator ruling, 2026-09-30: "Yep, we need a station reactor." Zenith's 16-cell Reactor hardpoint had no design to fill it, so generated stations had no power. A transient `AetherDb station-reactor [apply]` authored it from Manhattan, the largest and most industrial reactor, scaled by cells (16/9): Station Reactor (GearData, Reactor hardpoint, full 4x4) mass 500 -> 888.9, price 750,000 -> 1,350,000 (to the nearest 50,000), charge 500..1000 -> 888.9..1777.8; every other value, the efficiency, overload and throttling stats and all stat terms, the roles (core, regulator), temperatures, durability and sound bank are Manhattan's. Product "Station Reactor" by Zhestokost (Manhattan's maker), default role quality. Station generation fills the slot through its ordinary hardpoint pass. Tests: every generated station carries one heater and a reactor; an idle station (a quiet arena, nothing else running) holds its heater's cells above freezing over the second five of ten minutes (unpowered they fell to ~247 K); a station hull with no room for a heater fails generation by name instead of skipping it (Soul's S11 survived before). The station's border cells still sit near 265 K with the heater and reactor running (the reactor core reaches ~433 K); how warm the whole hull should run is left as a tuning question. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 49 +++++++++++++- tools/AetherDb/Program.cs | 70 +++++++++++++++++++- 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 675434aba..1d130d001 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:56b4875145c5dc2552a94318a593d11bbcf35db4fabdd7cbfda71552bcedd834 -size 12698071 +oid sha256:5e3b717b0c4dc5b2b9903ba472710719d27bb9c8d518559e607726e14916499f +size 12698849 diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index fd0356ef9..152efd675 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -404,9 +404,9 @@ public sealed class RunStartTests : IDisposable private static bool IsHeater(EquippedItem item) => item.Behaviors.OfType().Any(t => !t.ThermotoggleData.HighPass) && item.Behaviors.OfType().Any(); - // Every generated station carries exactly one heater. + // Every generated station carries exactly one heater, and a reactor to power it. [Fact] - public void EveryGeneratedStationCarriesOneHeater() + public void EveryGeneratedStationCarriesOneHeaterAndAReactor() { var zone = Arena(null); var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); @@ -414,9 +414,54 @@ public sealed class RunStartTests : IDisposable { var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); Assert.True(station.Equipment.Count(IsHeater) == 1, $"station {i} carries {station.Equipment.Count(IsHeater)} heaters"); + Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"station {i} has no reactor"); } } + private const float Freezing = 273.15f; + + // An idle station, powered by its reactor, holds its heater's cells (where the thermostat reads) above freezing + // over the second five of ten idle minutes. Unpowered, they cool from 280 K to about 247 K in that time. (The + // station's border cells still sit near 265 K; how warm the whole hull should run is a tuning question.) + [Fact] + public void AnIdleStationStaysAboveFreezing() + { + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); + var heater = station.Equipment.Single(IsHeater); + var coldest = float.MaxValue; + for (var tick = 0; tick < 6000; tick++) + { + zone.Update(.1f); + if (tick >= 3000) coldest = min(coldest, heater.Temperature); + } + Assert.True(coldest > Freezing, $"the heater's cells fell to {coldest} K (station now {station.MinTemp}..{station.MaxTemp} K)"); + } + + // A station hull with no room left for a heater fails its generation by name, rather than spawning a station + // that will freeze. The only station hull on offer is one whose interior takes the smallest docking bay and no more. + [Fact] + public void AStationWithNoRoomForAHeaterFailsLoudly() + { + var sold = _cache.GetAll().Select(product => product.Design.Key).ToHashSet(); + var bay = _cache.GetAll().Where(design => sold.Contains(_cache.RefOf(design).Key)) + .OrderBy(design => design.Shape.Coordinates.Length).ThenBy(design => design.Name, StringComparer.Ordinal).First(); + var shape = new Shape(bay.Shape.Width + 2, bay.Shape.Height + 2); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var cramped = new HullData { Name = "Cramped Station", HullType = HullType.Station, Shape = shape, Durability = 100, Mass = 1000, Price = 1 }; + _cache.Upsert(cramped); + _cache.Upsert(new FactionProductData + { + Name = "Cramped Station", Design = new CultRecordRef(_cache.RefOf(cramped).Key), Manufacturer = _cache.RefOf(_protagonist) + }); + foreach (var hull in _cache.GetAll().Where(hull => hull.HullType == HullType.Station && hull != cramped)) + hull.Price = 0; // generation offers no zero-price design, so the cramped hull is the only station + var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); + + var failure = Assert.Throws(() => generator.GenerateStationLoadout()); + Assert.Contains("heater", failure.Message); + } + // Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a // scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of // it, or cargo it carries. These are the unsold designs no scenario places, by name. diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 4635800a6..88762abc5 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -37,8 +37,9 @@ public static class Program case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); case "product-gap": return ProductGap(args.Contains("apply")); + case "station-reactor": return StationReactor(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], product-gap [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], product-gap [apply], station-reactor [apply]"); return 1; } } @@ -1555,4 +1556,71 @@ public static class Program Console.WriteLine($"\nLanded {industrial.Length} deletions, {made.Length} products and the heater's price and durability in Aetheria.cc"); return 0; } + + // Scenarios map, operator ruling 2026-09-30 ("Yep, we need a station reactor"): Zenith's 16-cell Reactor hardpoint + // had no design to fill it, so generated stations had no power and their heater never ran. The station reactor is + // Manhattan, the largest and most industrial reactor, scaled to the slot: a full 4x4, its charge, mass and price + // times 16/9 (its cells over Manhattan's), every other value and stat term Manhattan's own, sold by Manhattan's maker. + private static int StationReactor(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + var cache = db.Cache; + if (cache.GetAll().Any(d => d.Name == "Station Reactor")) throw new InvalidOperationException("Station Reactor already exists."); + var manhattan = cache.GetAll().Single(d => d.Name == "Manhattan"); + var manhattanProduct = cache.GetAll().Single(p => p.Design.Key.Equals(cache.RefOf(manhattan).Key)); + const float scale = 16f / 9f; + + var shape = new Shape(4, 4); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var design = new GearData + { + Name = "Station Reactor", + Description = "A reactor sized for a station's core.", + Hardpoint = HardpointType.Reactor, + Shape = shape, + Mass = manhattan.Mass * scale, + Price = (int) Math.Round(manhattan.Price * scale / 50000) * 50000, + SpecificHeat = manhattan.SpecificHeat, + Conductivity = manhattan.Conductivity, + Durability = manhattan.Durability, + MinimumTemperature = manhattan.MinimumTemperature, + MaximumTemperature = manhattan.MaximumTemperature, + OptimalTemperature = manhattan.OptimalTemperature, + PlateauWidth = manhattan.PlateauWidth, + ThermalResilience = manhattan.ThermalResilience, + SoundBank = manhattan.SoundBank, + Roles = manhattan.Roles.Select(role => new ItemRole { Name = role.Name }).ToList(), + Behaviors = MessagePack.MessagePackSerializer.Deserialize>(MessagePack.MessagePackSerializer.Serialize(manhattan.Behaviors)), + }; + var reactor = design.Behaviors.OfType().Single(); + reactor.Charge.Min *= scale; + reactor.Charge.Max *= scale; + var product = new FactionProductData + { + Name = "Station Reactor", + Description = "", + Manufacturer = manhattanProduct.Manufacturer, + Roles = design.Roles.Select(role => new ProductRole { Role = role.Name }).ToList(), + }; + + Console.WriteLine($"Station Reactor, from Manhattan x {scale:F3}: shape 4x4 (16), mass {manhattan.Mass} -> {design.Mass}, " + + $"price {manhattan.Price} -> {design.Price}, charge {reactor.Charge.Min / scale}..{reactor.Charge.Max / scale} -> {reactor.Charge.Min}..{reactor.Charge.Max}; " + + $"product by {cache.Get(product.Manufacturer).Name}"); + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to land it."); + return 0; + } + + CultRecordRefs.Validate(design); + var key = new CultRecordKey(Guid.NewGuid().ToString("N")); + product.Design = new CultRecordRef(key); + cache.Commit(batch => + { + batch.Upsert(typeof(GearData), design, key); + batch.Upsert(typeof(FactionProductData), product, new CultRecordKey(Guid.NewGuid().ToString("N"))); + }); + Console.WriteLine("\nLanded the Station Reactor and its product in Aetheria.cc"); + return 0; + } } From 5c3cd599ac2180053f3063d6eba08f3ea7fcda5b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 18:01:34 +0200 Subject: [PATCH 055/113] Zone: wait for last tick's belt tasks before clearing the orbit cache Update cleared `_updatedOrbits` and only then waited on the previous tick's asteroid-belt tasks, which read the cache through GetOrbitPosition. A task still running could see the set mid-clear ("concurrent update ... corrupted its state") or recompute an orbit into last tick's position. The wait now comes first. Test (Soul's orbit counter, committed): 3000 back-to-back 0.02 s ticks of a quiet arena, and no orbit stands still against its parent. Clearing first gave 500+ stalls or the corruption exception. Measured against the parent because a moon can genuinely pause in zone space: one such reading (tick 1321, a moon whose motion cancels its planet's) is real motion, not the race. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 3 ++- tests/Aetheria.Shared.Tests/RunStartTests.cs | 24 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index ec8aa304c..b1d7239bd 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -172,10 +172,11 @@ public class Zone public void Update(float deltaTime) { _time += deltaTime; - _updatedOrbits.Clear(); + // Last tick's belt tasks read the orbit cache; they finish before it is cleared. foreach (var t in BeltUpdates) t.Wait(); BeltUpdates.Clear(); + _updatedOrbits.Clear(); foreach (var orbit in Orbits) { orbit.Value.PreviousPosition = orbit.Value.Position; diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 152efd675..b9ec9a7a0 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -462,6 +462,30 @@ public sealed class RunStartTests : IDisposable Assert.Contains("heater", failure.Message); } + // Orbit positions are computed once per tick, before the asteroid belts' tasks start, and those tasks finish + // before the next tick clears the cache. Were the cache cleared under a running belt task, an orbit could be + // served last tick's position. Over 3000 back-to-back ticks no orbit stands still against its parent. (Measured + // against the parent because a moon can genuinely pause in zone space, where its motion cancels its planet's.) + [Fact] + public void NoOrbitStandsStillAgainstItsParent() + { + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + Assert.True(zone.AsteroidBelts.Count > 0, "the arena has an asteroid belt to race against"); + float2 Relative(Orbit orbit, bool previous) => + (previous ? orbit.PreviousPosition : orbit.Position) - + (zone.Orbits.TryGetValue(orbit.Data.Parent.Key, out var parent) ? previous ? parent.PreviousPosition : parent.Position : orbit.Data.FixedPosition); + zone.Update(.02f); + var stalls = new List(); + for (var tick = 0; tick < 3000; tick++) + { + zone.Update(.02f); + stalls.AddRange(zone.Orbits.Values + .Where(orbit => orbit.Period > .01f && orbit.Data.Distance > 0 && lengthsq(Relative(orbit, false) - Relative(orbit, true)) == 0) + .Select(orbit => $"tick {tick}: period {orbit.Period}, distance {orbit.Data.Distance}, at {orbit.Position}")); + } + Assert.True(stalls.Count == 0, $"{stalls.Count} readings of an orbit standing still; first {stalls.FirstOrDefault()}"); + } + // Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a // scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of // it, or cargo it carries. These are the unsold designs no scenario places, by name. From d91ab8daf9b4c843afefb33eb42506b32f0fdf5d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 18:01:35 +0200 Subject: [PATCH 056/113] Generation never offers gear no hull can mount Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold, we'll need to author a bunch more hulls before all the gear variety in the game has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type, laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit` all read it. RandomProducts, which both station stock and NPC loadouts draw through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some hull in the catalog takes. The Autocannon keeps its product and price. Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after a scratch hull whose hardpoint takes it is authored, it is. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ItemData.cs | 6 +++++ .../Scripts/ServerShared/LoadoutGenerator.cs | 16 +++++++++---- tests/Aetheria.Shared.Tests/RunStartTests.cs | 24 +++++++++++++++++++ tools/AetherDb/Program.cs | 5 +--- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 59d93c1ba..e7b9355cc 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -567,6 +567,12 @@ public class HardpointData return $"{Enum.GetName(typeof(HardpointType), Type)} Hardpoint {Rotation.Arrow()}"; } + // The one fit rule for hardpoint gear: its type, laid in at this hardpoint's rotation, filling every cell. + public bool Takes(EquippableItemData design) => + design.HardpointType == Type && + design.Shape.FitsWithin(Shape, Rotation, out _) && + design.Shape.Coordinates.Length == Shape.Coordinates.Length; + [IgnoreMember] public float3 TintColor { diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index e78dc9a4f..15e76e401 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -141,12 +141,14 @@ public class LoadoutGenerator // lacks variety, so it is logged rather than hidden. public (FactionProductData product, T design)[] RandomProducts(int count, float sizeExponent, Predicate filter = null, bool required = false) where T : EquippableItemData { + var hulls = ItemManager.ItemData.GetAll().ToArray(); var candidates = ItemManager.ItemData.GetAll() .Select(product => (product, design: ItemManager.ItemData.Get(product.Design) as T)) .Where(entry => entry.design != null && entry.design.Price > 0 && entry.product.Manufacturer.IsSet() && + HasHome(entry.design, hulls) && (filter?.Invoke(entry.design) ?? true)) .ToArray(); var available = candidates.Where(entry => IsAvailable(entry.product)).ToArray(); @@ -165,6 +167,13 @@ public class LoadoutGenerator count); } + // Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a + // bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station + // stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior. + private static bool HasHome(EquippableItemData design, HullData[] hulls) => + design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull || + hulls.Any(hull => hull.Hardpoints.Any(hardpoint => hardpoint.Takes(design))); + // No galaxy means no availability to filter by: every product is on offer. A fixture generates loadouts that // way, so a test can exercise placement and products without standing up a whole galaxy; no game path does. // Loadouts.Materialize takes availability as a predicate; RunStart, the game's one preset spawner, passes this, so @@ -192,10 +201,7 @@ public class LoadoutGenerator public (FactionProductData product, T design) RandomProduct(HardpointData hardpoint, float sizeExponent, Predicate filter = null, bool required = false) where T : EquippableItemData { - return RandomProduct(sizeExponent, item => item.HardpointType == hardpoint.Type && - (filter?.Invoke(item) ?? true) && - item.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && - item.Shape.Coordinates.Length==hardpoint.Shape.Coordinates.Length, required); + return RandomProduct(sizeExponent, item => hardpoint.Takes(item) && (filter?.Invoke(item) ?? true), required); } private void OutfitEntity(Entity entity) @@ -227,7 +233,7 @@ public class LoadoutGenerator { // If a previously selected product fits, use that one (this is why we must process larger hardpoints first) var entry = previousProducts - .FirstOrDefault(e => e.design.HardpointType == hardpoint.Type && e.design.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _)); + .FirstOrDefault(e => hardpoint.Takes(e.design)); var previousItem = entity.Equipment.FirstOrDefault(item => item.Data == entry.design); if (entry.design == null) entry = RandomProduct(hardpoint, 2); if (entry.design == null) ItemManager.Log($"No compatible item found for entity {Enum.GetName(typeof(HardpointType), hardpoint.Type)} hardpoint!"); diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index b9ec9a7a0..a17a7fe89 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -486,6 +486,30 @@ public sealed class RunStartTests : IDisposable Assert.True(stalls.Count == 0, $"{stalls.Count} readings of an orbit standing still; first {stalls.FirstOrDefault()}"); } + // Gear no hull can mount has no home (operator, 2026-09-30): generation never offers it, so no station stocks it + // and no loadout carries it, though it keeps its product and price. The Autocannon fits no hardpoint of any + // shipped hull. Authoring a hull whose hardpoint takes it makes it one generation offers. + [Fact] + public void GearNoHullCanMountIsOfferedOnlyOnceAHullTakesIt() + { + var autocannon = _cache.GetAll().Single(design => design.Name == "Autocannon"); + Assert.True(autocannon.Price > 0 && _cache.GetAll().Any(p => p.Design.Key.Equals(_cache.RefOf(autocannon).Key)), + "the Autocannon is priced and sold"); + var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); + int Offered() => generator.RandomProducts(2000, 0, design => design.HardpointType == autocannon.HardpointType) + .Count(entry => entry.design == autocannon); + Assert.Equal(0, Offered()); + + var shape = new Shape(autocannon.Shape.Width + 2, autocannon.Shape.Height + 2); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + _cache.Upsert(new HullData + { + Name = "Autocannon Mount", HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 1000, Price = 1, + Hardpoints = { new HardpointData { Type = autocannon.HardpointType, Position = int2(1, 1), Shape = autocannon.Shape } } + }); + Assert.True(Offered() > 0, "a hull that takes the Autocannon gives it a home"); + } + // Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a // scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of // it, or cargo it carries. These are the unsold designs no scenario places, by name. diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 88762abc5..253d0598f 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -468,10 +468,7 @@ public static class Program foreach (var hardpoint in hull.Hardpoints.OrderByDescending(h => h.Shape.Coordinates.Length)) { var cells = hardpoint.Shape.Coordinates.Length; - var matches = gear.Where(g => - g.HardpointType == hardpoint.Type && - g.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && - g.Shape.Coordinates.Length == cells).ToArray(); + var matches = gear.Where(hardpoint.Takes).ToArray(); var soldMatches = matches.Where(m => sold.Contains(db.Cache.RefOf(m).Key)).ToArray(); if (soldMatches.Length == 0) unfillable++; Console.WriteLine($" {hardpoint.Type,-14} {cells,2} cells: {matches.Length} designs match, {soldMatches.Length} sold" + From adad301b7ac16c2eaeffd606efa9ff939cb2e396 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 18:01:36 +0200 Subject: [PATCH 057/113] AetherDb: retire the product-gap and station-reactor one-shots Both landed their content (653ba490, the station reactor commit); git history keeps them. Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 151 +------------------------------------- 1 file changed, 1 insertion(+), 150 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 253d0598f..034bbfd6f 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,10 +36,8 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); - case "product-gap": return ProductGap(args.Contains("apply")); - case "station-reactor": return StationReactor(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], product-gap [apply], station-reactor [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); return 1; } } @@ -1473,151 +1471,4 @@ public static class Program Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); return 0; } - - // Scenarios map, operator rulings 2026-09-30 ("the ten product-less designs"): after this, a design with no - // product is a Q3 scenario test design and nothing else. The five RTS-era industrial placeholders are deleted - // (nothing references them). The four weapons are a content gap and get a product each, from the maker of their - // nearest analogue: same hardpoint and firing behaviour, then nearest size. The Industrial Thermostatic Heater is - // generic gear every generated station carries; it gets a product from the maker of the cockpit whose thermostat - // it shares (Thermotoggle, EnergyDraw, Heat in one group), and the price and durability without which generation - // cannot offer it (RandomProducts skips a zero price) and it can never come online (zero durability). - private static int ProductGap(bool apply) - { - var db = AetherDb.Open(catalogWritable: apply); - var cache = db.Cache; - - T Design(string name) where T : EquippableItemData => - cache.GetAll().SingleOrDefault(d => d.Name == name) ?? throw new InvalidOperationException($"No design named \"{name}\"."); - Faction MakerOf(string productName) => - cache.Get(cache.GetAll().Single(p => p.Name == productName).Manufacturer); - Faction Named(string factionName) => cache.GetAll().Single(f => f.Name == factionName); - bool HasProduct(EquippableItemData design) => - cache.GetAll().Any(p => p.Design.Key.Equals(cache.RefOf(design).Key)); - - var industrial = new[] { "Assembly Line", "Deep Ore Extractor", "Surface Ore Extractor", "Refinery", "Shipyard" } - .Select(Design).ToArray(); - foreach (var design in industrial) - if (HasProduct(design)) throw new InvalidOperationException($"{design.Name} has a product; it is not a placeholder."); - - var heater = Design("Industrial Thermostatic Heater"); - var products = new (EquippableItemData Design, string Name, string Description, Faction Maker, string Analogue)[] - { - (Design("Autocannon"), "Autocannon", "", MakerOf("DeathCluster"), - "DeathCluster: Ballistic, 4 cells, AutoWeapon"), - (Design("LRMM72"), "LRMM72", "", MakerOf("GT 3K"), - "GT 3K: the only other unguided LauncherData"), - (Design("SRMM72"), "SRMM72", "", MakerOf("scorched void policy"), - "scorched void policy: GuidedWeapon launcher, nearest in size and price"), - (Design("Tractor Beam"), "Space Tractor", "It's a tractor. In space.", Named("Ewan Hart Inc"), - "none (no behaviours); Ewan Hart's wishlist Space Tractor, docs/content-batch-one.md section 10"), - (heater, "Industrial Thermostatic Heater", "", MakerOf("Cockpit 2x2"), - "Cockpit 2x2: the same thermostat behaviours"), - }; - foreach (var (design, _, _, _, _) in products) - if (HasProduct(design)) throw new InvalidOperationException($"{design.Name} already has a product."); - const int heaterPrice = 100000; // between Cockpit 2x2 (50,000) and Fire Control Array (150,000) - const float heaterDurability = 100; // Cockpit 2x2's - - Console.WriteLine("Delete (no product, no reference):"); - foreach (var design in industrial) Console.WriteLine($" {design.Name}"); - Console.WriteLine("Products:"); - foreach (var (design, name, _, maker, analogue) in products) - Console.WriteLine($" {design.Name,-32} -> {name,-32} by {maker.Name,-24} ({analogue})"); - Console.WriteLine($"Heater design: Price {heater.Price} -> {heaterPrice}, Durability {heater.Durability} -> {heaterDurability}"); - - if (!apply) - { - Console.WriteLine("\nDry run. Pass \"apply\" to land it."); - return 0; - } - - heater.Price = heaterPrice; - heater.Durability = heaterDurability; - CultRecordRefs.Validate(heater); - var industrialKeys = industrial.Select(design => cache.RefOf(design).Key).ToArray(); - var heaterKey = cache.RefOf(heater).Key; - var made = products.Select(entry => new FactionProductData - { - Name = entry.Name, - Description = entry.Description, - Design = new CultRecordRef(cache.RefOf(entry.Design).Key), - Manufacturer = cache.RefOf(entry.Maker), - Roles = (entry.Design.Roles ?? new List()).Select(role => new ProductRole { Role = role.Name }).ToList() - }).ToArray(); - cache.Commit(batch => - { - foreach (var key in industrialKeys) batch.Remove(key); - batch.Upsert(typeof(GearData), heater, heaterKey); - foreach (var product in made) batch.Upsert(typeof(FactionProductData), product, new CultRecordKey(Guid.NewGuid().ToString("N"))); - }); - Console.WriteLine($"\nLanded {industrial.Length} deletions, {made.Length} products and the heater's price and durability in Aetheria.cc"); - return 0; - } - - // Scenarios map, operator ruling 2026-09-30 ("Yep, we need a station reactor"): Zenith's 16-cell Reactor hardpoint - // had no design to fill it, so generated stations had no power and their heater never ran. The station reactor is - // Manhattan, the largest and most industrial reactor, scaled to the slot: a full 4x4, its charge, mass and price - // times 16/9 (its cells over Manhattan's), every other value and stat term Manhattan's own, sold by Manhattan's maker. - private static int StationReactor(bool apply) - { - var db = AetherDb.Open(catalogWritable: apply); - var cache = db.Cache; - if (cache.GetAll().Any(d => d.Name == "Station Reactor")) throw new InvalidOperationException("Station Reactor already exists."); - var manhattan = cache.GetAll().Single(d => d.Name == "Manhattan"); - var manhattanProduct = cache.GetAll().Single(p => p.Design.Key.Equals(cache.RefOf(manhattan).Key)); - const float scale = 16f / 9f; - - var shape = new Shape(4, 4); - foreach (var cell in shape.AllCoordinates) shape[cell] = true; - var design = new GearData - { - Name = "Station Reactor", - Description = "A reactor sized for a station's core.", - Hardpoint = HardpointType.Reactor, - Shape = shape, - Mass = manhattan.Mass * scale, - Price = (int) Math.Round(manhattan.Price * scale / 50000) * 50000, - SpecificHeat = manhattan.SpecificHeat, - Conductivity = manhattan.Conductivity, - Durability = manhattan.Durability, - MinimumTemperature = manhattan.MinimumTemperature, - MaximumTemperature = manhattan.MaximumTemperature, - OptimalTemperature = manhattan.OptimalTemperature, - PlateauWidth = manhattan.PlateauWidth, - ThermalResilience = manhattan.ThermalResilience, - SoundBank = manhattan.SoundBank, - Roles = manhattan.Roles.Select(role => new ItemRole { Name = role.Name }).ToList(), - Behaviors = MessagePack.MessagePackSerializer.Deserialize>(MessagePack.MessagePackSerializer.Serialize(manhattan.Behaviors)), - }; - var reactor = design.Behaviors.OfType().Single(); - reactor.Charge.Min *= scale; - reactor.Charge.Max *= scale; - var product = new FactionProductData - { - Name = "Station Reactor", - Description = "", - Manufacturer = manhattanProduct.Manufacturer, - Roles = design.Roles.Select(role => new ProductRole { Role = role.Name }).ToList(), - }; - - Console.WriteLine($"Station Reactor, from Manhattan x {scale:F3}: shape 4x4 (16), mass {manhattan.Mass} -> {design.Mass}, " + - $"price {manhattan.Price} -> {design.Price}, charge {reactor.Charge.Min / scale}..{reactor.Charge.Max / scale} -> {reactor.Charge.Min}..{reactor.Charge.Max}; " + - $"product by {cache.Get(product.Manufacturer).Name}"); - if (!apply) - { - Console.WriteLine("\nDry run. Pass \"apply\" to land it."); - return 0; - } - - CultRecordRefs.Validate(design); - var key = new CultRecordKey(Guid.NewGuid().ToString("N")); - product.Design = new CultRecordRef(key); - cache.Commit(batch => - { - batch.Upsert(typeof(GearData), design, key); - batch.Upsert(typeof(FactionProductData), product, new CultRecordKey(Guid.NewGuid().ToString("N"))); - }); - Console.WriteLine("\nLanded the Station Reactor and its product in Aetheria.cc"); - return 0; - } } From 2c3d4005b64819cf4d8c659827ed2c5ce6bc3105 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 19:02:32 +0200 Subject: [PATCH 058/113] Scenarios map: station reactors per manufacturer (B); tractor/pickup becomes its own campaign Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index 18ed0a1be..10f175fee 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -62,6 +62,17 @@ claim measured against a real file. gear variety in the game has a home." Self's implementation: generation and stock never offer gear that no hull's hardpoint can fit. The rule is derived from hardpoint fit, not a hand list. The Autocannon keeps its product and becomes sold automatically once a hull fits it. The census test is unaffected. + - Station reactors by manufacturer (operator, 2026-09-30): "On station reactors: A is a compensator that breaks + the setting; a station with no access to Zhestokost gear should not have Zhestokost gear, so B." + - Batch 2 (`b3e7995c`) authored a Zhestokost Station Reactor scaled from Manhattan. A faction without + Zhestokost allegiance got no reactor, so its station heater had no power. + - The fix is content, not a generation exception: station reactors are authored for other manufacturers, so + that every faction's stations can power their heater from gear the faction can actually get. + - The heater must be reachable the same way. + - Tractor/pickup (`adad301b`, Hands stopped at the fork): the ruled design is `headless-playground-cut.md` fork + L, which needs loot as simulation bodies. That substrate does not exist, so it becomes its own campaign, typed + in the new session (`F:\Projects\HANDOFF-eureka-typed-2026-09-30.md`). The census rule "price 0 is unsold" + waits for it. - **Weapons:** Autocannon, LRMM72, SRMM72, Tractor Beam. "yep that's a content gap". They get products. - After both, "no product" means exactly "Q3 test design". A census test pins that: every product-less design in the shipped catalog is a scenario test design. From 99adb2fc7300e0e4d8dabacef49741a7170bccf7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 19:35:15 +0200 Subject: [PATCH 059/113] Scenarios map: Soul on batch 2 (do not merge; reactor burnout, inert heater test, two fit rules, null damage curves) Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index 10f175fee..8d36091f7 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -69,6 +69,22 @@ claim measured against a real file. - The fix is content, not a generation exception: station reactors are authored for other manufacturers, so that every faction's stations can power their heater from gear the faction can actually get. - The heater must be reachable the same way. + - **Soul on batch 2 (`89ef80d2..adad301b`, 2026-09-30): do not merge.** + - F1 (high): the station reactor burns out after about 20 idle minutes. By minute 40 all 128 cells are + below freezing, and at 120 minutes the hull reads 155 K. Minutes 0-20 overshoot to 447 K, which makes the + station a sensor beacon. + - F2 (high): the idle test samples minutes 5-10 and does not pay for the heater. The inert-thermostat mutant + S17 survives. + - F3 (medium, operator question): `Entity.ItemFits` (the item fits inside the hardpoint; used for equip, save + load and presets) and `HardpointData.Takes` (the item fills the hardpoint; used by generation and + `HasHome`) are two fit rules. Under `Takes`, about 15 designs players can mount are kept out of station + stock. + - F4 (high): LRMM72 and SRMM72 have no `DamageCurve` but now have products, and 22 of 300 NPCs carry them. + `SampleDps` throws an NRE at `InstantWeapon.cs:64`. Autocannon, pswarm and plight are also missing curves. + - F5: `_time` is advanced before the belt wait. F6: mining races the belt tasks. F7: reuse by cell count is + unpinned. + - Soul did **not** reproduce the Zhestokost gap: every station in 5 seeds and 12 factions got a reactor. The + premise behind the per-manufacturer reactor ruling is being checked (census) before any authoring. - Tractor/pickup (`adad301b`, Hands stopped at the fork): the ruled design is `headless-playground-cut.md` fork L, which needs loot as simulation bodies. That substrate does not exist, so it becomes its own campaign, typed in the new session (`F:\Projects\HANDOFF-eureka-typed-2026-09-30.md`). The census rule "price 0 is unsold" From 197c47c4ff5f278179e60b165592c71e26b8d71a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 19:54:23 +0200 Subject: [PATCH 060/113] Scenarios map: F3 ruled, hardpoint fit is loose (one rule everywhere) Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index 8d36091f7..33c0bc3f7 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -81,6 +81,11 @@ claim measured against a real file. stock. - F4 (high): LRMM72 and SRMM72 have no `DamageCurve` but now have products, and 22 of 300 NPCs carry them. `SampleDps` throws an NRE at `InstantWeapon.cs:64`. Autocannon, pswarm and plight are also missing curves. + - **F3 RULED (operator, 2026-09-30): "Hardpoint fit is loose: nobody's gonna stop you from putting a small + reactor in a large reactor's hardpoint."** One fit rule: an item fits if its shape fits within the + hardpoint. Generation, `HasHome`, stock, equip, save load, presets and `AetherDb hardpoint-fit` all use + it. So the Autocannon now has a home (the Turret) and is sold. The Autocannon ruling's own rule, "never + stock gear no hull can fit", is unchanged. - F5: `_time` is advanced before the belt wait. F6: mining races the belt tasks. F7: reuse by cell count is unpinned. - Soul did **not** reproduce the Zhestokost gap: every station in 5 seeds and 12 factions got a reactor. The From 87fae6e4f02d0e055f0c6a9d334825cbc8fa1851 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 20:46:27 +0200 Subject: [PATCH 061/113] Content: damage curves for the five sold weapons that had none Soul's F4: LRMM72, SRMM72, Autocannon, pswarm and plight had DamageCurve == null, and once they had products NPCs were generated with them and CombatState.SampleDps threw at InstantWeapon.cs:64 / ChargedWeapon.cs:81. A transient `AetherDb damage-curves [apply]` copies each one's curve from its nearest comparable weapon, same class first, then closest range and damage: Autocannon <- DeathCluster (auto-fire Ballistic, 4 cells, range reaching 1000) LRMM72 <- GT 3K (the only other unguided launcher) SRMM72 <- scorched void policy (guided; 750 range and 128 damage inside its 500-1250, 128-512) pswarm <- scorched void policy (guided; identical damage, range, cooldown) plight <- ChargeBlast+- (charged Energy; nearest range) Every curve in the catalog is the same one, keys (0, 1, 0, 0) and (1, 0, -1.7656243, -1.7656243): full damage at minimum range falling to none at maximum. Test: every weapon a product sells has a damage curve. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 13 ++++++ tools/AetherDb/Program.cs | 47 +++++++++++++++++++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 1d130d001..9fdde41a6 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e3b717b0c4dc5b2b9903ba472710719d27bb9c8d518559e607726e14916499f -size 12698849 +oid sha256:0a990c6e8c4b8cc90cd16b5cdb7d598e0ec5279bf0c0f2891d3918879ff4eb7d +size 12699064 diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index a17a7fe89..2f246dbb2 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -510,6 +510,19 @@ public sealed class RunStartTests : IDisposable Assert.True(Offered() > 0, "a hull that takes the Autocannon gives it a home"); } + // A sold weapon can be generated onto an NPC, whose combat state samples its damage at range, so every weapon a + // product sells carries a damage curve. + [Fact] + public void EverySoldWeaponHasADamageCurve() + { + var sold = _cache.GetAll().Select(product => product.Design.Key).ToHashSet(); + var missing = _cache.GetAll() + .Where(design => sold.Contains(_cache.RefOf(design).Key)) + .Where(design => design.Behaviors.OfType().Any(weapon => weapon.DamageCurve?.Keys == null || weapon.DamageCurve.Keys.Length == 0)) + .Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToList(); + Assert.True(missing.Count == 0, $"sold weapons with no damage curve: {string.Join(", ", missing)}"); + } + // Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a // scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of // it, or cargo it carries. These are the unsold designs no scenario places, by name. diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 034bbfd6f..a94782379 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,8 +36,9 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); + case "damage-curves": return DamageCurves(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], damage-curves [apply]"); return 1; } } @@ -1471,4 +1472,48 @@ public static class Program Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); return 0; } + + // Soul's F4 (scenarios batch 3): five sold weapons had no DamageCurve, and the first NPC to sample their damage + // at range threw. Each takes the curve of its nearest comparable weapon, same weapon class first, then closest + // range and damage. Every curve in the catalog was the same one (full damage at minimum range, falling to none + // at maximum), so what is copied is that curve; the source is named so the operator can tune from it. + private static int DamageCurves(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + var cache = db.Cache; + var sources = new (string Weapon, string Source, string Why)[] + { + ("Autocannon", "DeathCluster", "auto-fire, Ballistic, 4 cells; the other auto-fire Ballistic whose range reaches 1000"), + ("LRMM72", "GT 3K", "the only other unguided launcher"), + ("SRMM72", "scorched void policy", "guided launcher; SRMM72's 750 range and 128 damage sit inside its 500-1250 and 128-512"), + ("pswarm", "scorched void policy", "guided launcher with identical damage, range and cooldown"), + ("plight", "ChargeBlast+-", "charged Energy weapon; nearest range (800-1900 against plight's 500-2000)"), + }; + WeaponData WeaponOf(string name) => + cache.GetAll().Single(d => d.Name == name).Behaviors.OfType().Single(); + var changed = new List<(EquippableItemData Design, CultRecordKey Key)>(); + foreach (var (weapon, source, why) in sources) + { + var target = WeaponOf(weapon); + if (target.DamageCurve != null) throw new InvalidOperationException($"{weapon} already has a DamageCurve."); + var curve = WeaponOf(source).DamageCurve; + target.DamageCurve = new BezierCurve { Keys = curve.Keys.ToArray() }; + var design = cache.GetAll().Single(d => d.Name == weapon); + changed.Add((design, cache.RefOf(design).Key)); + Console.WriteLine($"{weapon,-10} <- {source,-22} ({why}): keys {string.Join(" ", curve.Keys.Select(k => $"({k.x}, {k.y}, {k.z}, {k.w})"))}"); + } + + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to land it."); + return 0; + } + foreach (var (design, _) in changed) CultRecordRefs.Validate(design); + cache.Commit(batch => + { + foreach (var (design, key) in changed) batch.Upsert(design.GetType(), design, key); + }); + Console.WriteLine($"\nLanded {changed.Count} DamageCurves in Aetheria.cc"); + return 0; + } } From 072a56f0b4229cc3fa8c15ba085d3a9a41f82e9e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 20:46:28 +0200 Subject: [PATCH 062/113] One loose fit rule for hardpoint gear Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you from putting a small reactor in a large reactor's hardpoint." There is one rule, HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same rule at one offset, and Entity.ItemFits (equipping, save load, presets) now asks it instead of repeating the containment test. Generation's hardpoint picks, HasHome (so station stock and loadouts), product reuse and `AetherDb hardpoint-fit` all read Takes. Generation keeps its own placement preference, named IsFilledBy and not a fit rule: it offers gear that fills the hardpoint when any is on offer and falls back to anything that fits only when none is, so loadouts change only where a hardpoint used to stay empty. RestoredHullsTests pick fillers through IsFilledBy. Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now have a home and are offered. Still homeless: CShot RainbowLite Lazer, ChargeBlast SG, ChargeBlast+-, OK Disperser. Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7 design no hull fits is offered only once a hull that takes it exists; a 5x5 Reactor hardpoint nothing fills still gets a reactor; equipping and the rule agree for every hull, hardpoint and design; the homeless list is stated. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 24 +--- Assets/Scripts/ServerShared/ItemData.cs | 33 +++-- .../Scripts/ServerShared/LoadoutGenerator.cs | 4 + .../RestoredHullsTests.cs | 8 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 136 +++++++++++++++--- 5 files changed, 151 insertions(+), 54 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 162848b9b..4ef347ead 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -814,30 +814,14 @@ public abstract class Entity // If there's no hardpoint there, it won't fit if (hardpoint == null) return false; - // If the hardpoint type doesn't match the item, it won't fit - if (hardpoint.Type != itemData.HardpointType) return false; - - // Items placed in hardpoints are automatically aligned to hardpoint rotation + // The one fit rule, at this placement: the item, aligned to the hardpoint's rotation, lies within its cells + if (!hardpoint.TakesAt(itemData, hullCoord - hardpoint.Position)) return false; item.Rotation = hardpoint.Rotation; - // Inset the shapes of both item and hardpoint - var itemShapeInset = hullData.Shape.Inset(itemData.Shape, hullCoord, item.Rotation); - var hardpointShapeInset = hullData.Shape.Inset(hardpoint.Shape, hardpoint.Position); - - // Check every cell of the hardpoint shape for existing items - foreach(var v in hardpointShapeInset.Coordinates) + // One item per hardpoint: every cell of it is free (and so every cell of the item) + foreach (var v in hullData.Shape.Inset(hardpoint.Shape, hardpoint.Position).Coordinates) if (GearOccupancy[v.x, v.y] != null) return false; - - // Check every cell of the item's shape - foreach (var i in itemShapeInset.Coordinates) - { - // If the hardpoint does not have a matching cell, it wont fit - if (!hardpointShapeInset[i]) return false; - - // If there is any gear already occupying that space, it won't fit - if (GearOccupancy[i.x, i.y] != null) return false; - } } return true; diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index e7b9355cc..896756612 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -204,14 +204,7 @@ public class Shape for (int y = 0; y < other.Height - height + 1; y++) { position = int2(x, y); - var fits = true; - foreach (var v in Coordinates) - { - fits = fits && other[Rotate(v, rotation) + position]; - if (!fits) break; - } - - if (fits) return true; + if (FitsAt(other, rotation, position)) return true; } } @@ -219,6 +212,14 @@ public class Shape return false; } + // Whether this shape, turned to a rotation and placed at a position, lies wholly within another shape's cells + public bool FitsAt(Shape other, ItemRotation rotation, int2 position) + { + foreach (var v in Coordinates) + if (!other[Rotate(v, rotation) + position]) return false; + return true; + } + // Set every cell on the line from a to b to true according to Bresenham's Line Algorithm public void SetLine(float2 a, float2 b) { @@ -567,11 +568,19 @@ public class HardpointData return $"{Enum.GetName(typeof(HardpointType), Type)} Hardpoint {Rotation.Arrow()}"; } - // The one fit rule for hardpoint gear: its type, laid in at this hardpoint's rotation, filling every cell. + // The one fit rule for hardpoint gear (operator, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you + // from putting a small reactor in a large reactor's hardpoint"): gear of this hardpoint's type fits when its shape, + // laid in at the hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same rule at one offset + // from the hardpoint's origin, which is where equipping places it. public bool Takes(EquippableItemData design) => - design.HardpointType == Type && - design.Shape.FitsWithin(Shape, Rotation, out _) && - design.Shape.Coordinates.Length == Shape.Coordinates.Length; + design.HardpointType == Type && design.Shape.FitsWithin(Shape, Rotation, out _); + + public bool TakesAt(EquippableItemData design, int2 offset) => + design.HardpointType == Type && design.Shape.FitsAt(Shape, Rotation, offset); + + // Generation's placement preference, not a fit rule: gear that fits and fills every cell of the hardpoint. + public bool IsFilledBy(EquippableItemData design) => + Takes(design) && design.Shape.Coordinates.Length == Shape.Coordinates.Length; [IgnoreMember] public float3 TintColor diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 15e76e401..4fe94b054 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -201,6 +201,10 @@ public class LoadoutGenerator public (FactionProductData product, T design) RandomProduct(HardpointData hardpoint, float sizeExponent, Predicate filter = null, bool required = false) where T : EquippableItemData { + // Every candidate passes the one fit rule, HardpointData.Takes. Generation prefers gear that fills the hardpoint + // and falls back to anything that fits only when nothing that fills it is on offer. + var filling = RandomProduct(sizeExponent, item => hardpoint.IsFilledBy(item) && (filter?.Invoke(item) ?? true)); + if (filling.design != null) return filling; return RandomProduct(sizeExponent, item => hardpoint.Takes(item) && (filter?.Invoke(item) ?? true), required); } diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index b0a125ddf..6a70f9a43 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -193,7 +193,7 @@ public sealed class RestoredHullsTests foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster)) { var cells = hardpoint.Shape.Coordinates.Length; - var design = thrusterDesigns.FirstOrDefault(d => d.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && d.Shape.Coordinates.Length == cells); + var design = thrusterDesigns.FirstOrDefault(hardpoint.IsFilledBy); Assert.True(design != null, $"{name}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} has no fitting catalog thruster design (needs {cells} cells)."); var gearItem = new EquippableItem { Data = cache.RefOf(design), Durability = design.Durability, Lot = lot++ }; Assert.True(ship.TryEquip(gearItem, hardpoint.Position), @@ -225,8 +225,7 @@ public sealed class RestoredHullsTests var lot = 2; foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster)) { - var cells = hardpoint.Shape.Coordinates.Length; - var design = thrusterDesigns.First(d => d.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && d.Shape.Coordinates.Length == cells); + var design = thrusterDesigns.First(hardpoint.IsFilledBy); var gearItem = new EquippableItem { Data = cache.RefOf(design), Durability = design.Durability, Lot = lot++ }; Assert.True(ship.TryEquip(gearItem, hardpoint.Position)); } @@ -239,8 +238,7 @@ public sealed class RestoredHullsTests var reactorHardpoint = hull.Hardpoints.FirstOrDefault(h => h.Type == HardpointType.Reactor); if (reactorHardpoint != null) { - var reactorDesign = cache.GetAll().First(g => g.Hardpoint == HardpointType.Reactor && - g.Shape.FitsWithin(reactorHardpoint.Shape, reactorHardpoint.Rotation, out _) && g.Shape.Coordinates.Length == reactorHardpoint.Shape.Coordinates.Length); + var reactorDesign = cache.GetAll().First(reactorHardpoint.IsFilledBy); var reactorItem = new EquippableItem { Data = cache.RefOf(reactorDesign), Durability = reactorDesign.Durability, Lot = lot++ }; Assert.True(ship.TryEquip(reactorItem, reactorHardpoint.Position)); } diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 2f246dbb2..e6814f0d2 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -486,28 +486,130 @@ public sealed class RunStartTests : IDisposable Assert.True(stalls.Count == 0, $"{stalls.Count} readings of an orbit standing still; first {stalls.FirstOrDefault()}"); } - // Gear no hull can mount has no home (operator, 2026-09-30): generation never offers it, so no station stocks it - // and no loadout carries it, though it keeps its product and price. The Autocannon fits no hardpoint of any - // shipped hull. Authoring a hull whose hardpoint takes it makes it one generation offers. - [Fact] - public void GearNoHullCanMountIsOfferedOnlyOnceAHullTakesIt() - { - var autocannon = _cache.GetAll().Single(design => design.Name == "Autocannon"); - Assert.True(autocannon.Price > 0 && _cache.GetAll().Any(p => p.Design.Key.Equals(_cache.RefOf(autocannon).Key)), - "the Autocannon is priced and sold"); - var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); - int Offered() => generator.RandomProducts(2000, 0, design => design.HardpointType == autocannon.HardpointType) - .Count(entry => entry.design == autocannon); - Assert.Equal(0, Offered()); + private LoadoutGenerator PreludeGenerator() => + new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); - var shape = new Shape(autocannon.Shape.Width + 2, autocannon.Shape.Height + 2); + private bool Offered(LoadoutGenerator generator, EquippableItemData design) => + generator.RandomProducts(1, 0, candidate => candidate == design).Length > 0; + + private GearData ScratchGear(string name, HardpointType type, int width, int height) + { + var shape = new Shape(width, height); foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var design = new GearData { Name = name, Hardpoint = type, Shape = shape, Durability = 10, Mass = 10, Price = 1000 }; + _cache.Upsert(design); + _cache.Upsert(new FactionProductData + { + Name = name, Design = new CultRecordRef(_cache.RefOf(design).Key), Manufacturer = _cache.RefOf(_protagonist) + }); + return design; + } + + // Hardpoint fit is loose (operator, 2026-09-30: "nobody's gonna stop you from putting a small reactor in a large + // reactor's hardpoint"), and there is one fit rule, HardpointData.Takes. Generation offers every sold hardpoint + // design that fits some hull's hardpoint of its type, and none that fits no hull. + [Fact] + public void GenerationOffersExactlyTheSoldGearSomeHullCanFit() + { + var hulls = _cache.GetAll().ToArray(); + var sold = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).Select(p => p.Design.Key).ToHashSet(); + var generator = PreludeGenerator(); + var wrong = _cache.GetAll() + .Where(design => sold.Contains(_cache.RefOf(design).Key) && design.Price > 0) + .Where(design => design.HardpointType != HardpointType.Tool && design.HardpointType != HardpointType.Hull) + .Where(design => Offered(generator, design) != hulls.Any(hull => hull.Hardpoints.Any(hardpoint => hardpoint.Takes(design)))) + .Select(design => design.Name).ToList(); + Assert.True(wrong.Count == 0, $"offered against the fit rule: {string.Join(", ", wrong)}"); + Assert.True(Offered(generator, _cache.GetAll().Single(d => d.Name == "Autocannon")), + "the Autocannon fits the turret's 8-cell Ballistic hardpoint, so it is offered"); + } + + // Gear no hull can fit is never offered; authoring a hull whose hardpoint takes it makes it offered. + [Fact] + public void GearNoHullCanFitIsOfferedOnlyOnceAHullTakesIt() + { + var huge = ScratchGear("Oversized Cannon", HardpointType.Ballistic, 7, 7); + var generator = PreludeGenerator(); + Assert.False(Offered(generator, huge), "no hull fits a 7x7 Ballistic design"); + + var shape = new Shape(9, 9); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var hardpointShape = new Shape(8, 8); + foreach (var cell in hardpointShape.AllCoordinates) hardpointShape[cell] = true; _cache.Upsert(new HullData { - Name = "Autocannon Mount", HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 1000, Price = 1, - Hardpoints = { new HardpointData { Type = autocannon.HardpointType, Position = int2(1, 1), Shape = autocannon.Shape } } + Name = "Big Mount", HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 1000, Price = 1, + Hardpoints = { new HardpointData { Type = HardpointType.Ballistic, Position = int2(0, 0), Shape = hardpointShape } } }); - Assert.True(Offered() > 0, "a hull that takes the Autocannon gives it a home"); + Assert.True(Offered(generator, huge), "a hull whose hardpoint takes it gives it a home"); + } + + // Where nothing fills a hardpoint, generation puts in something that fits: a hull whose only Reactor hardpoint is + // 5x5, which no reactor fills, still gets a reactor. + [Fact] + public void GenerationFitsWhatItCannotFill() + { + var shape = new Shape(9, 9); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var hardpointShape = new Shape(5, 5); + foreach (var cell in hardpointShape.AllCoordinates) hardpointShape[cell] = true; + var hull = new HullData + { + Name = "Odd Reactor Bay", HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 1000, Price = 1, + Hardpoints = { new HardpointData { Type = HardpointType.Reactor, Position = int2(1, 1), Shape = hardpointShape } } + }; + _cache.Upsert(hull); + _cache.Upsert(new FactionProductData + { + Name = "Odd Reactor Bay", Design = new CultRecordRef(_cache.RefOf(hull).Key), Manufacturer = _cache.RefOf(_protagonist) + }); + Assert.DoesNotContain(_cache.GetAll(), design => hull.Hardpoints[0].IsFilledBy(design)); + + var ship = EntitySerializer.Unpack(_items, null, PreludeGenerator().GenerateShipLoadout(candidate => candidate == hull)); + Assert.True(ship.Equipment.Any(item => item.Behaviors.OfType().Any()), "the 5x5 Reactor hardpoint got a reactor"); + } + + // Equipping and generation read the same rule: for every hull, hardpoint and design of that hardpoint's type, the + // hardpoint takes the design exactly when an empty hull of that kind accepts it somewhere in that hardpoint. + [Fact] + public void EquippingAndGenerationAgreeOnEveryDesignAndHardpoint() + { + var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + var gear = _cache.GetAll().Where(d => d.HardpointType != HardpointType.Tool && d.HardpointType != HardpointType.Hull).ToArray(); + var disagreements = new List(); + foreach (var hull in _cache.GetAll()) + { + var product = products.FirstOrDefault(p => p.Design.Key.Equals(_cache.RefOf(hull).Key)); + if (product == null) continue; + var entity = new Ship(_items, null, (EquippableItem) _items.CreateInstance(product), _items.GameplaySettings.DefaultEntitySettings); + foreach (var hardpoint in hull.Hardpoints) + foreach (var design in gear.Where(d => d.HardpointType == hardpoint.Type)) + { + var item = new EquippableItem { Data = _cache.RefOf(design), Durability = design.Durability }; + var accepted = false; + for (var x = 0; x < hardpoint.Shape.Width && !accepted; x++) + for (var y = 0; y < hardpoint.Shape.Height && !accepted; y++) + accepted = entity.ItemFits(item, hardpoint.Position + int2(x, y)); + if (accepted != hardpoint.Takes(design)) + disagreements.Add($"{hull.Name} {hardpoint.Type} at {hardpoint.Position}, {design.Name}: equip {accepted}, rule {!accepted}"); + } + } + Assert.True(disagreements.Count == 0, string.Join("; ", disagreements.Take(10)) + $" ({disagreements.Count} in all)"); + } + + // The sold hardpoint gear that no hull fits even loosely today, stated so that authoring a hull that takes one + // shows up here as a changed list. + [Fact] + public void TheSoldHardpointGearWithNoHomeToday() + { + var hulls = _cache.GetAll().ToArray(); + var sold = _cache.GetAll().Select(product => product.Design.Key).ToHashSet(); + var homeless = _cache.GetAll() + .Where(design => sold.Contains(_cache.RefOf(design).Key) && design.Price > 0) + .Where(design => design.HardpointType != HardpointType.Tool && design.HardpointType != HardpointType.Hull) + .Where(design => !hulls.Any(hull => hull.Hardpoints.Any(hardpoint => hardpoint.Takes(design)))) + .Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToArray(); + Assert.Equal(new[] { "CShot RainbowLite Lazer", "ChargeBlast SG", "ChargeBlast+-", "OK Disperser" }, homeless); } // A sold weapon can be generated onto an NPC, whose combat state samples its damage at range, so every weapon a From 74d2cf8fc4e992f9a0a5acb019a601b578c093b8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 20:46:30 +0200 Subject: [PATCH 063/113] Power bus: a consumer its thermostat has closed is not billed The bus billed every active IPowerConsumer, but a behaviour group stops at its Thermotoggle: a heater above its target never runs its EnergyDraw, yet its full draw (1000-2500/s) was charged every tick. On an idle station that kept the reactor in permanent deficit, overloading at overload efficiency. The bus now skips a consumer whose group has a closed thermostat ahead of it, the same rule it already applies to inactive items; Thermotoggle.Open is the one statement of when a thermostat is open, and Execute returns it. Tests (Soul's F2; S17 survived before): from a hull at one uniform temperature, one tick below the heater's target raises its cells and one above does not, so the heater's own contribution is measured with no reactor heat in it; and the bus bills the heater's draw below its target and not above. This does not keep an idle station warm (Soul's F1, reported separately): the reactor's throttled idle heat alone still drives it past its plateau. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/Thermotoggle.cs | 8 ++-- Assets/Scripts/ServerShared/PowerBus.cs | 14 +++++- tests/Aetheria.Shared.Tests/RunStartTests.cs | 48 +++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Thermotoggle.cs b/Assets/Scripts/ServerShared/Behaviors/Thermotoggle.cs index 05a354d8b..f2ed29ff0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thermotoggle.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thermotoggle.cs @@ -47,8 +47,8 @@ public class Thermotoggle : Behavior TargetTemperature = data.TargetTemperature; } - public override bool Execute(float dt) - { - return Temperature < TargetTemperature ^ _data.HighPass; - } + // Whether the rest of this behaviour's group runs this tick: below the target, or above it for a high-pass one. + public bool Open => Temperature < TargetTemperature ^ _data.HighPass; + + public override bool Execute(float dt) => Open; } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/PowerBus.cs b/Assets/Scripts/ServerShared/PowerBus.cs index 488ea07d2..d0c53b0df 100644 --- a/Assets/Scripts/ServerShared/PowerBus.cs +++ b/Assets/Scripts/ServerShared/PowerBus.cs @@ -92,6 +92,16 @@ public class PowerBus public Draw(EquippedItem item, int tier, float request) { Item = item; Tier = tier; Request = request; } } + private static bool ClosedByThermostat(EquippedItem item, Behavior consumer) + { + foreach (var behavior in item.BehaviorGroups[consumer.Data.Group].Behaviors) + { + if (behavior == consumer) return false; + if (behavior is Thermotoggle { Open: false }) return true; + } + return false; + } + public void Step(float dt) { var reactors = new List(); @@ -119,7 +129,9 @@ public class PowerBus // The other direction of the same rule: a consumer switched off or shut down (Active.Value false) // never runs its Execute, so it must never be billed either -- otherwise a disabled item's demand // starves every live consumer and drains the capacitors for energy nobody spends. - if (behavior is IPowerConsumer consumer && item.Active.Value) + // And a consumer whose group a thermostat has closed ahead of it does not run this tick either, so it is + // not billed: a heater above its target draws nothing. + if (behavior is IPowerConsumer consumer && item.Active.Value && !ClosedByThermostat(item, behavior)) { // Cut 5 (docs/stats-and-power-cut.md §1.3): the item's own stored choice wins once it has one; // PowerTiers.Unassigned only survives past EquippedItem's constructor for a consumer added to diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index e6814f0d2..31ea78fc1 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -438,6 +438,54 @@ public sealed class RunStartTests : IDisposable Assert.True(coldest > Freezing, $"the heater's cells fell to {coldest} K (station now {station.MinTemp}..{station.MaxTemp} K)"); } + // The heater itself warms the station: from a hull at one uniform temperature (so nothing conducts and interior + // cells radiate nothing), one tick below the heater's target raises its cells, and one tick above it does not. + [Fact] + public void AStationHeaterHeatsBelowItsTargetAndNotAbove() + { + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); + var heater = station.Equipment.Single(IsHeater); + var target = heater.Behaviors.OfType().Single().TargetTemperature; + for (var tick = 0; tick < 10; tick++) zone.Update(.1f); // the power bus is running + + float Step(float start) + { + foreach (var v in station.HullData.Shape.Coordinates) station.Temperature[v.x, v.y] = start; + zone.Update(.1f); + return heater.Temperature - start; + } + var below = Step(target - 20); + var above = Step(target + 20); + Assert.True(below > 0, $"below its target the heater's cells moved {below} K"); + Assert.True(above <= 0, $"above its target the heater's cells moved {above} K"); + } + + // A consumer runs only when its group's thermostat is open, and is billed only then: a heater above its target + // puts no demand on the power bus, below it the bus bills its draw. + [Fact] + public void AHeaterAboveItsTargetDrawsNoPower() + { + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); + var heater = station.Equipment.Single(IsHeater); + var target = heater.Behaviors.OfType().Single().TargetTemperature; + var request = heater.Behaviors.OfType().Single().PowerRequest(.1f); + Assert.True(request > 0, "the heater asks for power"); + for (var tick = 0; tick < 10; tick++) zone.Update(.1f); // the heater is online + Assert.True(heater.Active.Value, "the heater is active"); + + float Demand(float temperature) + { + foreach (var v in station.HullData.Shape.Coordinates) station.Temperature[v.x, v.y] = temperature; + station.PowerBus.Step(.1f); + return station.PowerBus.TotalDemand; + } + var cold = Demand(target - 20); + var warm = Demand(target + 20); + Assert.True(cold - warm >= request * .99f, $"demand {cold} below the target, {warm} above; the heater asks {request}"); + } + // A station hull with no room left for a heater fails its generation by name, rather than spawning a station // that will freeze. The only station hull on offer is one whose interior takes the smallest docking bay and no more. [Fact] From f8795271582291a5f04d8ebacc4004a9ecc0f2f1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 20:46:31 +0200 Subject: [PATCH 064/113] Zone: every writer of what the belt tasks read settles them first Soul's F5 and F6. The asteroid-belt tasks read the clock, the orbits and each belt's damage and respawn state. Update advanced the clock before waiting on last tick's tasks (Soul measured 242k transforms placed for the next tick in 576 of 3000 ticks), MineAsteroid wrote damage and respawn state under a running task, CreateOrbit and AddOrbit wrote the orbits, and a task that threw stayed in the list, so Wait rethrew it on every tick after. SettleBelts waits for the tasks and clears the list even when one failed; Update settles before it advances the clock, and MineAsteroid, CreateOrbit and AddOrbit settle before they write. Tests: over 3000 ticks every asteroid of a belt is placed for the time of the tick that started its task; mining every asteroid of a belt, chipping and breaking each four times a tick for 1000 ticks, never races the task; a task that fails surfaces once and later ticks run clean. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 23 +++++-- tests/Aetheria.Shared.Tests/RunStartTests.cs | 72 ++++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index b1d7239bd..0af842695 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -166,16 +166,29 @@ public class Zone public void AddOrbit(OrbitData orbit) { + SettleBelts(); Orbits.Add(_itemManager.ItemData.RefOf(orbit).Key, new Orbit(Settings, orbit)); } + // The asteroid belts' tasks read the clock, the orbits and each belt's damage and respawn state. Everything that + // writes any of those settles the running tasks first. The list is cleared even when a task failed, so a failure + // surfaces once, here, instead of on every later tick. + private void SettleBelts() + { + try + { + Task.WaitAll(BeltUpdates.ToArray()); + } + finally + { + BeltUpdates.Clear(); + } + } + public void Update(float deltaTime) { + SettleBelts(); _time += deltaTime; - // Last tick's belt tasks read the orbit cache; they finish before it is cleared. - foreach (var t in BeltUpdates) - t.Wait(); - BeltUpdates.Clear(); _updatedOrbits.Clear(); foreach (var orbit in Orbits) { @@ -308,6 +321,7 @@ public class Zone var currentPhase = frac(_time / period); var storedPhase = (float) frac(phase - currentPhase); + SettleBelts(); var orbit = new OrbitData { Distance = distance, @@ -320,6 +334,7 @@ public class Zone public void MineAsteroid(Entity miner, CultRecordKey asteroidBelt, int asteroid, float damage, float efficiency, float penetration) { + SettleBelts(); var beltData = Planets[asteroidBelt] as AsteroidBeltData; var belt = AsteroidBelts[asteroidBelt]; //var asteroidTransform = belt.Transforms[asteroid]; diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 31ea78fc1..de0dc9edc 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -534,6 +534,78 @@ public sealed class RunStartTests : IDisposable Assert.True(stalls.Count == 0, $"{stalls.Count} readings of an orbit standing still; first {stalls.FirstOrDefault()}"); } + // A belt's asteroids are laid out for one time: the time of the tick that started the belt's task. Were the + // clock advanced before that task finished, some asteroids would be placed for the next tick and some for this. + [Fact] + public void EveryAsteroidInABeltIsPlacedForTheSameTime() + { + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var (key, belt) = zone.AsteroidBelts.First(); + var asteroids = ((AsteroidBeltData) zone.Planets[key]).Asteroids; + const float dt = .02f; + float2 At(Asteroid asteroid, float time, float2 center) => + OrbitData.Evaluate((float) frac((double) time / zone.Settings.OrbitPeriod.Evaluate(asteroid.Distance) + asteroid.Phase)) * asteroid.Distance + center; + var torn = 0; + zone.Update(dt); + for (var tick = 0; tick < 3000; tick++) + { + var started = zone.Time; // the time the running task was started for + zone.Update(dt); // settles that task and publishes its layout + for (var i = 0; i < asteroids.Length; i++) + { + var placed = belt.Transforms[i].xy; + var here = At(asteroids[i], started, belt.OrbitPosition); + var next = At(asteroids[i], started + dt, belt.OrbitPosition); + if (lengthsq(next - here) < 1e-6f) continue; // too slow to tell the two times apart + if (lengthsq(placed - here) > lengthsq(placed - next)) torn++; + } + } + Assert.True(torn == 0, $"{torn} asteroid placements were for the next tick"); + } + + // Mining writes a belt's damage and respawn state, which the belt's task reads, so it waits for the task first. + // This drives mining hard while the belt updates and fails on any exception the race throws; the race itself is + // rare enough that removing the wait did not fail it in two runs, so it guards, but does not prove, the rule. + [Fact] + public void MiningWhileTheBeltUpdatesNeverRacesIt() + { + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var (key, _) = zone.AsteroidBelts.First(); + var data = (AsteroidBeltData) zone.Planets[key]; + var count = data.Asteroids.Length; + var miner = zone.Entities.First(); + for (var tick = 0; tick < 1000; tick++) + { + zone.Update(.02f); + for (var pass = 0; pass < 4; pass++) + { + // Chip every asteroid (the belt's damage table fills), then break every one (it empties again). + if (data.Resources.Count > 0) + for (var i = 0; i < count; i++) zone.MineAsteroid(miner, key, (i + tick) % count, 1e-3f, 0, 1); + for (var i = 0; i < count; i++) zone.MineAsteroid(miner, key, (i + tick) % count, 1e6f, 0, 1); + } + } + } + + // A belt task that fails surfaces once, on the next tick, and not on every tick after it. + [Fact] + public void AFailedBeltTaskSurfacesOnce() + { + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var (key, _) = zone.AsteroidBelts.First(); + var data = (AsteroidBeltData) zone.Planets[key]; + var asteroids = data.Asteroids; + zone.Update(.02f); + var failures = 0; + data.Asteroids = asteroids.Append(asteroids[0]).ToArray(); // one asteroid more than the belt has room for + zone.Update(.02f); // starts a task that will fail + try { zone.Update(.02f); } catch (AggregateException) { failures++; } // settles it, starts another + data.Asteroids = asteroids; + try { zone.Update(.02f); } catch (AggregateException) { failures++; } // settles that one + Assert.True(failures >= 1, "the failed task surfaced"); + for (var tick = 0; tick < 10; tick++) zone.Update(.02f); + } + private LoadoutGenerator PreludeGenerator() => new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); From c4af12728095e6e1370491d47aba2f937dc361e2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 20:46:32 +0200 Subject: [PATCH 065/113] Stations carry only gear their faction can reach Operator ruling B, 2026-09-30: "a station with no access to Zhestokost gear should not have Zhestokost gear." Test: in a main-sector galaxy (not the prelude, which offers every product), every faction's generated stations carry a heater and a reactor, and nothing, equipped or stocked, whose manufacturer is outside the galaxy or the faction's allegiance. With today's catalog every faction's allegiance names every faction (weights only bias preference) except Rossum & Douglas, which leaves out itself; the test killed a reach that ignores allegiance through an R&D station carrying R&D reactors. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunStartTests.cs | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index de0dc9edc..5fd3636ea 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -420,6 +420,49 @@ public sealed class RunStartTests : IDisposable private const float Freezing = 273.15f; + // A main-sector galaxy (not the prelude, which offers every product): each station's gear must come from + // manufacturers its faction can reach, the galaxy's factions its allegiance names. + private Galaxy MainGalaxy() + { + var authored = AuthoredSettings.Load(FindRepoRoot()); + return new Galaxy(authored.Read("SectorGenerationSettings"), + authored.Read("SectorBackgroundSettings"), + authored.Read("NameGeneratorSettings"), _cache, _ => { }, null, GalaxySeed); + } + + // Every item a station carries, equipped or stocked, with the faction that made it. + private IEnumerable<(string item, Faction maker)> Made(Entity station) => + station.Equipment.Select(item => item.EquippableItem).Cast() + .Concat(station.CargoBays.SelectMany(bay => bay.Cargo.Keys).OfType()) + .Select(item => (_items.GetData(item).Name, _items.Brand(item).Maker)); + + private bool Reaches(Galaxy galaxy, Faction faction, Faction maker) => + maker != null && galaxy.ContainsFaction(_cache.RefOf(maker)) && + faction.Allegiance.Keys.Any(key => key.Key.Equals(_cache.RefOf(maker).Key)); + + // Every faction's stations, in a main-sector galaxy, carry a heater and a reactor, and nothing, equipped or + // stocked, from a manufacturer that faction cannot reach. + [Fact] + public void EveryFactionsStationsCarryOnlyGearItCanReachIncludingAHeaterAndAReactor() + { + var galaxy = MainGalaxy(); + Assert.False(galaxy.IsPrelude); + var zone = Arena(null); + foreach (var faction in galaxy.Factions) + { + var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, faction, .5f); + for (var i = 0; i < 3; i++) + { + var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + Assert.True(station.Equipment.Count(IsHeater) == 1, $"{faction.Name} station {i}: no heater"); + Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"{faction.Name} station {i}: no reactor"); + var unreachable = Made(station).Where(made => !Reaches(galaxy, faction, made.maker)).ToList(); + Assert.True(unreachable.Count == 0, + $"{faction.Name} station {i} carries {string.Join(", ", unreachable.Select(u => u.item + " by " + (u.maker?.Name ?? "nobody")))}"); + } + } + } + // An idle station, powered by its reactor, holds its heater's cells (where the thermostat reads) above freezing // over the second five of ten idle minutes. Unpowered, they cool from 280 K to about 247 K in that time. (The // station's border cells still sit near 265 K; how warm the whole hull should run is a tuning question.) From 2125f2b18198cb509b10c8098bda4477d98febe4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 20:46:35 +0200 Subject: [PATCH 066/113] AetherDb: retire the damage-curves one-shot It landed its content; git history keeps it. Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 47 +-------------------------------------- 1 file changed, 1 insertion(+), 46 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index a94782379..034bbfd6f 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,9 +36,8 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); - case "damage-curves": return DamageCurves(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], damage-curves [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); return 1; } } @@ -1472,48 +1471,4 @@ public static class Program Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); return 0; } - - // Soul's F4 (scenarios batch 3): five sold weapons had no DamageCurve, and the first NPC to sample their damage - // at range threw. Each takes the curve of its nearest comparable weapon, same weapon class first, then closest - // range and damage. Every curve in the catalog was the same one (full damage at minimum range, falling to none - // at maximum), so what is copied is that curve; the source is named so the operator can tune from it. - private static int DamageCurves(bool apply) - { - var db = AetherDb.Open(catalogWritable: apply); - var cache = db.Cache; - var sources = new (string Weapon, string Source, string Why)[] - { - ("Autocannon", "DeathCluster", "auto-fire, Ballistic, 4 cells; the other auto-fire Ballistic whose range reaches 1000"), - ("LRMM72", "GT 3K", "the only other unguided launcher"), - ("SRMM72", "scorched void policy", "guided launcher; SRMM72's 750 range and 128 damage sit inside its 500-1250 and 128-512"), - ("pswarm", "scorched void policy", "guided launcher with identical damage, range and cooldown"), - ("plight", "ChargeBlast+-", "charged Energy weapon; nearest range (800-1900 against plight's 500-2000)"), - }; - WeaponData WeaponOf(string name) => - cache.GetAll().Single(d => d.Name == name).Behaviors.OfType().Single(); - var changed = new List<(EquippableItemData Design, CultRecordKey Key)>(); - foreach (var (weapon, source, why) in sources) - { - var target = WeaponOf(weapon); - if (target.DamageCurve != null) throw new InvalidOperationException($"{weapon} already has a DamageCurve."); - var curve = WeaponOf(source).DamageCurve; - target.DamageCurve = new BezierCurve { Keys = curve.Keys.ToArray() }; - var design = cache.GetAll().Single(d => d.Name == weapon); - changed.Add((design, cache.RefOf(design).Key)); - Console.WriteLine($"{weapon,-10} <- {source,-22} ({why}): keys {string.Join(" ", curve.Keys.Select(k => $"({k.x}, {k.y}, {k.z}, {k.w})"))}"); - } - - if (!apply) - { - Console.WriteLine("\nDry run. Pass \"apply\" to land it."); - return 0; - } - foreach (var (design, _) in changed) CultRecordRefs.Validate(design); - cache.Commit(batch => - { - foreach (var (design, key) in changed) batch.Upsert(design.GetType(), design, key); - }); - Console.WriteLine($"\nLanded {changed.Count} DamageCurves in Aetheria.cc"); - return 0; - } } From d19b7251ac4b54d5a13533faf91d9adfa0b7b0f1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:33:52 +0200 Subject: [PATCH 067/113] Scenarios map: stations get radiators; per-manufacturer reactors kept; self-allegiance question Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index 33c0bc3f7..aff236b17 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -86,6 +86,14 @@ claim measured against a real file. hardpoint. Generation, `HasHome`, stock, equip, save load, presets and `AetherDb hardpoint-fit` all use it. So the Autocannon now has a home (the Turret) and is sold. The Autocannon ruling's own rule, "never stock gear no hull can fit", is unchanged. + - **Batch 3 rulings (operator, 2026-09-30):** + - Idle station reactor overheating and burnout (F1): **"Stations should have radiators"** (option C). The + station has a heat source and no sink. + - Per-manufacturer station reactors: **"Keep the variety, not every galaxy will have Zhestokost in it"**. + The census showed every main-sector galaxy holds all 12 factions today. The reactors are authored anyway. + - Rossum & Douglas missing from its own allegiance: the operator asked "Does allegiance to oneself even make + sense?". Self's proposal: a faction always reaches its own manufacturer's gear, and allegiance lists only + other factions. This is pending the operator's answer. - F5: `_time` is advanced before the belt wait. F6: mining races the belt tasks. F7: reuse by cell count is unpinned. - Soul did **not** reproduce the Zhestokost gap: every station in 5 seeds and 12 factions got a reactor. The From b3b550dbafbce831079fa206df72c140fae7a132 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:37:20 +0200 Subject: [PATCH 068/113] Scenarios map: self-allegiance ruled (own-faction reach implicit; 11 self-entries removed) Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index aff236b17..ec5641749 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -93,7 +93,8 @@ claim measured against a real file. The census showed every main-sector galaxy holds all 12 factions today. The reactors are authored anyway. - Rossum & Douglas missing from its own allegiance: the operator asked "Does allegiance to oneself even make sense?". Self's proposal: a faction always reaches its own manufacturer's gear, and allegiance lists only - other factions. This is pending the operator's answer. + other factions. **Ruled: "removing the 11 self-entries was my intention".** Own-faction reach is implicit + in the one reach function. - F5: `_time` is advanced before the belt wait. F6: mining races the belt tasks. F7: reuse by cell count is unpinned. - Soul did **not** reproduce the Zhestokost gap: every station in 5 seeds and 12 factions got a reactor. The From b2f74f0565fcddc8d49262e9603963d172b4af07 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 22:20:48 +0200 Subject: [PATCH 069/113] Scenarios map: Soul on batch 3 (flaky test, occupancy question, L-hardpoint crash, respawn timers) Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index ec5641749..f73a8f45e 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -95,6 +95,16 @@ claim measured against a real file. sense?". Self's proposal: a faction always reaches its own manufacturer's gear, and allegiance lists only other factions. **Ruled: "removing the 11 self-entries was my intention".** Own-faction reach is implicit in the one reach function. + - **Soul on batch 3 (`adad301b..2125f2b1`, 2026-09-30): do not merge yet.** + - S1: the new belt-failure test is flaky and failed 5 of 8 runs. + - S2: a second occupancy rule. Entity.cs:820-823 requires every hardpoint cell to be free, while tool gear may + fill a hardpoint's spare cells. So a generated Turret refuses its own gun back (40 of 955). Operator question. + - S3: placement searches fewer offsets than `Takes`, so an L-shaped hardpoint crashes generation. Latent. + - S4: asteroid respawn timers never count down, so mined asteroids never return. Sent to mining. + - S5: a shield charges while unbilled. This is documented design. + - S6: pins are missing for the filling preference, product reuse and the thermostat band. + - Held: F2 (no unbilled running in the catalog, players only warmer), F4 (no nulls; no exceptions in 24k-tick + ambient runs or crowded fights), the census, rotation, and no other fit checker. - F5: `_time` is advanced before the belt wait. F6: mining races the belt tasks. F7: reuse by cell count is unpinned. - Soul did **not** reproduce the Zhestokost gap: every station in 5 seeds and 12 factions got a reactor. The From 8863f05ebbe85cd6287f6747fb6d8e5a2196c413 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 22:22:36 +0200 Subject: [PATCH 070/113] Scenarios map: belt code superseded by mining; merge rule for Zone.cs Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index f73a8f45e..e6b75a8ab 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -100,7 +100,11 @@ claim measured against a real file. - S2: a second occupancy rule. Entity.cs:820-823 requires every hardpoint cell to be free, while tool gear may fill a hardpoint's spare cells. So a generated Turret refuses its own gun back (40 of 955). Operator question. - S3: placement searches fewer offsets than `Takes`, so an L-shaped hardpoint crashes generation. Latent. - - S4: asteroid respawn timers never count down, so mined asteroids never return. Sent to mining. + - S4: asteroid respawn timers never count down, so mined asteroids never return. **Already fixed by mining:** + `codex/mining` deleted the belt task threading (Cut 1, `22a54ccb`), `MineAsteroid` and `RespawnTimers` (Cut 2). + Respawn is `Zone.ChunkWear.BrokenUntil`. **Merge rule:** when scenarios and mining meet, mining's side wins + for belt and chunk code, and only scenarios' `CreateOrbit`/`AddOrbit` changes are re-applied. Scenarios + `f8795271` (`SettleBelts`) is superseded. - S5: a shield charges while unbilled. This is documented design. - S6: pins are missing for the filling preference, product reuse and the thermostat band. - Held: F2 (no unbilled running in the catalog, players only warmer), F4 (no nulls; no exceptions in 24k-tick From ad42ff55548611f79ae4f1e13adf13d114442759 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 22:23:28 +0200 Subject: [PATCH 071/113] Scenarios map: S2 ruled B (leftover hardpoint cells may hold tool gear) Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index e6b75a8ab..59b2a35ef 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -99,6 +99,10 @@ claim measured against a real file. - S1: the new belt-failure test is flaky and failed 5 of 8 runs. - S2: a second occupancy rule. Entity.cs:820-823 requires every hardpoint cell to be free, while tool gear may fill a hardpoint's spare cells. So a generated Turret refuses its own gun back (40 of 955). Operator question. + - **S2 RULED B (operator, 2026-09-30): "B is the design intent, saving slots in a hardpoint can be a valid + tradeoff for crowded ships if you really need to fit an extra tool".** A hardpoint item needs only its own + cells free. A hardpoint's leftover cells may hold general (tool) gear. A hardpoint still holds at most one + hardpoint item; option C, two hardpoint items sharing one hardpoint, was not chosen. - S3: placement searches fewer offsets than `Takes`, so an L-shaped hardpoint crashes generation. Latent. - S4: asteroid respawn timers never count down, so mined asteroids never return. **Already fixed by mining:** `codex/mining` deleted the belt task threading (Cut 1, `22a54ccb`), `MineAsteroid` and `RespawnTimers` (Cut 2). From 09050ae920c1dc92aed12a4f5216dcad7be0adc2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 23:13:27 +0200 Subject: [PATCH 072/113] Tests: drop the flaky failed-belt-task test AFailedBeltTaskSurfacesOnce swapped a belt's asteroids while that belt's task could still be running, so it failed in 5 of 8 runs including unmutated ones (Soul S1). It tests belt-task machinery that codex/mining deletes (Cut 1, 22a54ccb), so it goes rather than being reworked. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunStartTests.cs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 5fd3636ea..b459c9c77 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -630,25 +630,6 @@ public sealed class RunStartTests : IDisposable } } - // A belt task that fails surfaces once, on the next tick, and not on every tick after it. - [Fact] - public void AFailedBeltTaskSurfacesOnce() - { - var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs - var (key, _) = zone.AsteroidBelts.First(); - var data = (AsteroidBeltData) zone.Planets[key]; - var asteroids = data.Asteroids; - zone.Update(.02f); - var failures = 0; - data.Asteroids = asteroids.Append(asteroids[0]).ToArray(); // one asteroid more than the belt has room for - zone.Update(.02f); // starts a task that will fail - try { zone.Update(.02f); } catch (AggregateException) { failures++; } // settles it, starts another - data.Asteroids = asteroids; - try { zone.Update(.02f); } catch (AggregateException) { failures++; } // settles that one - Assert.True(failures >= 1, "the failed task surfaced"); - for (var tick = 0; tick < 10; tick++) zone.Update(.02f); - } - private LoadoutGenerator PreludeGenerator() => new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); From f458206c768df6f951bd55dab879f8bb1c33e271 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 23:13:31 +0200 Subject: [PATCH 073/113] Content: a station reactor for every reactor maker Operator ruling, 2026-09-30: "Keep the variety, not every galaxy will have Zhestokost in it." A transient `AetherDb station-reactors [apply]` gives each manufacturer that makes reactors a design for Zenith's 16-cell Reactor hardpoint, scaled by cells from its largest reactor with every other value, stat term, role, temperature and sound bank its own, and a product by it: Vulcan Station Reactor (AU, from Vulcan 12 cells x1.333) charge 66.7..266.7, mass 666.7, price 1,550,000 Notorious Station Reactor (Lucent, from Notorious 9 x1.778) charge 711.1..2133.3, mass 1333.3, price 1,800,000 MoveOnPro Station Reactor (NiteLife, from MoveOnPro 4 x4) charge 100..500, mass 1200, price 3,600,000 Core Power Station Reactor (R&D, from Core Power 4 x4) charge 400, mass 200, price 1,000,000 products: Vulcan Station, Notorious Station, MoveOnPro Station, Steadfast Station The Manhattan-derived one is renamed "Manhattan Station Reactor" (product "Manhattan Station"). Reach is read through the one rule, LoadoutGenerator.IsAvailable. Test: a faction allied only to AU, Lightsail and NiteLife is offered none of Zhestokost's products and AU's, its stations carry nothing it cannot reach and nothing by Zhestokost, and they are powered. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 54 ++++++++++-- tools/AetherDb/Program.cs | 87 +++++++++++++++++++- 3 files changed, 133 insertions(+), 12 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 9fdde41a6..f247b3fd4 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a990c6e8c4b8cc90cd16b5cdb7d598e0ec5279bf0c0f2891d3918879ff4eb7d -size 12699064 +oid sha256:6ce919b3fbe9149cbbddd1cf0b9e6d93379c406770dfdd60928715ba4f564b6f +size 12702246 diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index b459c9c77..75c490e3b 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -430,18 +430,16 @@ public sealed class RunStartTests : IDisposable authored.Read("NameGeneratorSettings"), _cache, _ => { }, null, GalaxySeed); } - // Every item a station carries, equipped or stocked, with the faction that made it. - private IEnumerable<(string item, Faction maker)> Made(Entity station) => + // Every item a station carries, equipped or stocked, with the product it was made as. + private IEnumerable<(string item, FactionProductData product)> Made(Entity station) => station.Equipment.Select(item => item.EquippableItem).Cast() .Concat(station.CargoBays.SelectMany(bay => bay.Cargo.Keys).OfType()) - .Select(item => (_items.GetData(item).Name, _items.Brand(item).Maker)); + .Select(item => (_items.GetData(item).Name, _items.Brand(item).Product)); - private bool Reaches(Galaxy galaxy, Faction faction, Faction maker) => - maker != null && galaxy.ContainsFaction(_cache.RefOf(maker)) && - faction.Allegiance.Keys.Any(key => key.Key.Equals(_cache.RefOf(maker).Key)); + private string Maker(FactionProductData product) => product == null ? "nobody" : _cache.Get(product.Manufacturer)?.Name ?? "nobody"; // Every faction's stations, in a main-sector galaxy, carry a heater and a reactor, and nothing, equipped or - // stocked, from a manufacturer that faction cannot reach. + // stocked, from a manufacturer that faction cannot reach (LoadoutGenerator.IsAvailable, the one reach rule). [Fact] public void EveryFactionsStationsCarryOnlyGearItCanReachIncludingAHeaterAndAReactor() { @@ -456,13 +454,51 @@ public sealed class RunStartTests : IDisposable var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); Assert.True(station.Equipment.Count(IsHeater) == 1, $"{faction.Name} station {i}: no heater"); Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"{faction.Name} station {i}: no reactor"); - var unreachable = Made(station).Where(made => !Reaches(galaxy, faction, made.maker)).ToList(); + var unreachable = Made(station).Where(made => made.product == null || !generator.IsAvailable(made.product)).ToList(); Assert.True(unreachable.Count == 0, - $"{faction.Name} station {i} carries {string.Join(", ", unreachable.Select(u => u.item + " by " + (u.maker?.Name ?? "nobody")))}"); + $"{faction.Name} station {i} carries {string.Join(", ", unreachable.Select(u => u.item + " by " + Maker(u.product)))}"); } } } + // A faction with no access to Zhestokost (its allegiance names only AU, Lightsail and NiteLife) gets + // stations with no Zhestokost gear, powered by a reactor it can reach, and an idle one of them holds its heater's + // cells above freezing over the second five of ten minutes. + [Fact] + public void AStationWithoutZhestokostAccessIsPoweredAndStaysAboveFreezing() + { + var galaxy = MainGalaxy(); + Faction Named(string shortName) => _cache.GetAll().Single(f => f.ShortName == shortName); + var coop = new Faction { Name = "Test Cooperative", ShortName = "Coop" }; + _cache.Upsert(coop); + foreach (var ally in new[] { Named("AU"), Named("Lightsail"), Named("NiteLife") }) coop.Allegiance[_cache.RefOf(ally)] = 1; + var zhestokost = Named("Zhestokost"); + var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, coop, .5f); + var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + Assert.DoesNotContain(products.Where(p => p.Manufacturer.Key.Equals(_cache.RefOf(zhestokost).Key)), generator.IsAvailable); + Assert.Contains(products.Where(p => p.Manufacturer.Key.Equals(_cache.RefOf(Named("AU")).Key)), generator.IsAvailable); + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + Entity station = null; + for (var i = 0; i < 8; i++) + { + station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"station {i}: no reactor"); + var foreign = Made(station).Where(made => made.product == null || !generator.IsAvailable(made.product) || + made.product.Manufacturer.Key.Equals(_cache.RefOf(zhestokost).Key)).ToList(); + Assert.True(foreign.Count == 0, $"station {i} carries {string.Join(", ", foreign.Select(u => u.item + " by " + Maker(u.product)))}"); + } + + zone.Admit(station, piloted: false); + var heater = station.Equipment.Single(IsHeater); + var coldest = float.MaxValue; + for (var tick = 0; tick < 6000; tick++) + { + zone.Update(.1f); + if (tick >= 3000) coldest = min(coldest, heater.Temperature); + } + Assert.True(coldest > Freezing, $"the heater's cells fell to {coldest} K (station now {station.MinTemp}..{station.MaxTemp} K)"); + } + // An idle station, powered by its reactor, holds its heater's cells (where the thermostat reads) above freezing // over the second five of ten idle minutes. Unpowered, they cool from 280 K to about 247 K in that time. (The // station's border cells still sit near 265 K; how warm the whole hull should run is a tuning question.) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 034bbfd6f..1e0572798 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,8 +36,9 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); + case "station-reactors": return StationReactors(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], station-reactors [apply]"); return 1; } } @@ -1471,4 +1472,88 @@ public static class Program Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); return 0; } + + // Scenarios map, operator ruling 2026-09-30 on station reactors (B): "a station with no access to Zhestokost gear + // should not have Zhestokost gear". Every manufacturer that makes reactors gets its own design for Zenith's 16-cell + // Reactor hardpoint, scaled by cells from its largest reactor as the Manhattan one was: a full 4x4, charge, mass + // and price times 16 over the source's cells, every other value and stat term the source's own, sold by that + // manufacturer. The Manhattan-derived "Station Reactor" is renamed to the same pattern. + private static int StationReactors(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + var cache = db.Cache; + var reactors = cache.GetAll().Where(d => d.Hardpoint == HardpointType.Reactor).ToArray(); + var products = cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + FactionProductData ProductOf(GearData design) => products.Single(p => p.Design.Key.Equals(cache.RefOf(design).Key)); + + var existing = cache.GetAll().Single(d => d.Name == "Station Reactor"); + var existingProduct = ProductOf(existing); + var changes = new List<(Type Type, object Document, CultRecordKey Key)>(); + existing.Name = "Manhattan Station Reactor"; + existingProduct.Name = "Manhattan Station"; + changes.Add((typeof(GearData), existing, cache.RefOf(existing).Key)); + changes.Add((typeof(FactionProductData), existingProduct, cache.RefOf(existingProduct).Key)); + Console.WriteLine("Renamed: Station Reactor -> Manhattan Station Reactor, product Station Reactor -> Manhattan Station"); + + var makers = reactors.Where(r => r.Shape.Coordinates.Length < 16).GroupBy(r => ProductOf(r).Manufacturer.Key) + .Where(g => !reactors.Any(r => r.Shape.Coordinates.Length == 16 && ProductOf(r).Manufacturer.Key.Equals(g.Key))); + foreach (var group in makers.OrderBy(g => cache.Get(g.Key).Name, StringComparer.Ordinal)) + { + var source = group.OrderByDescending(r => r.Shape.Coordinates.Length).ThenByDescending(r => r.Price).First(); + var sourceProduct = ProductOf(source); + var scale = 16f / source.Shape.Coordinates.Length; + var shape = new Shape(4, 4); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var design = new GearData + { + Name = source.Name + " Station Reactor", + Description = "A reactor sized for a station's core.", + Hardpoint = HardpointType.Reactor, + Shape = shape, + Mass = source.Mass * scale, + Price = (int) Math.Round(source.Price * scale / 50000) * 50000, + SpecificHeat = source.SpecificHeat, + Conductivity = source.Conductivity, + Durability = source.Durability, + MinimumTemperature = source.MinimumTemperature, + MaximumTemperature = source.MaximumTemperature, + OptimalTemperature = source.OptimalTemperature, + PlateauWidth = source.PlateauWidth, + ThermalResilience = source.ThermalResilience, + SoundBank = source.SoundBank, + Roles = source.Roles.Select(role => new ItemRole { Name = role.Name }).ToList(), + Behaviors = MessagePack.MessagePackSerializer.Deserialize>(MessagePack.MessagePackSerializer.Serialize(source.Behaviors)), + }; + var reactor = design.Behaviors.OfType().Single(); + reactor.Charge.Min *= scale; + reactor.Charge.Max *= scale; + CultRecordRefs.Validate(design); + var key = new CultRecordKey(Guid.NewGuid().ToString("N")); + var product = new FactionProductData + { + Name = sourceProduct.Name + " Station", + Description = "", + Design = new CultRecordRef(key), + Manufacturer = sourceProduct.Manufacturer, + Roles = design.Roles.Select(role => new ProductRole { Role = role.Name }).ToList(), + }; + changes.Add((typeof(GearData), design, key)); + changes.Add((typeof(FactionProductData), product, new CultRecordKey(Guid.NewGuid().ToString("N")))); + Console.WriteLine($"{design.Name}: from {source.Name} ({source.Shape.Coordinates.Length} cells) x {scale:F3}, mass {source.Mass} -> {design.Mass}, " + + $"price {source.Price} -> {design.Price}, charge {reactor.Charge.Min / scale}..{reactor.Charge.Max / scale} -> {reactor.Charge.Min}..{reactor.Charge.Max}; " + + $"product {product.Name} by {cache.Get(product.Manufacturer).Name}"); + } + + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to land it."); + return 0; + } + cache.Commit(batch => + { + foreach (var (type, document, key) in changes) batch.Upsert(type, document, key); + }); + Console.WriteLine($"\nLanded {changes.Count} records in Aetheria.cc"); + return 0; + } } From d50fd1b9738313578b65de4e18860a27eeb41c97 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 23:13:34 +0200 Subject: [PATCH 074/113] A faction reaches its own gear; allegiance names only other factions Operator ruling, 2026-09-30: "removing the 11 self-entries was my intention". IsAvailable, the one reach rule, now says a faction always reaches its own manufacturer's gear, and a transient `AetherDb allegiance-self [apply]` drops the self-entries from the eleven factions that had one (Rossum & Douglas never did, which is why its stations could not carry its own gear). Tests: in a main-sector galaxy every faction is offered all it makes and no allegiance map names its own faction; and, through a fixture faction outside the galaxy with no self-entry, its own product is on offer to it. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/LoadoutGenerator.cs | 5 ++- GameData/Aetheria.cc | 4 +-- tests/Aetheria.Shared.Tests/RunStartTests.cs | 20 +++++++++++ tools/AetherDb/Program.cs | 34 ++++++++++++++++++- 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 4fe94b054..2fb2a69f9 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -177,9 +177,12 @@ public class LoadoutGenerator // No galaxy means no availability to filter by: every product is on offer. A fixture generates loadouts that // way, so a test can exercise placement and products without standing up a whole galaxy; no game path does. // Loadouts.Materialize takes availability as a predicate; RunStart, the game's one preset spawner, passes this, so - // presets and generation share one availability rule. + // presets and generation share one availability rule. In a galaxy, a faction always reaches its own manufacturer's + // gear, and otherwise gear made by a manufacturer in that galaxy that its allegiance names (operator, 2026-09-30: + // allegiance lists only other factions). public bool IsAvailable(FactionProductData product) => Galaxy == null || Galaxy.IsPrelude || + Faction != null && ItemManager.ItemData.Get(product.Manufacturer) == Faction || Galaxy.ContainsFaction(product.Manufacturer) && (Faction == null || Faction.Allegiance.ContainsKey(product.Manufacturer)); // Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index f247b3fd4..20377e0db 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ce919b3fbe9149cbbddd1cf0b9e6d93379c406770dfdd60928715ba4f564b6f -size 12702246 +oid sha256:70a7b0a9f5a59bf64a0bff80f5d266083910e88cc141745b5082424e3ae30b79 +size 12701773 diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 75c490e3b..d0a888a3d 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -461,6 +461,22 @@ public sealed class RunStartTests : IDisposable } } + // A faction always reaches its own manufacturer's gear: in a main-sector galaxy every product a faction makes is + // on offer to that faction, allegiance or not. And an allegiance map lists only other factions. + [Fact] + public void AFactionReachesItsOwnGearAndItsAllegianceNamesOnlyOthers() + { + var galaxy = MainGalaxy(); + var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + foreach (var faction in galaxy.Factions) + { + Assert.DoesNotContain(faction.Allegiance.Keys, key => key.Key.Equals(_cache.RefOf(faction).Key)); + var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, faction, .5f); + var own = products.Where(p => p.Manufacturer.Key.Equals(_cache.RefOf(faction).Key)).ToList(); + Assert.True(own.All(generator.IsAvailable), $"{faction.Name} cannot reach its own {string.Join(", ", own.Where(p => !generator.IsAvailable(p)).Select(p => p.Name))}"); + } + } + // A faction with no access to Zhestokost (its allegiance names only AU, Lightsail and NiteLife) gets // stations with no Zhestokost gear, powered by a reactor it can reach, and an idle one of them holds its heater's // cells above freezing over the second five of ten minutes. @@ -477,6 +493,10 @@ public sealed class RunStartTests : IDisposable var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); Assert.DoesNotContain(products.Where(p => p.Manufacturer.Key.Equals(_cache.RefOf(zhestokost).Key)), generator.IsAvailable); Assert.Contains(products.Where(p => p.Manufacturer.Key.Equals(_cache.RefOf(Named("AU")).Key)), generator.IsAvailable); + // Its allegiance does not name itself, yet it reaches what it makes. + var own = new FactionProductData { Name = "Cooperative Capacitor", Design = products.First().Design, Manufacturer = _cache.RefOf(coop) }; + _cache.Upsert(own); + Assert.True(generator.IsAvailable(own), "a faction reaches its own manufacturer's gear"); var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs Entity station = null; for (var i = 0; i < 8; i++) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 1e0572798..e23ffe251 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -37,8 +37,9 @@ public static class Program case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); case "station-reactors": return StationReactors(args.Contains("apply")); + case "allegiance-self": return AllegianceSelf(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], station-reactors [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], station-reactors [apply], allegiance-self [apply]"); return 1; } } @@ -1556,4 +1557,35 @@ public static class Program Console.WriteLine($"\nLanded {changes.Count} records in Aetheria.cc"); return 0; } + + // Scenarios map, operator ruling 2026-09-30 ("removing the 11 self-entries was my intention"): a faction always + // reaches its own manufacturer's gear (LoadoutGenerator.IsAvailable), so an allegiance map lists only other + // factions. This drops each faction's entry for itself. + private static int AllegianceSelf(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + var cache = db.Cache; + var changed = new List<(Faction Faction, CultRecordKey Key)>(); + foreach (var faction in cache.GetAll().OrderBy(f => f.Name, StringComparer.Ordinal)) + { + var self = cache.RefOf(faction); + var entry = faction.Allegiance.Keys.FirstOrDefault(k => k.Key.Equals(self.Key)); + if (!entry.IsSet()) continue; + Console.WriteLine($"{faction.Name}: drop its own allegiance entry ({faction.Allegiance[entry]})"); + faction.Allegiance.Remove(entry); + changed.Add((faction, self.Key)); + } + + if (!apply) + { + Console.WriteLine($"\nDry run. Pass \"apply\" to land {changed.Count} changes."); + return 0; + } + cache.Commit(batch => + { + foreach (var (faction, key) in changed) batch.Upsert(typeof(Faction), faction, key); + }); + Console.WriteLine($"\nLanded {changed.Count} changes in Aetheria.cc"); + return 0; + } } From ff2be8e26fd80caa564931b2509cb5b11dc565e4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 23:13:37 +0200 Subject: [PATCH 075/113] One placement rule for hardpoint gear Soul's S3 and the S2 ruling ("B is the design intent, saving slots in a hardpoint can be a valid tradeoff for crowded ships if you really need to fit an extra tool"). Entity.HardpointAt is the one placement rule: at an origin, the item lies within a hardpoint of its type at that hardpoint's rotation (HardpointData.TakesAt), its own cells are free, and that hardpoint holds no other hardpoint item. The origin no longer has to be a hardpoint cell, and TryFindSpace tries every offset inside a hardpoint, the offsets the fit rule tries, so an L-shaped hardpoint takes its L design instead of generation throwing. Leftover hardpoint cells may hold general gear, and a hardpoint still holds one hardpoint item. Tests: an L hardpoint takes its L design by hand and in generation (Soul's P11); a turret's gun goes back beside the tool gear in its leftover cells (P21); a second gun in a roomy hardpoint is refused; a saved entity reloads to the same loadout with its items in reverse order. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 41 ++++--- tests/Aetheria.Shared.Tests/RunStartTests.cs | 107 +++++++++++++++++++ 2 files changed, 132 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 4ef347ead..d0641b695 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -790,9 +790,6 @@ public abstract class Entity // Check whether the given item will fit when its origin is placed at the given coordinate private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord) { - // If the given coordinate isn't even in the ship it obviously won't fit - if (!hullData.Shape[hullCoord]) return false; - // Items without specific hardpoints on the ship can be freely rotated and placed anywhere if (itemData.HardpointType == HardpointType.Tool) { @@ -809,24 +806,34 @@ public abstract class Entity } else { - var hardpoint = Hardpoints[hullCoord.x, hullCoord.y]; - - // If there's no hardpoint there, it won't fit + var hardpoint = HardpointAt(itemData, hullData, hullCoord); if (hardpoint == null) return false; - - // The one fit rule, at this placement: the item, aligned to the hardpoint's rotation, lies within its cells - if (!hardpoint.TakesAt(itemData, hullCoord - hardpoint.Position)) return false; item.Rotation = hardpoint.Rotation; - - // One item per hardpoint: every cell of it is free (and so every cell of the item) - foreach (var v in hullData.Shape.Inset(hardpoint.Shape, hardpoint.Position).Coordinates) - if (GearOccupancy[v.x, v.y] != null) - return false; } return true; } + // The one placement rule for hardpoint gear. At this origin the item lies within a hardpoint of its type, turned to + // that hardpoint's rotation (HardpointData.TakesAt, the fit rule); its own cells are free, since a hardpoint's + // leftover cells may hold general gear (operator, 2026-09-30: "saving slots in a hardpoint can be a valid tradeoff + // for crowded ships"); and that hardpoint holds no other hardpoint item, since it holds one at most. + private HardpointData HardpointAt(EquippableItemData itemData, HullData hullData, int2 hullCoord) + { + foreach (var hardpoint in hullData.Hardpoints) + { + if (!hardpoint.TakesAt(itemData, hullCoord - hardpoint.Position)) continue; + var ownCellsFree = itemData.Shape.Coordinates + .Select(v => hullCoord + itemData.Shape.Rotate(v, hardpoint.Rotation)) + .All(c => GearOccupancy[c.x, c.y] == null); + var holdsAnother = hardpoint.Shape.Coordinates + .Select(v => GearOccupancy[hardpoint.Position.x + v.x, hardpoint.Position.y + v.y]) + .Any(occupant => occupant != null && occupant.Data.HardpointType != HardpointType.Tool); + if (ownCellsFree && !holdsAnother) return hardpoint; + } + return null; + } + // Check whether the given item will fit when its origin is placed at the given coordinate on the hull public bool ItemFits(EquippableItem item, int2 hullCoord) { @@ -867,13 +874,15 @@ public abstract class Entity // Search the ship for an empty hardpoint that matches the type and shape of the item else { + // Every offset within a hardpoint of the item's type, the same offsets the fit rule tries foreach (var hardpoint in hullData.Hardpoints) { if(hardpoint.Type == itemData.HardpointType) { - foreach (var hardpointCoord in hardpoint.Shape.Coordinates) + for (var x = 0; x < hardpoint.Shape.Width; x++) + for (var y = 0; y < hardpoint.Shape.Height; y++) { - var hullCoord2 = hardpoint.Position + hardpointCoord; + var hullCoord2 = hardpoint.Position + int2(x, y); if (ItemFits(itemData, hullData, item, hullCoord2)) { hullCoord = hullCoord2; diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index d0a888a3d..be6580a47 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -825,6 +825,113 @@ public sealed class RunStartTests : IDisposable Assert.True(missing.Count == 0, $"sold weapons with no damage curve: {string.Join(", ", missing)}"); } + // An empty hull of this kind to equip into by hand. + private Entity BareHull(HullData hull) => + new Ship(_items, null, (EquippableItem) _items.CreateInstance(_cache.GetAll().First(p => p.Design.Key.Equals(_cache.RefOf(hull).Key))), + _items.GameplaySettings.DefaultEntitySettings); + + private HullData ScratchHull(string name, int size, params HardpointData[] hardpoints) + { + var shape = new Shape(size, size); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var hull = new HullData { Name = name, HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 1000, Price = 1 }; + hull.Hardpoints.AddRange(hardpoints); + _cache.Upsert(hull); + _cache.Upsert(new FactionProductData + { + Name = name, Design = new CultRecordRef(_cache.RefOf(hull).Key), Manufacturer = _cache.RefOf(_protagonist) + }); + return hull; + } + + private EquippableItem Instance(EquippableItemData design) => + (EquippableItem) _items.CreateInstance(_cache.GetAll().First(p => p.Design.Key.Equals(_cache.RefOf(design).Key))); + + // Placement searches every offset the fit rule does: an L-shaped hardpoint, whose origin cell is empty, takes the L + // design that fills it, by hand and in generation. + [Fact] + public void AnLShapedHardpointTakesItsLDesign() + { + var l = new Shape(2, 2); + l[int2(1, 0)] = true; + l[int2(0, 1)] = true; + l[int2(1, 1)] = true; + var hull = ScratchHull("L Mount", 9, new HardpointData { Type = HardpointType.Ballistic, Position = int2(1, 1), Shape = l }); + var gun = ScratchGear("L Gun", HardpointType.Ballistic, 1, 1); + gun.Shape = l; + Assert.True(hull.Hardpoints[0].IsFilledBy(gun)); + + Assert.True(BareHull(hull).TryEquip(Instance(gun)), "equipping by hand"); + var ship = EntitySerializer.Unpack(_items, null, PreludeGenerator().GenerateShipLoadout(candidate => candidate == hull)); + Assert.True(ship.Equipment.Any(item => item.Data.HardpointType == HardpointType.Ballistic), "generation equips the L hardpoint"); + } + + // Generated entities whose hardpoints share leftover cells with general gear, found by generating until enough. + private List<(Entity entity, HardpointData hardpoint, EquippedItem held)> SharedHardpoints(int wanted) + { + var found = new List<(Entity, HardpointData, EquippedItem)>(); + var generator = PreludeGenerator(); + for (var i = 0; i < 400 && found.Count < wanted; i++) + { + var entity = EntitySerializer.Unpack(_items, null, generator.GenerateTurretLoadout()); + foreach (var hardpoint in entity.HullData.Hardpoints) + { + var occupants = hardpoint.Shape.Coordinates.Select(c => entity.GearOccupancy[hardpoint.Position.x + c.x, hardpoint.Position.y + c.y]) + .Where(o => o != null).Distinct().ToList(); + var held = occupants.FirstOrDefault(o => o.Data.HardpointType == hardpoint.Type); + if (held != null && occupants.Any(o => o.Data.HardpointType == HardpointType.Tool)) found.Add((entity, hardpoint, held)); + } + } + return found; + } + + // A hardpoint's leftover cells may hold general gear (operator, 2026-09-30, "B is the design intent"): a hardpoint + // item needs only its own cells free. So a generated turret whose spare gun-mount cells hold tool gear takes its own + // gun back after the gun is pulled. + [Fact] + public void AHardpointItemGoesBackBesideGearInItsLeftoverCells() + { + var shared = SharedHardpoints(10); + Assert.True(shared.Count > 0, "generation shares hardpoint cells with general gear"); + foreach (var (entity, hardpoint, held) in shared) + { + var item = entity.TryUnequip(held); + Assert.True(item != null && entity.TryEquip(item), $"{entity.HullData.Name}: {held.Data.Name} back into its {hardpoint.Type} hardpoint"); + } + } + + // A hardpoint holds at most one hardpoint item, however much room it has left. + [Fact] + public void AHardpointHoldsOneHardpointItem() + { + var mount = new Shape(8, 8); + foreach (var cell in mount.AllCoordinates) mount[cell] = true; + var hull = ScratchHull("Roomy Mount", 10, new HardpointData { Type = HardpointType.Ballistic, Position = int2(1, 1), Shape = mount }); + var gun = ScratchGear("Small Gun", HardpointType.Ballistic, 1, 1); + var entity = BareHull(hull); + Assert.True(entity.TryEquip(Instance(gun)), "the first gun"); + Assert.False(entity.TryEquip(Instance(gun)), "a second gun in the same hardpoint"); + } + + // Loading a saved entity equips its items one by one; the result does not depend on their order, even where general + // gear sits in a hardpoint's leftover cells. + [Fact] + public void SaveLoadGivesTheSameLoadoutInAnyOrder() + { + var shared = SharedHardpoints(3); + Assert.True(shared.Count > 0, "generation shares hardpoint cells with general gear"); + string Loadout(Entity e) => string.Join(";", e.Equipment.Select(i => $"{i.Data.Name}@{i.Position}").OrderBy(s => s, StringComparer.Ordinal)); + foreach (var entity in shared.Select(s => s.entity).Distinct()) + { + var pack = EntitySerializer.Pack(entity); + var inOrder = Loadout(EntitySerializer.Unpack(_items, null, pack)); + pack.Equipment = pack.Equipment.Reverse().ToArray(); + var reversed = Loadout(EntitySerializer.Unpack(_items, null, pack)); + Assert.Equal(Loadout(entity), inOrder); + Assert.Equal(inOrder, reversed); + } + } + // Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a // scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of // it, or cargo it carries. These are the unsold designs no scenario places, by name. From 2d30403ab2aa9d657e1b5e57ba08eafeef10d1a8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 23:13:38 +0200 Subject: [PATCH 076/113] Tests: pin generation's fill preference, lot reuse and thermostat billing Soul's S6. Generation always gives Zenith's 16-cell Reactor hardpoint a reactor that fills it though smaller ones fit; a generated Djinni's eight thrusters share one lot; across 5 K either side of a heater's target the bus bills it exactly when its thermostat is open; and a thermostat gates only its own behaviour group (a two-group fixture: the closed group's 10 is not billed, the other group's 7 is). Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunStartTests.cs | 86 ++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index be6580a47..7a83b8f89 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -932,6 +932,92 @@ public sealed class RunStartTests : IDisposable } } + // Generation prefers gear that fills a hardpoint: a station's 16-cell Reactor hardpoint always gets a 16-cell + // reactor, though smaller ones fit it. + [Fact] + public void GenerationFillsAHardpointWhenSomethingFillsIt() + { + var zenith = _cache.GetAll().Single(h => h.Name == "Zenith"); + var reactorPoint = zenith.Hardpoints.Single(h => h.Type == HardpointType.Reactor); + Assert.Contains(_cache.GetAll(), design => reactorPoint.Takes(design) && !reactorPoint.IsFilledBy(design)); + var generator = PreludeGenerator(); + for (var i = 0; i < 50; i++) + { + var design = generator.RandomProduct(reactorPoint, 2).design; + Assert.True(reactorPoint.IsFilledBy(design), $"draw {i}: {design.Name} does not fill the hardpoint"); + } + } + + // Matching hardpoints carry matching units: a generated Djinni's eight thruster hardpoints hold one product's lot. + [Fact] + public void MatchingHardpointsShareOneLot() + { + var ship = EntitySerializer.Unpack(_items, null, PreludeGenerator().GenerateShipLoadout(hull => hull.Name == "Djinni")); + var thrusters = ship.Equipment.Where(item => item.Data.HardpointType == HardpointType.Thruster).ToList(); + Assert.Equal(8, thrusters.Count); + Assert.Single(thrusters.Select(item => item.EquippableItem.Lot).Distinct()); + } + + // The bus bills a thermostat-gated consumer on exactly the ticks it runs: across 5 K either side of a heater's + // target, the heater's draw is billed exactly when its thermostat is open. + [Fact] + public void TheBusBillsAHeaterExactlyWhenItsThermostatIsOpen() + { + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); + var heater = station.Equipment.Single(IsHeater); + var thermostat = heater.Behaviors.OfType().Single(); + var request = heater.Behaviors.OfType().Single().PowerRequest(.1f); + for (var tick = 0; tick < 10; tick++) zone.Update(.1f); + + float Demand(float temperature) + { + foreach (var v in station.HullData.Shape.Coordinates) station.Temperature[v.x, v.y] = temperature; + station.PowerBus.Step(.1f); + return station.PowerBus.TotalDemand; + } + var unbilled = Demand(thermostat.TargetTemperature + 20); // well above: closed + var mismatches = new List(); + for (var delta = -5f; delta <= 5f; delta += .25f) + { + var billed = Demand(thermostat.TargetTemperature + delta) - unbilled > request * .5f; + if (billed != thermostat.Open) mismatches.Add($"{delta:+0.00;-0.00} K: open {thermostat.Open}, billed {billed}"); + } + Assert.True(mismatches.Count == 0, string.Join("; ", mismatches)); + } + + // A thermostat gates the rest of its own behaviour group, not the whole item: of two draws on one item, the one + // behind a closed thermostat is not billed and the one in another group is. + [Fact] + public void AThermostatGatesOnlyItsOwnGroup() + { + var gear = new GearData + { + Name = "Two Group Gear", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, Mass = 10, Price = 1000, + MinimumTemperature = 100, MaximumTemperature = 500, OptimalTemperature = 280, PlateauWidth = 200, // online at any hull temperature here + Behaviors = + { + new ThermotoggleData { Group = 0, TargetTemperature = 1 }, // low-pass below 1 K: always closed here + new EnergyDrawData { Group = 0, EnergyDraw = new PerformanceStat { Min = 10, Max = 10 }, PerSecond = true }, + new EnergyDrawData { Group = 1, EnergyDraw = new PerformanceStat { Min = 7, Max = 7 }, PerSecond = true }, + } + }; + _cache.Upsert(gear); + _cache.Upsert(new FactionProductData + { + Name = "Two Group Gear", Design = new CultRecordRef(_cache.RefOf(gear).Key), Manufacturer = _cache.RefOf(_protagonist) + }); + var zone = Arena(new Scenario { Ambient = false }); + var ship = BareHull(Hull("Djinni")); + ship.Zone = zone; + Assert.True(ship.TryEquip(Instance(gear))); + zone.Admit(ship, piloted: false); + for (var tick = 0; tick < 5; tick++) zone.Update(.1f); + Assert.True(ship.Equipment.Single(item => item.Data == gear).Active.Value, "the gear is active"); + ship.PowerBus.Step(1f); + Assert.True(abs(ship.PowerBus.TotalDemand - 7) < .01f, $"billed {ship.PowerBus.TotalDemand} for a closed 10 and an ungated 7"); + } + // Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a // scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of // it, or cargo it carries. These are the unsold designs no scenario places, by name. From aa3baf12e487a36eac100ab681aedc14295e1c39 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 23:13:40 +0200 Subject: [PATCH 077/113] AetherDb: retire the station-reactors and allegiance-self one-shots Both landed their content; git history keeps them. Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 119 +------------------------------------- 1 file changed, 1 insertion(+), 118 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index e23ffe251..034bbfd6f 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,10 +36,8 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); - case "station-reactors": return StationReactors(args.Contains("apply")); - case "allegiance-self": return AllegianceSelf(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], station-reactors [apply], allegiance-self [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); return 1; } } @@ -1473,119 +1471,4 @@ public static class Program Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); return 0; } - - // Scenarios map, operator ruling 2026-09-30 on station reactors (B): "a station with no access to Zhestokost gear - // should not have Zhestokost gear". Every manufacturer that makes reactors gets its own design for Zenith's 16-cell - // Reactor hardpoint, scaled by cells from its largest reactor as the Manhattan one was: a full 4x4, charge, mass - // and price times 16 over the source's cells, every other value and stat term the source's own, sold by that - // manufacturer. The Manhattan-derived "Station Reactor" is renamed to the same pattern. - private static int StationReactors(bool apply) - { - var db = AetherDb.Open(catalogWritable: apply); - var cache = db.Cache; - var reactors = cache.GetAll().Where(d => d.Hardpoint == HardpointType.Reactor).ToArray(); - var products = cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); - FactionProductData ProductOf(GearData design) => products.Single(p => p.Design.Key.Equals(cache.RefOf(design).Key)); - - var existing = cache.GetAll().Single(d => d.Name == "Station Reactor"); - var existingProduct = ProductOf(existing); - var changes = new List<(Type Type, object Document, CultRecordKey Key)>(); - existing.Name = "Manhattan Station Reactor"; - existingProduct.Name = "Manhattan Station"; - changes.Add((typeof(GearData), existing, cache.RefOf(existing).Key)); - changes.Add((typeof(FactionProductData), existingProduct, cache.RefOf(existingProduct).Key)); - Console.WriteLine("Renamed: Station Reactor -> Manhattan Station Reactor, product Station Reactor -> Manhattan Station"); - - var makers = reactors.Where(r => r.Shape.Coordinates.Length < 16).GroupBy(r => ProductOf(r).Manufacturer.Key) - .Where(g => !reactors.Any(r => r.Shape.Coordinates.Length == 16 && ProductOf(r).Manufacturer.Key.Equals(g.Key))); - foreach (var group in makers.OrderBy(g => cache.Get(g.Key).Name, StringComparer.Ordinal)) - { - var source = group.OrderByDescending(r => r.Shape.Coordinates.Length).ThenByDescending(r => r.Price).First(); - var sourceProduct = ProductOf(source); - var scale = 16f / source.Shape.Coordinates.Length; - var shape = new Shape(4, 4); - foreach (var cell in shape.AllCoordinates) shape[cell] = true; - var design = new GearData - { - Name = source.Name + " Station Reactor", - Description = "A reactor sized for a station's core.", - Hardpoint = HardpointType.Reactor, - Shape = shape, - Mass = source.Mass * scale, - Price = (int) Math.Round(source.Price * scale / 50000) * 50000, - SpecificHeat = source.SpecificHeat, - Conductivity = source.Conductivity, - Durability = source.Durability, - MinimumTemperature = source.MinimumTemperature, - MaximumTemperature = source.MaximumTemperature, - OptimalTemperature = source.OptimalTemperature, - PlateauWidth = source.PlateauWidth, - ThermalResilience = source.ThermalResilience, - SoundBank = source.SoundBank, - Roles = source.Roles.Select(role => new ItemRole { Name = role.Name }).ToList(), - Behaviors = MessagePack.MessagePackSerializer.Deserialize>(MessagePack.MessagePackSerializer.Serialize(source.Behaviors)), - }; - var reactor = design.Behaviors.OfType().Single(); - reactor.Charge.Min *= scale; - reactor.Charge.Max *= scale; - CultRecordRefs.Validate(design); - var key = new CultRecordKey(Guid.NewGuid().ToString("N")); - var product = new FactionProductData - { - Name = sourceProduct.Name + " Station", - Description = "", - Design = new CultRecordRef(key), - Manufacturer = sourceProduct.Manufacturer, - Roles = design.Roles.Select(role => new ProductRole { Role = role.Name }).ToList(), - }; - changes.Add((typeof(GearData), design, key)); - changes.Add((typeof(FactionProductData), product, new CultRecordKey(Guid.NewGuid().ToString("N")))); - Console.WriteLine($"{design.Name}: from {source.Name} ({source.Shape.Coordinates.Length} cells) x {scale:F3}, mass {source.Mass} -> {design.Mass}, " + - $"price {source.Price} -> {design.Price}, charge {reactor.Charge.Min / scale}..{reactor.Charge.Max / scale} -> {reactor.Charge.Min}..{reactor.Charge.Max}; " + - $"product {product.Name} by {cache.Get(product.Manufacturer).Name}"); - } - - if (!apply) - { - Console.WriteLine("\nDry run. Pass \"apply\" to land it."); - return 0; - } - cache.Commit(batch => - { - foreach (var (type, document, key) in changes) batch.Upsert(type, document, key); - }); - Console.WriteLine($"\nLanded {changes.Count} records in Aetheria.cc"); - return 0; - } - - // Scenarios map, operator ruling 2026-09-30 ("removing the 11 self-entries was my intention"): a faction always - // reaches its own manufacturer's gear (LoadoutGenerator.IsAvailable), so an allegiance map lists only other - // factions. This drops each faction's entry for itself. - private static int AllegianceSelf(bool apply) - { - var db = AetherDb.Open(catalogWritable: apply); - var cache = db.Cache; - var changed = new List<(Faction Faction, CultRecordKey Key)>(); - foreach (var faction in cache.GetAll().OrderBy(f => f.Name, StringComparer.Ordinal)) - { - var self = cache.RefOf(faction); - var entry = faction.Allegiance.Keys.FirstOrDefault(k => k.Key.Equals(self.Key)); - if (!entry.IsSet()) continue; - Console.WriteLine($"{faction.Name}: drop its own allegiance entry ({faction.Allegiance[entry]})"); - faction.Allegiance.Remove(entry); - changed.Add((faction, self.Key)); - } - - if (!apply) - { - Console.WriteLine($"\nDry run. Pass \"apply\" to land {changed.Count} changes."); - return 0; - } - cache.Commit(batch => - { - foreach (var (faction, key) in changed) batch.Upsert(typeof(Faction), faction, key); - }); - Console.WriteLine($"\nLanded {changed.Count} changes in Aetheria.cc"); - return 0; - } } From 4809b0663f38964c5b6624b9dd6c732697e2e0ef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 23:56:45 +0200 Subject: [PATCH 078/113] Scenarios map: F1 ruled a separate thermal balance campaign; Cut 1 merges with the freeze as a known defect Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index 59b2a35ef..abe6248bf 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -87,6 +87,14 @@ claim measured against a real file. it. So the Autocannon now has a home (the Turret) and is sold. The Autocannon ruling's own rule, "never stock gear no hull can fit", is unchanged. - **Batch 3 rulings (operator, 2026-09-30):** + - **F1 re-ruled after batch 4 (operator, 2026-09-30): "A, separate thermal balance cut".** Radiators did not fix + it. Heat cannot cross the hull to Zenith's edge radiators: the reactor runs at 360-436 K while the radiator + cells sit at their 278 K floor. Every station reactor wears out between minute 20 and minute 100. Ships + show the same imbalance under load: MoveOnPro runs at about 480 K and is thermally shut down for most of a + fight (S8). Thermal balance becomes its own campaign, measured by a steady-state harness across all hulls + at idle and under load. Its levers are reactor idle heat, hull conductivity, reactor tolerance and radiator + placement. Scenarios Cut 1 merges with the station freeze recorded as a known defect, which predates this + work. The 120-minute idle test is held for that campaign. - Idle station reactor overheating and burnout (F1): **"Stations should have radiators"** (option C). The station has a heat source and no sink. - Per-manufacturer station reactors: **"Keep the variety, not every galaxy will have Zhestokost in it"**. From 5dd03cab008365208da9f1a1814994e9f523aa90 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 00:15:09 +0200 Subject: [PATCH 079/113] Scenarios map: Soul on batch 4 (origin-outside-hardpoint blocker); queued under drain Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index abe6248bf..de969a6c0 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -87,6 +87,24 @@ claim measured against a real file. it. So the Autocannon now has a home (the Turret) and is sold. The Autocannon ruling's own rule, "never stock gear no hull can fit", is unchanged. - **Batch 3 rulings (operator, 2026-09-30):** + - **Soul on batch 4 (`2125f2b1..aa3baf12`, 2026-10-01): do not merge yet.** + - **Blocker: origin outside the hardpoint.** The item origin can land outside its hardpoint (`Entity.cs:825`, + `:879-885`). Every other hardpoint lookup reads the cell under the origin, `Hardpoints[item.Position]`: + ArcFor, barrels, thrusters, `ActionGameManager`. For an L-shaped hardpoint that lookup returns nothing, so + Unity throws on the first shot. It is latent: every catalog hardpoint is rectangular today. + Two ways to fix it: + - keep the origin inside the hardpoint; + - look the hardpoint up by the item's cells. + + Probe: `scratchpad/soul5/SoulScen5Probes.cs` P30. + - **Minor:** + - `IsFilledBy` is its own oracle in the test. + - Last-row search and thermostat-order are unpinned. + - A gun can't be dragged within its own hardpoint (`InventoryPanel.cs:389`). + - The new station-reactor products drop their source products' quality spread. + - **Thermal:** the Vulcan Station Reactor burns out within 10 idle minutes. That goes to the thermal + campaign. Core Power ship reactors also shut down from heat in fights. + - **Queued, not dispatched:** the operator ordered a drain on 2026-10-01. - **F1 re-ruled after batch 4 (operator, 2026-09-30): "A, separate thermal balance cut".** Radiators did not fix it. Heat cannot cross the hull to Zenith's edge radiators: the reactor runs at 360-436 K while the radiator cells sit at their 278 K floor. Every station reactor wears out between minute 20 and minute 100. Ships From d00a16480dc65ab2ea34464b43d5817256e075f1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 01:48:47 +0200 Subject: [PATCH 080/113] Scenarios map: mining Cut 3 Soul (hold for one fix batch); merge rule corrected Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index de969a6c0..28b595130 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -133,8 +133,12 @@ claim measured against a real file. - S4: asteroid respawn timers never count down, so mined asteroids never return. **Already fixed by mining:** `codex/mining` deleted the belt task threading (Cut 1, `22a54ccb`), `MineAsteroid` and `RespawnTimers` (Cut 2). Respawn is `Zone.ChunkWear.BrokenUntil`. **Merge rule:** when scenarios and mining meet, mining's side wins - for belt and chunk code, and only scenarios' `CreateOrbit`/`AddOrbit` changes are re-applied. Scenarios - `f8795271` (`SettleBelts`) is superseded. + for belt and chunk code. **Corrected 2026-10-01 by mining Cut 3 Soul (rehearsal `62b7cefb`/`393334c4`, 557/557):** + scenarios' `CreateOrbit`/`AddOrbit` changes are only `SettleBelts()` calls, so drop `SettleBelts` and all + three calls (lines 169/190/324 on scenarios); they merge cleanly but reference the deleted `BeltUpdates`. Delete + scenarios tests `RunStartTests.cs:639` and `:669` (deleted belt API). Conflicts: `CatalogTypes` take both; + `Zone.cs` delete `MineAsteroid`; `FireControlCut124Tests.cs:2609` take scenarios' strict Exact; + `GameData/Aetheria.cc` take scenarios' `70a7b0a9` then rerun `AetherDb field-kinds apply`. - S5: a shield charges while unbilled. This is documented design. - S6: pins are missing for the filling preference, product reuse and the thermostat band. - Held: F2 (no unbilled running in the catalog, players only warmer), F4 (no nulls; no exceptions in 24k-tick @@ -822,3 +826,29 @@ Defaults Self took, which you may overrule: others; `Program.cs` is 1,477 lines). This is a subtraction pass of its own. - `ItemManager`'s clock seed (`ItemManager.cs:20`) makes lot quality vary per scenario launch. It is recorded, not fixed here. + +## Mining Cut 3: Soul (`codex/mining` `39cec96a..55ca6d3d`, 2026-10-01) + +**Verdict: hold for one fix batch.** It is queued, not dispatched, because the operator ordered a drain. + +No correctness bug was found. The suite passes 516/516 with the catalog `b698e224` and the settings `b2e346f5` that the commit pins. The shared `pins/data/Settings.asset` (`347752dd`) is scenarios' file, so any runner that mounts that path by default has been testing mining with the wrong settings. + +Findings: +- **F1 (medium, operator):** picking a rock in a big belt costs one scan of every rock the search circle reaches. `Zone.ChunksNear` (`Zone.cs:333`) and `VisibleChunksInReach` (`Entity.cs:378-385`) run on every reticle, next or previous key press. Measured cost: 63 ms at 30k rocks, 449 ms at 300k and 4.3 s at 3M. The per-tick cost is fine. +- **F2 (medium):** nothing tests that the shipped catalog has a rock kind. Taking either side of the catalog conflict unchanged ships dark belts silently. Add a test for "at least one rock kind with weight above 0". +- **F3:** the merge rule is corrected above. +- **F4 (medium):** nothing tests the ping half of `Sensor.Gain`, because the golden fixture saturates at the cap. Two mutants survive (`Sensor.cs:168`, `:189`). Soul's sweep fixture kills both and gives the same hash (`35DE0E26…`) before extraction, at extraction and at the tip. +- **F5:** `FieldKinds.Ensure`'s explicit save (`ZoneData.cs:162`) is needed on a clean store. Keep it and commit the probe. +- **F6/F7/F8 (low):** the `SetTarget` refusal is tested only on dark rocks (`Entity.cs:345`). The outer skip edge of `ChunksNear` is untested. Rock reach reads `Enabled`, and nothing tests it. +- **Deviation (operator):** launchers count toward rock reach (Q13), but under Q12 they can't mine. So after Cut 4 a player can pick a rock that no weapon can act on. +- **Plausible (operator visual check):** the target indicator uses `AsteroidVerticalOffset`, but the shader also subtracts the nebula surface height. + +Promises that held: +- The `Gain` extraction is bit-identical. +- `SetTarget` is the only writer. +- A dark target is dropped on the tick and on `Activate`. +- Docking, broken rocks and wormholes are handled. +- The catalog has 206 identical records, plus one kind and 18 weapons now Exact. +- Old saves get a kind on first load. + +The fix batch commits Soul's four probes (in the session scratchpad, `soul-mc3-run/probes/`) and the catalog-kind test. From 22e9741984c09bda8f10b490f73c33575744aac4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:45:55 +0200 Subject: [PATCH 081/113] Scenarios map: operator rules mining F1 fixed before merge; launchers excluded from rock reach Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index 28b595130..41c08cece 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -852,3 +852,8 @@ Promises that held: - Old saves get a kind on first load. The fix batch commits Soul's four probes (in the session scratchpad, `soul-mc3-run/probes/`) and the catalog-kind test. + +**Rulings (operator, 2026-10-01): "Fix the belt freeze; launchers shouldn't count toward reach".** +- F1: fix it before the merge. The cost of a key press must not grow with belt size. +- Rock reach counts only weapons that can mine; under Q12 launchers can't. This supersedes Hands' Q13 reading. +- The fix batch was dispatched with both rulings. From 1cc08e20c030257b01198d75cf38cca1b333bf77 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 13:37:50 +0200 Subject: [PATCH 082/113] Scenarios map: mining fix batch landed on codex/mining; belt freeze stopped at structure fork Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index 41c08cece..6e5f76f99 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -857,3 +857,13 @@ The fix batch commits Soul's four probes (in the session scratchpad, `soul-mc3-r - F1: fix it before the merge. The cost of a key press must not grow with belt size. - Rock reach counts only weapons that can mine; under Q12 launchers can't. This supersedes Hands' Q13 reading. - The fix batch was dispatched with both rulings. + +**Mining fix batch, Hands (2026-10-01): `f88d1fc1`, `83d8371e` on `codex/mining`. 523/523; 9 mutants each killed by their own test.** +- Launchers: `Weapon.CanMine` (virtual, true by default; `LockWeapon` sets false) is the one predicate. Reach reads `CanMine && Active`. Cut 4's firing gate (`FireControl.cs:175`) must read the same predicate. +- Soul F2/F4/F5/F6/F7/F8 tests are committed. +- Belt freeze **not built; stopped at a fork.** + - Each rock has its own angular speed (`Distance` is a continuous float), so no band shares a phase. + - At 3M rocks, 633k are in reach and 2,240 are visible. + - Proposed: a derived per-belt index (bands of 256 by distance, sorted by turn, with the query arc widened by rate spread and at most 8 band re-sorts per query); a visibility upper bound that skips dark bands; best-first next/previous and reticle; the rules move from `ActionGameManager` to `Entity`. + - Work in progress (unbuilt) is in the session scratchpad: `hands-mc3fix-index-wip.patch`. +- Question: today's Previous with no target picks the second-farthest rock (off by one). Hands recommends fixing it. From f1dee18409c5f2692f826452ef5f37e348a720d6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 14:04:00 +0200 Subject: [PATCH 083/113] Scenarios map: belt-freeze rulings (general targeting index; fix Previous off-by-one) Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index 6e5f76f99..25102e378 100644 --- a/docs/scenarios-cut.md +++ b/docs/scenarios-cut.md @@ -867,3 +867,10 @@ The fix batch commits Soul's four probes (in the session scratchpad, `soul-mc3-r - Proposed: a derived per-belt index (bands of 256 by distance, sorted by turn, with the query arc widened by rate spread and at most 8 band re-sorts per query); a visibility upper bound that skips dark bands; best-first next/previous and reticle; the rules move from `ActionGameManager` to `Entity`. - Work in progress (unbuilt) is in the session scratchpad: `hands-mc3fix-index-wip.patch`. - Question: today's Previous with no target picks the second-farthest rock (off by one). Hands recommends fixing it. + +**Belt-freeze rulings (operator, 2026-10-01):** +- **Structure:** "Let's go for it. There's a voice in my head screaming that this is too much code and we should just reduce the whole belt to a single entity, but this stuff will be extra important for when we have EW and much more crowded levels. Just don't specialize the indexing too much towards asteroid belts, because we'll want a bunch of subsystems feeding targeting data. Think the spacebound microfauna from the slime mold experiment, pretty sure there's a campaign for that." + - The index is a general targeting index that many sources feed: belts, entities, later EW and microfauna. Belts are one provider, not the shape of the index. + - The slime-mold experiment is `Assets/Shaders/Compute/Slime/Slime.cs`. No campaign doc was found by grep on master docs or by voidbot; ask the operator before assuming one. +- **Previous off-by-one:** "Fix the off-by-one". With no current target, Previous picks the farthest rock. +- **Next step:** an Imagination pass (Opus) maps the general index. Inputs: the provider seam, motion models (orbiting rocks with per-rock rates, and moving entities), the brightness/visibility bound as a per-provider bound, and best-first queries. Hands' work in progress (`hands-mc3fix-index-wip.patch`) is input, not spec. Then Hands on Sonnet, then Soul. From 23976aada77cf02a66e258ad6da62311fd75314f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 14:34:05 +0200 Subject: [PATCH 084/113] Ship mounts: delete EquipmentHardpoints and the plan's Equipment list The array's only reader was the dead SoundBank block in EntityInstance, which looked up a hardpoint transform and did nothing with it. ShipModPlan.Equipment, ShipModShips' assignment, the preview check, the FBX builder's HP.Equipment case and its smoke assertion all fed that one dead reader. An HP.Equipment node is now refused by the builder as an unknown hardpoint kind. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/ShipModPreview.cs | 4 +--- Assets/Scripts/Editor/ShipPrefabAuthoring.cs | 8 +++----- Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs | 4 +--- Assets/Scripts/Gameplay/EntityInstance.cs | 11 ----------- Assets/Scripts/Gameplay/ShipModShips.cs | 1 - Assets/Scripts/ServerShared/ShipModPlan.cs | 3 --- docs/ship-collection-authoring.md | 6 ++---- tests/Aetheria.Shared.Tests/ShipModPlanTests.cs | 1 - 8 files changed, 7 insertions(+), 31 deletions(-) diff --git a/Assets/Scripts/Editor/ShipModPreview.cs b/Assets/Scripts/Editor/ShipModPreview.cs index 3cd6a05d0..44d419d92 100644 --- a/Assets/Scripts/Editor/ShipModPreview.cs +++ b/Assets/Scripts/Editor/ShipModPreview.cs @@ -53,7 +53,7 @@ public static class ShipModPreview var clone = UnityEngine.Object.Instantiate(ship.gameObject).GetComponent(); try { CheckShip(clone, plan, "clone"); } finally { UnityEngine.Object.DestroyImmediate(clone.gameObject); } - Debug.Log($"SHIP_MOD_ASSEMBLY_SMOKE equipment={plan.Equipment.Length} thrusters={plan.Thrusters.Length} " + + Debug.Log($"SHIP_MOD_ASSEMBLY_SMOKE thrusters={plan.Thrusters.Length} " + $"weapons={plan.Weapons.Length} radiators={plan.Radiators.Length}"); } @@ -64,8 +64,6 @@ public static class ShipModPreview Require(ship.HullColliders.Length == 1 && Inside(ship.HullColliders[0]) && ship.HullColliders[0].GetComponent() != null, "one hull collider with a MeshCollider"); Require(Inside(ship.MapIcon) && Inside(ship.Shield) && Inside(ship.TractorBeam), "map icon, shield and tractor inside the ship"); - Require(ship.EquipmentHardpoints.Select(node => node.name).SequenceEqual(plan.Equipment) && - ship.EquipmentHardpoints.All(Inside), "equipment hardpoints named by the plan"); Require(ship.ThrusterHardpoints.Select(node => node.name).SequenceEqual(plan.Thrusters) && ship.ThrusterHardpoints.All(node => Inside(node) && Inside(node.Emitter)), "thruster hardpoints with emitters"); Require(ship.RadiatorHardpoints.Select(node => node.name).SequenceEqual(plan.Radiators) && diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs index 988fe15e7..8a419c6e0 100644 --- a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs +++ b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs @@ -92,7 +92,7 @@ public static class ShipPrefabAuthoring var combatLayer = LayerMask.NameToLayer("Combat"); if (minimapLayer < 0 || combatLayer < 0) throw new InvalidOperationException("Minimap or Combat layer is missing"); - var equipment = new List(); + var hardpoints = 0; var thrusters = new List(); var weapons = new List(); var radiators = new List(); @@ -106,10 +106,9 @@ public static class ShipPrefabAuthoring var parts = name.Split('.'); if (parts.Length != 3 || string.IsNullOrWhiteSpace(parts[2])) throw new InvalidOperationException($"Invalid hardpoint name {name}"); - equipment.Add(node); + hardpoints++; switch (parts[1]) { - case "Equipment": break; case "Thruster": var emitter = DirectChild(node, "Emitter")?.GetComponent(); if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child"); @@ -155,7 +154,7 @@ public static class ShipPrefabAuthoring name != "SHIP.Shield" && name != "SHIP.TractorBeam") throw new InvalidOperationException($"Unknown ship anchor {name}"); } - if (equipment.Count == 0) throw new InvalidOperationException("Ship has no HP.* hardpoints"); + if (hardpoints == 0) throw new InvalidOperationException("Ship has no HP.* hardpoints"); mapIcon.sharedMaterial = mapMaterial; mapIcon.gameObject.layer = minimapLayer; @@ -182,7 +181,6 @@ public static class ShipPrefabAuthoring ship.InvisibleMaterial = invisible; ship.Shield = shield; ship.HullColliders = new[] { hullSurface }; - ship.EquipmentHardpoints = equipment.ToArray(); ship.ThrusterHardpoints = thrusters.ToArray(); ship.WeaponHardpoints = weapons.ToArray(); ship.RadiatorHardpoints = radiators.ToArray(); diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs index adda9ff7d..34f5abf83 100644 --- a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs +++ b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs @@ -23,13 +23,11 @@ public static class ShipPrefabAuthoringSmoke Child(weapon.gameObject, "Muzzle.0"); var radiator = Child(root, "HP.Radiator.Port"); Primitive(radiator.gameObject, "RadiatorMesh"); - Child(root, "HP.Equipment.Reactor"); Child(root, "Pivot.2.-90.90.-80.80.60"); ShipPrefabAuthoring.Configure(root); var ship = root.GetComponent(); - Require(ship != null && ship.EquipmentHardpoints.Length == 4, "equipment references"); - Require(ship.ThrusterHardpoints.Length == 1 && ship.ThrusterHardpoints[0].Emitter != null, "thruster emitter"); + Require(ship != null && ship.ThrusterHardpoints.Length == 1 && ship.ThrusterHardpoints[0].Emitter != null, "thruster emitter"); Require(ship.WeaponHardpoints.Length == 1 && ship.WeaponHardpoints[0].FiringPoint.Length == 2, "weapon muzzles"); Require(ship.WeaponHardpoints[0].FiringPoint[0].name == "Muzzle.0", "numeric muzzle order"); Require(ship.RadiatorHardpoints.Length == 1 && ship.RadiatorHardpoints[0].Mesh != null, "radiator mesh"); diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index f3d977c8c..af80d8c2d 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -20,7 +20,6 @@ public class EntityInstance : MonoBehaviour public ShieldManager Shield; public HullCollider[] HullColliders; - public Transform[] EquipmentHardpoints; public RadiatorHardpoint[] RadiatorHardpoints; public ThrusterHardpoint[] ThrusterHardpoints; public WeaponHardpoint[] WeaponHardpoints; @@ -241,16 +240,6 @@ public class EntityInstance : MonoBehaviour _constantWeaponManagers[data].StopFiring(item); } } - - var hp = Entity.Hardpoints[item.Position.x, item.Position.y]; - if (hp != null && item.Data.SoundBank != 0) - { - var hardpointTransform = EquipmentHardpoints.FirstOrDefault(x => x.name == hp.Transform); - if (hardpointTransform != null) - { - var hardpointGameObject = hardpointTransform.gameObject; - } - } } RadiatorMeshes = new Dictionary(); Barrels = new Dictionary(); diff --git a/Assets/Scripts/Gameplay/ShipModShips.cs b/Assets/Scripts/Gameplay/ShipModShips.cs index 26761ad11..9e06099c9 100644 --- a/Assets/Scripts/Gameplay/ShipModShips.cs +++ b/Assets/Scripts/Gameplay/ShipModShips.cs @@ -149,7 +149,6 @@ public static class ShipModShips instance.InvisibleMaterial = template.Invisible; instance.Shield = shield; instance.HullColliders = new[] { surface }; - instance.EquipmentHardpoints = plan.Equipment.Select(Node).ToArray(); instance.ThrusterHardpoints = thrusters; instance.WeaponHardpoints = weapons; instance.RadiatorHardpoints = radiators; diff --git a/Assets/Scripts/ServerShared/ShipModPlan.cs b/Assets/Scripts/ServerShared/ShipModPlan.cs index 6b696bcaa..787656a3e 100644 --- a/Assets/Scripts/ServerShared/ShipModPlan.cs +++ b/Assets/Scripts/ServerShared/ShipModPlan.cs @@ -10,8 +10,6 @@ public sealed class ShipModPlan public string HullCollider; public string Shield; public string Tractor; - // Every hull hardpoint's mount id, in hardpoint order: the anchor node each one is matched to by name. - public string[] Equipment; // Mount ids whose anchor (role thruster-emitter / radiator-mesh) is the emitter / mesh node itself. public string[] Thrusters; public string[] Radiators; @@ -53,7 +51,6 @@ public sealed class ShipModPlan HullCollider = Only("hull-collider"), Shield = Only("shield"), Tractor = Only("tractor"), - Equipment = hardpoints.Select(hardpoint => hardpoint.Transform).ToArray(), Thrusters = Mounts(HardpointType.Thruster), Radiators = Mounts(HardpointType.Radiator), Weapons = weapons diff --git a/docs/ship-collection-authoring.md b/docs/ship-collection-authoring.md index a92551ccd..198a083dc 100644 --- a/docs/ship-collection-authoring.md +++ b/docs/ship-collection-authoring.md @@ -44,13 +44,11 @@ reserved. IDs after a role prefix must be unique across the model. Other visual Muzzle.1 optional additional barrel HP.Radiator.Port empty; exact name copied to the catalog RadiatorMesh one mesh; temperature emission surface - HP.Equipment.Reactor empty; exact name copied to the catalog Pivot.0.-90.90.-80.80.60 optional empty parent of articulated mounts ``` -`HP.Equipment` covers sensors, reactor, shield, cargo, and other mounts without a special visual component. -`HP.Weapon` covers ballistic, energy, and launcher mounts; the catalog chooses which. Every `HP.*` becomes an -`EquipmentHardpoints` entry. Thruster, weapon, and radiator roles also get their matching components and arrays. +`HP.Weapon` covers ballistic, energy, and launcher mounts; the catalog chooses which. Only thrusters, weapons and +radiators are visible and get a node; internal hardpoints have none. Each role gets its matching component and array. `Muzzle.` children are sorted numerically; their transforms become `FiringPoint`. A weapon mount requires at least one. A `Pivot` name carries `Group.YawMin.YawMax.PitchMin.PitchMax.Speed` as signed integers in degrees and degrees per second. It gets `ArticulationPoint`; child mounts follow its transform. Pivots are diff --git a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs index c98ff3a10..e44c60e83 100644 --- a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs @@ -33,7 +33,6 @@ public sealed class ShipModPlanTests var plan = Plan(Rig()); Assert.Equal(("map", "collider", "shield", "tractor"), (plan.MapIcon, plan.HullCollider, plan.Shield, plan.Tractor)); - Assert.Equal(new[] { "thruster.port", "gun", "fin" }, plan.Equipment); Assert.Equal(new[] { "thruster.port" }, plan.Thrusters); Assert.Equal(new[] { "fin" }, plan.Radiators); var (mount, muzzles) = Assert.Single(plan.Weapons); From 976b7119054f10fcb76f29fe2c1121901918f779 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 14:39:08 +0200 Subject: [PATCH 085/113] Ship mounts: anchors only for visible hardpoints, every role rule in Validate ShipAuthoringStore.Validate now alone decides which hardpoints carry anchors and which role each plays. A thruster, radiator, energy, ballistic or launcher hardpoint needs an anchor of its mount id with role thruster-emitter, radiator-mesh or the new weapon-mount; every other hardpoint is internal and no anchor may take its id. A mount-role anchor must be a hardpoint's mount, a muzzle must hang off a weapon mount, and each weapon needs a muzzle; these checks move out of ShipModPlan.Build, which now only maps a validated pair to slots and throws nothing itself. 'articulation' stays in the vocabulary for pivots and is never a mount. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 53 ++++-- Assets/Scripts/ServerShared/ShipModPlan.cs | 30 +--- .../ShipAuthoringTests.cs | 156 ++++++++++++++---- tests/Aetheria.Shared.Tests/ShipFixture.cs | 2 +- .../Aetheria.Shared.Tests/ShipModPlanTests.cs | 47 +----- .../ShipValidationTests.cs | 6 +- 6 files changed, 171 insertions(+), 123 deletions(-) diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index d7263fd42..837bcefc2 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -24,8 +24,8 @@ public sealed class ShipAnchor { // Stable semantic identity. HullData.Hardpoints[].Transform names this ID for mounts. [Key(0)] public string Id; - // One of: map-icon, hull-collider, shield, tractor, thruster-emitter, - // weapon-muzzle, radiator-mesh, or articulation. + // One of: map-icon, hull-collider, shield, tractor, thruster-emitter, weapon-mount, + // weapon-muzzle, radiator-mesh, or articulation (a pivot, never a mount). [Key(1)] public string Role; // Stable node ID in the compiled model, never a Blender display name. [Key(2)] public string ModelNodeId; @@ -53,9 +53,19 @@ public static class ShipAuthoringStore // ShipAnchor.Role's vocabulary. private static readonly HashSet Roles = new HashSet(StringComparer.Ordinal) { - "map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-muzzle", "radiator-mesh", "articulation" + "map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh", + "articulation" }; + // The anchor role a visible hardpoint's mount plays, or null for an internal hardpoint, which has no anchor. + private static string MountRole(HardpointType type) => + type == HardpointType.Thruster ? "thruster-emitter" : + type == HardpointType.Radiator ? "radiator-mesh" : + IsWeapon(type) ? "weapon-mount" : null; + + public static bool IsWeapon(HardpointType type) => + type == HardpointType.Energy || type == HardpointType.Ballistic || type == HardpointType.Launcher; + public static CultCache Open(string path, bool writable = false) { var cache = new CultCache(); @@ -193,13 +203,13 @@ public static class ShipAuthoringStore if (anchors.Count(anchor => anchor.Role == role) != 1) throw new InvalidOperationException($"{ship.Id}: exactly one {role} anchor is required."); - var mounts = new HashSet(StringComparer.Ordinal); + var mounts = new Dictionary(StringComparer.Ordinal); var occupiedHardpointCells = new HashSet<(int x, int y)>(); foreach (var hardpoint in hull.Hardpoints ?? new List()) { - if (hardpoint == null || string.IsNullOrWhiteSpace(hardpoint.Transform) || - !mounts.Add(hardpoint.Transform)) + if (hardpoint == null || string.IsNullOrWhiteSpace(hardpoint.Transform) || mounts.ContainsKey(hardpoint.Transform)) throw new InvalidOperationException($"{ship.Id}: hardpoint IDs must be present and unique."); + mounts.Add(hardpoint.Transform, hardpoint); if (!Enum.IsDefined(typeof(HardpointType), hardpoint.Type)) throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has an unknown type {(int)hardpoint.Type}."); if (!Enum.IsDefined(typeof(ItemRotation), hardpoint.Rotation)) @@ -219,19 +229,32 @@ public static class ShipAuthoringStore if (!occupiedHardpointCells.Add((x, y))) throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} overlaps another hardpoint."); } + // Only a hardpoint that shows on the model has a mount anchor, and its type fixes the role that anchor plays. var mountAnchor = anchors.FirstOrDefault(anchor => anchor.Id == hardpoint.Transform); - if (mountAnchor == null) - throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has no model anchor."); - // A mount's anchor plays the mount, so its role is fixed by the hardpoint type; the structural roles (map-icon, - // hull-collider, shield, tractor, weapon-muzzle) name other nodes and never double as a mount. - var mountRole = hardpoint.Type == HardpointType.Thruster ? "thruster-emitter" : - hardpoint.Type == HardpointType.Radiator ? "radiator-mesh" : "articulation"; - if (mountAnchor.Role != mountRole) + var mountRole = MountRole(hardpoint.Type); + if (mountRole == null && mountAnchor != null) + throw new InvalidOperationException($"{ship.Id}: {hardpoint.Type} hardpoint {hardpoint.Transform} is internal, so no anchor may take its id."); + if (mountRole != null && mountAnchor == null) + throw new InvalidOperationException($"{ship.Id}: {hardpoint.Type} hardpoint {hardpoint.Transform} has no model anchor."); + if (mountRole != null && mountAnchor.Role != mountRole) throw new InvalidOperationException($"{ship.Id}: {hardpoint.Type} hardpoint {hardpoint.Transform} needs a {mountRole} anchor of that id."); } - foreach (var anchor in anchors.Where(anchor => !string.IsNullOrEmpty(anchor.ParentId))) - if (!mounts.Contains(anchor.ParentId)) + foreach (var anchor in anchors) + { + if (!string.IsNullOrEmpty(anchor.ParentId) && !mounts.ContainsKey(anchor.ParentId)) throw new InvalidOperationException($"{ship.Id}: anchor {anchor.Id} has unknown hardpoint parent {anchor.ParentId}."); + // A mount-role anchor is the mount of a hardpoint of its type, never a loose node. The hardpoint loop above has + // already refused one whose id names a hardpoint of another type, so here it only has to name a hardpoint. + if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh" || anchor.Role == "weapon-mount") && + !mounts.ContainsKey(anchor.Id)) + throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} must be the mount of a hardpoint of its type."); + if (anchor.Role == "weapon-muzzle" && + (string.IsNullOrEmpty(anchor.ParentId) || !IsWeapon(mounts[anchor.ParentId].Type))) + throw new InvalidOperationException($"{ship.Id}: muzzle {anchor.Id} must be parented to a weapon hardpoint's mount."); + } + foreach (var hardpoint in mounts.Values.Where(hardpoint => IsWeapon(hardpoint.Type))) + if (!anchors.Any(anchor => anchor.Role == "weapon-muzzle" && anchor.ParentId == hardpoint.Transform)) + throw new InvalidOperationException($"{ship.Id}: weapon hardpoint {hardpoint.Transform} needs at least one muzzle anchor."); foreach (var line in ship.SchematicLines ?? new List()) { diff --git a/Assets/Scripts/ServerShared/ShipModPlan.cs b/Assets/Scripts/ServerShared/ShipModPlan.cs index 787656a3e..bb80647a1 100644 --- a/Assets/Scripts/ServerShared/ShipModPlan.cs +++ b/Assets/Scripts/ServerShared/ShipModPlan.cs @@ -13,37 +13,18 @@ public sealed class ShipModPlan // Mount ids whose anchor (role thruster-emitter / radiator-mesh) is the emitter / mesh node itself. public string[] Thrusters; public string[] Radiators; - // A weapon mount's muzzle anchors, by Order then id. + // Each weapon mount (role weapon-mount) and its muzzle anchors, by Order then id. public (string Mount, string[] Muzzles)[] Weapons; - // Anchors with role "articulation" become ordinary mount nodes: ArticulationPoint's limits are not authorable until - // pivots move onto HullData, so no ArticulationPoint is planned. + // Maps a pair Validate accepts; every rule about which anchor may play which part is Validate's, so this refuses nothing. public static ShipModPlan Build(HullData hull, ShipAuthoring visual) { ShipAuthoringStore.Validate(hull, visual); var hardpoints = hull.Hardpoints ?? new List(); - var byMount = hardpoints.ToDictionary(hardpoint => hardpoint.Transform, StringComparer.Ordinal); string Only(string wanted) => visual.Anchors.Single(anchor => anchor.Role == wanted).Id; - - foreach (var anchor in visual.Anchors.Where(anchor => anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh")) - { - var wanted = anchor.Role == "thruster-emitter" ? HardpointType.Thruster : HardpointType.Radiator; - if (!byMount.ContainsKey(anchor.Id)) - throw new InvalidOperationException($"{visual.Id}: {anchor.Role} anchor {anchor.Id} must be a {wanted} hardpoint's mount."); - } string[] Mounts(HardpointType type) => hardpoints.Where(hardpoint => hardpoint.Type == type).Select(hardpoint => hardpoint.Transform).ToArray(); - var muzzles = visual.Anchors.Where(anchor => anchor.Role == "weapon-muzzle") .OrderBy(anchor => anchor.Order).ThenBy(anchor => anchor.Id, StringComparer.Ordinal).ToArray(); - foreach (var muzzle in muzzles) - if (string.IsNullOrEmpty(muzzle.ParentId) || !IsWeapon(byMount[muzzle.ParentId].Type)) - throw new InvalidOperationException($"{visual.Id}: muzzle {muzzle.Id} must be parented to a weapon hardpoint's mount."); - var weapons = hardpoints.Where(hardpoint => IsWeapon(hardpoint.Type)).Select(hardpoint => ( - hardpoint.Transform, - muzzles.Where(muzzle => muzzle.ParentId == hardpoint.Transform).Select(muzzle => muzzle.Id).ToArray())).ToArray(); - foreach (var (mount, ids) in weapons) - if (ids.Length == 0) - throw new InvalidOperationException($"{visual.Id}: weapon hardpoint {mount} needs at least one muzzle anchor."); return new ShipModPlan { @@ -53,10 +34,9 @@ public sealed class ShipModPlan Tractor = Only("tractor"), Thrusters = Mounts(HardpointType.Thruster), Radiators = Mounts(HardpointType.Radiator), - Weapons = weapons + Weapons = hardpoints.Where(hardpoint => ShipAuthoringStore.IsWeapon(hardpoint.Type)).Select(hardpoint => ( + hardpoint.Transform, + muzzles.Where(muzzle => muzzle.ParentId == hardpoint.Transform).Select(muzzle => muzzle.Id).ToArray())).ToArray() }; } - - private static bool IsWeapon(HardpointType type) => - type == HardpointType.Energy || type == HardpointType.Ballistic || type == HardpointType.Launcher; } diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 2d6680933..349411ea2 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -230,49 +230,137 @@ public sealed class ShipAuthoringTests Assert.Contains("records must be stored under", Assert.Throws(() => ShipAuthoringStore.Load(wrongVisualKey)).Message); } - // A mount's anchor plays the mount, so a structural anchor (the hull collider, the shield, a muzzle...) is never one. - // Interim ruling until an `equipment` role exists: a mount that is not a thruster or radiator is an articulation anchor. - [Theory] - [InlineData("map-icon", "map")] - [InlineData("hull-collider", "collider")] - [InlineData("shield", "shield")] - [InlineData("tractor", "tractor")] - [InlineData("weapon-muzzle", "muzzle")] - [InlineData("thruster-emitter", "emitter")] - [InlineData("radiator-mesh", "mesh")] - public void AStructuralAnchorCannotBeAnEquipmentMount(string role, string anchorId) + private static readonly string[] AllRoles = + { + "map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh", "articulation" + }; + + // The fixture with its thruster swapped for one hardpoint of the given type at mount id "mount", and no anchor for it. + private static ShipParts WithOnly(HardpointType type) { var ship = Fixture(); - // The extra anchors exist so the role-count and parent checks pass; the Tool hardpoint then claims the anchor. - ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" }); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "emitter", Role = "thruster-emitter", ModelNodeId = "emitter" }); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "mesh", Role = "radiator-mesh", ModelNodeId = "mesh" }); - ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Tool, Position = new int2(0, 0), Shape = new Shape(), Transform = anchorId }); + ship.Hull.Hardpoints.Clear(); + ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "thruster.port"); + ship.Hull.Hardpoints.Add(new HardpointData { Type = type, Position = new int2(0, 0), Shape = new Shape(), Transform = "mount" }); + return ship; + } - Assert.Contains($"Tool hardpoint {anchorId} needs a articulation anchor of that id", Assert.Throws(ship.Validate).Message); - ship = Fixture(); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "tool", Role = "articulation", ModelNodeId = "tool" }); - ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Tool, Position = new int2(0, 0), Shape = new Shape(), Transform = "tool" }); - ship.Validate(); + private static string Refusal(ShipParts ship) => Assert.Throws(ship.Validate).Message; + + // Mount anchors exist only for hardpoints that show on the model: weapons, radiators and thrusters. + [Theory] + [InlineData(HardpointType.Hull), InlineData(HardpointType.Tool), InlineData(HardpointType.Thermal), InlineData(HardpointType.WarpDrive), + InlineData(HardpointType.Reactor), InlineData(HardpointType.Shield), InlineData(HardpointType.Sensors), + InlineData(HardpointType.ControlModule), InlineData(HardpointType.AetherDrive)] + public void AnInternalHardpointCarriesNoAnchor(HardpointType type) + { + WithOnly(type).Validate(); + + // Any anchor taking the mount's id is refused, a mount role or not. (The four structural roles are one-of-a-kind, + // so they are covered by renaming the existing anchor.) + foreach (var role in AllRoles) + { + var ship = WithOnly(type); + var existing = ship.Visual.Anchors.FirstOrDefault(anchor => anchor.Role == role); + if (existing != null) existing.Id = "mount"; + else ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" }); + Assert.Contains($"mod.skiff: {type} hardpoint mount is internal, so no anchor may take its id", Refusal(ship)); + } + } + + [Theory] + [InlineData(HardpointType.Thruster, "thruster-emitter")] + [InlineData(HardpointType.Radiator, "radiator-mesh")] + [InlineData(HardpointType.Energy, "weapon-mount")] + [InlineData(HardpointType.Ballistic, "weapon-mount")] + [InlineData(HardpointType.Launcher, "weapon-mount")] + public void EachVisibleHardpointNeedsItsRoleAnchor(HardpointType type, string wanted) + { + ShipParts Rig() + { + var ship = WithOnly(type); + if (ShipAuthoringStore.IsWeapon(type)) + ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount.muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "mount" }); + return ship; + } + + Assert.Contains($"mod.skiff: {type} hardpoint mount has no model anchor", Refusal(Rig())); + + // A structural anchor (the map icon, the collider...) whose id is the mount, or a mount anchor of the wrong role. + foreach (var role in AllRoles.Where(role => role != wanted)) + { + var ship = Rig(); + var existing = ship.Visual.Anchors.FirstOrDefault(anchor => anchor.Role == role); + if (existing != null) existing.Id = "mount"; + else ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" }); + Assert.Contains($"mod.skiff: {type} hardpoint mount needs a {wanted} anchor of that id", Refusal(ship)); + } + + var valid = Rig(); + valid.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = wanted, ModelNodeId = "mount" }); + valid.Validate(); + } + + // Called on Validate itself: a mount-role anchor is the mount of a hardpoint of its own type, never a loose node and + // never another type's mount. + [Theory] + [InlineData("thruster-emitter", HardpointType.Radiator)] + [InlineData("thruster-emitter", HardpointType.Energy)] + [InlineData("thruster-emitter", HardpointType.Reactor)] + [InlineData("radiator-mesh", HardpointType.Thruster)] + [InlineData("radiator-mesh", HardpointType.Launcher)] + [InlineData("radiator-mesh", HardpointType.Sensors)] + [InlineData("weapon-mount", HardpointType.Thruster)] + [InlineData("weapon-mount", HardpointType.Radiator)] + [InlineData("weapon-mount", HardpointType.Tool)] + public void AMountRoleAnchorMustBeAMountOfItsType(string role, HardpointType otherType) + { + string Refused(ShipParts ship) => + Assert.Throws(() => ShipAuthoringStore.Validate(ship.Hull, ship.Visual)).Message; + + var loose = Fixture(); + loose.Visual.Anchors.Add(new ShipAnchor { Id = "loose", Role = role, ModelNodeId = "loose" }); + Assert.Contains($"mod.skiff: {role} anchor loose must be the mount of a hardpoint of its type", Refused(loose)); + + // The anchor takes the id of a hardpoint of another type; whichever side the validator reads first, it refuses. + var other = WithOnly(otherType); + other.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" }); + if (ShipAuthoringStore.IsWeapon(otherType)) + other.Visual.Anchors.Add(new ShipAnchor { Id = "mount.muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "mount" }); + Assert.Contains($"mod.skiff: {otherType} hardpoint mount ", Refused(other)); } [Fact] - public void EveryHardpointTypeHasOneAnchorRoleForItsMount() + public void MuzzleRulesBelongToTheValidator() { - foreach (HardpointType type in Enum.GetValues(typeof(HardpointType))) + ShipParts Armed() { - var wanted = type == HardpointType.Thruster ? "thruster-emitter" : type == HardpointType.Radiator ? "radiator-mesh" : "articulation"; - foreach (var role in new[] { "articulation", "thruster-emitter", "radiator-mesh" }) - { - var ship = Fixture(); - ship.Hull.Hardpoints.Clear(); - ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "thruster.port"); - ship.Hull.Hardpoints.Add(new HardpointData { Type = type, Position = new int2(0, 0), Shape = new Shape(), Transform = "mount" }); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" }); - if (role == wanted) ship.Validate(); - else Assert.Contains($"{type} hardpoint mount needs a {wanted} anchor of that id", Assert.Throws(ship.Validate).Message); - } + var ship = Fixture(); + ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "weapon-mount", ModelNodeId = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.b", Role = "weapon-muzzle", ModelNodeId = "gun-b", ParentId = "gun", Order = 0 }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.a", Role = "weapon-muzzle", ModelNodeId = "gun-a", ParentId = "gun", Order = 0 }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.z", Role = "weapon-muzzle", ModelNodeId = "gun-z", ParentId = "gun", Order = -1 }); + return ship; } + + var orphan = Armed(); + orphan.Visual.Anchors.Single(anchor => anchor.Id == "gun.z").ParentId = null; + Assert.Contains("mod.skiff: muzzle gun.z must be parented to a weapon hardpoint's mount", Refusal(orphan)); + + var onThruster = Armed(); + onThruster.Visual.Anchors.Single(anchor => anchor.Id == "gun.z").ParentId = "thruster.port"; + Assert.Contains("mod.skiff: muzzle gun.z must be parented to a weapon hardpoint's mount", Refusal(onThruster)); + + var unarmed = Armed(); + unarmed.Visual.Anchors.RemoveAll(anchor => anchor.Role == "weapon-muzzle"); + Assert.Contains("mod.skiff: weapon hardpoint gun needs at least one muzzle anchor", Refusal(unarmed)); + + // A valid pair plans without a refusal of the plan's own; muzzles by Order, then id ordinally. + var valid = Armed(); + var (mount, muzzles) = Assert.Single(ShipModPlan.Build(valid.Hull, valid.Visual).Weapons); + Assert.Equal("gun", mount); + Assert.Equal(new[] { "gun.z", "gun.a", "gun.b" }, muzzles); } internal static ShipParts Fixture() diff --git a/tests/Aetheria.Shared.Tests/ShipFixture.cs b/tests/Aetheria.Shared.Tests/ShipFixture.cs index eb4f5c01e..2e8d4994e 100644 --- a/tests/Aetheria.Shared.Tests/ShipFixture.cs +++ b/tests/Aetheria.Shared.Tests/ShipFixture.cs @@ -68,7 +68,7 @@ internal static class ShipFixture var ship = ShipAuthoringTests.Fixture(); // A playable rig: the fixture thruster plus one energy weapon mount on the hull's other cell, with its muzzle. ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" }); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "articulation", ModelNodeId = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "weapon-mount", ModelNodeId = "gun" }); ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.muzzle", Role = "weapon-muzzle", ModelNodeId = "gun-muzzle", ParentId = "gun" }); tweak?.Invoke(ship); ship.WithId(id); diff --git a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs index e44c60e83..6df76dc6b 100644 --- a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs @@ -17,7 +17,7 @@ public sealed class ShipModPlanTests ship.Hull.Hardpoints[0].Position = new int2(2, 0); ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" }); ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Radiator, Position = new int2(1, 0), Shape = new Shape(), Transform = "fin" }); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "articulation", ModelNodeId = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "weapon-mount", ModelNodeId = "gun" }); ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.a", Role = "weapon-muzzle", ModelNodeId = "gun-a", ParentId = "gun", Order = 1 }); ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.z", Role = "weapon-muzzle", ModelNodeId = "gun-z", ParentId = "gun", Order = 0 }); ship.Visual.Anchors.Add(new ShipAnchor { Id = "fin", Role = "radiator-mesh", ModelNodeId = "fin" }); @@ -40,31 +40,6 @@ public sealed class ShipModPlanTests Assert.Equal(new[] { "gun.z", "gun.a" }, muzzles); } - [Fact] - public void AThrusterOrRadiatorMountMustCarryItsRole() - { - var ship = Rig(); - ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Role = "articulation"; - Assert.Contains("Thruster hardpoint thruster.port needs a thruster-emitter anchor", Refusal(ship)); - - ship = Rig(); - ship.Visual.Anchors.Single(anchor => anchor.Id == "fin").Role = "articulation"; - Assert.Contains("Radiator hardpoint fin needs a radiator-mesh anchor", Refusal(ship)); - } - - [Fact] - public void AnEmitterOrMeshAnchorMustBeItsHardpointsMount() - { - // A thruster-emitter anchor on a weapon mount, and a radiator-mesh anchor that is no mount at all. - var ship = Rig(); - ship.Visual.Anchors.Single(anchor => anchor.Id == "gun").Role = "thruster-emitter"; - Assert.Contains("Energy hardpoint gun needs a articulation anchor of that id", Refusal(ship)); - - ship = Rig(); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "loose", Role = "radiator-mesh", ModelNodeId = "loose" }); - Assert.Contains("radiator-mesh anchor loose must be a Radiator hardpoint's mount", Refusal(ship)); - } - // Equal Order falls back to the id, compared ordinally, whatever order the anchors were authored in. [Fact] public void MuzzlesOfEqualOrderAreTiedByIdOrdinally() @@ -78,22 +53,6 @@ public sealed class ShipModPlanTests Assert.Equal(new[] { "gun.z", "gun.B", "gun.a", "gun.b", "gun.c" }, Assert.Single(Plan(ship).Weapons).Muzzles); } - [Fact] - public void MuzzlesBelongToWeaponMountsAndEveryWeaponHasOne() - { - var ship = Rig(); - ship.Visual.Anchors.Single(anchor => anchor.Id == "gun.z").ParentId = null; - Assert.Contains("muzzle gun.z must be parented to a weapon hardpoint's mount", Refusal(ship)); - - ship = Rig(); - ship.Visual.Anchors.Single(anchor => anchor.Id == "gun.z").ParentId = "thruster.port"; - Assert.Contains("muzzle gun.z must be parented to a weapon hardpoint's mount", Refusal(ship)); - - ship = Rig(); - ship.Visual.Anchors.RemoveAll(anchor => anchor.Role == "weapon-muzzle"); - Assert.Contains("weapon hardpoint gun needs at least one muzzle anchor", Refusal(ship)); - } - [Theory] [InlineData(HardpointType.Energy)] [InlineData(HardpointType.Ballistic)] @@ -103,10 +62,6 @@ public sealed class ShipModPlanTests var ship = Rig(); ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "gun").Type = type; Assert.Equal("gun", Assert.Single(Plan(ship).Weapons).Mount); - - // Any other type refuses the same muzzles. - ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "gun").Type = HardpointType.Tool; - Assert.Contains("must be parented to a weapon hardpoint's mount", Refusal(ship)); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index eb150939a..c67f3e6d3 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -34,7 +34,7 @@ public sealed class ShipValidationTests ship.Hull.Hardpoints[0].Armor = 5; ship.Hull.Hardpoints[0].FiringArc = 360; ship.Hull.Hardpoints[0].Type = HardpointType.AetherDrive; - ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Role = "articulation"; + ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "thruster.port"); ship.Hull.Hardpoints[0].Rotation = ItemRotation.Clockwise; ship.Validate(); } @@ -128,7 +128,9 @@ public sealed class ShipValidationTests public void AnAnchorMayNameItsHardpointAsParent() { var ship = ShipAuthoringTests.Fixture(); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" }); + ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Ballistic, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "weapon-mount", ModelNodeId = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "gun" }); ship.Validate(); } From 95c0fa621b4b8fa1a921635254c409a32468a259 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 14:39:09 +0200 Subject: [PATCH 086/113] Ship mounts: a thruster's mesh is an unrendered emission surface Ruling thrusters-radiators-are-meshes: thruster and radiator mounts are meshes; the thruster's is not drawn and is the surface its exhaust emits from, the radiator's is drawn. The assembler already required a mesh for both; it now turns rendering off on the thruster emitter, and the preview smoke checks it. The authoring doc states the mount rules and the mesh rule. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/ShipModPreview.cs | 3 ++- Assets/Scripts/Gameplay/ShipModShips.cs | 2 ++ docs/moddable-ship-authoring.md | 21 ++++++++++++++------- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/Editor/ShipModPreview.cs b/Assets/Scripts/Editor/ShipModPreview.cs index 44d419d92..090917d9e 100644 --- a/Assets/Scripts/Editor/ShipModPreview.cs +++ b/Assets/Scripts/Editor/ShipModPreview.cs @@ -65,7 +65,8 @@ public static class ShipModPreview ship.HullColliders[0].GetComponent() != null, "one hull collider with a MeshCollider"); Require(Inside(ship.MapIcon) && Inside(ship.Shield) && Inside(ship.TractorBeam), "map icon, shield and tractor inside the ship"); Require(ship.ThrusterHardpoints.Select(node => node.name).SequenceEqual(plan.Thrusters) && - ship.ThrusterHardpoints.All(node => Inside(node) && Inside(node.Emitter)), "thruster hardpoints with emitters"); + ship.ThrusterHardpoints.All(node => Inside(node) && Inside(node.Emitter) && node.Emitter.forceRenderingOff), + "thruster hardpoints with unrendered emitter meshes"); Require(ship.RadiatorHardpoints.Select(node => node.name).SequenceEqual(plan.Radiators) && ship.RadiatorHardpoints.All(node => Inside(node) && Inside(node.Mesh)), "radiator hardpoints with meshes"); Require(ship.WeaponHardpoints.Select(node => node.name).SequenceEqual(plan.Weapons.Select(weapon => weapon.Mount)) && diff --git a/Assets/Scripts/Gameplay/ShipModShips.cs b/Assets/Scripts/Gameplay/ShipModShips.cs index 9e06099c9..d60dc2a8c 100644 --- a/Assets/Scripts/Gameplay/ShipModShips.cs +++ b/Assets/Scripts/Gameplay/ShipModShips.cs @@ -122,8 +122,10 @@ public static class ShipModShips var thrusters = plan.Thrusters.Select(id => { + // The thruster's mesh is its exhaust's emission surface, never drawn (ruling thrusters-radiators-are-meshes). var hardpoint = AddOrGet(Node(id).gameObject); hardpoint.Emitter = Renderer(id); + hardpoint.Emitter.forceRenderingOff = true; return hardpoint; }).ToArray(); var radiators = plan.Radiators.Select(id => diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 5a69f06de..6072e1afb 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -161,13 +161,20 @@ import](https://github.com/Unity-Technologies/com.unity.cloud.gltfast/blob/main/ to read the package GLB asynchronously and map its stable node IDs to Unity transforms. It keeps a partially loaded object inactive and destroys it on failure. `ShipModShips` preloads one assembled, inactive prototype per hull -that names a `Visual`, at boot and before the game scene loads. `ShipModPlan` -decides which anchor fills which `ShipInstance` slot: the hull's hardpoint type -says what a mount is, and its anchor (id equal to the mount id) supplies the -node; a thruster mount needs a `thruster-emitter` anchor, a radiator mount a -`radiator-mesh` anchor, a weapon mount muzzle anchors parented to it. An -`articulation` anchor is an ordinary mount node until pivots move onto -`HullData`. The shared effects (shield, tractor beam, ping, explosion, map +that names a `Visual`, at boot and before the game scene loads. +`ShipAuthoringStore.Validate` owns which hardpoints carry anchors and which role +each anchor plays. Mount anchors exist only for hardpoints that show on the +model: weapons, radiators and thrusters. Each such hardpoint needs an anchor +whose id is its mount id: `thruster-emitter` for a thruster, `radiator-mesh` for +a radiator, `weapon-mount` for an energy, ballistic or launcher hardpoint. An +internal hardpoint (reactor, sensors, shield and the rest) has no anchor, and +an anchor taking its id is refused. A thruster's or radiator's anchor node is a +mesh: the thruster's is not rendered and is the surface its exhaust emits from, +the radiator's is rendered. A weapon mount is a point, and each one needs at +least one `weapon-muzzle` anchor whose `ParentId` is the weapon's mount id. +`articulation` is reserved for pivots and is never a mount; no pivot is +planned until pivots move onto `HullData`. `ShipModPlan` then maps the +validated pair to `ShipInstance` slots and refuses nothing itself. The shared effects (shield, tractor beam, ping, explosion, map and invisible materials) come from the Addressable `ShipModTemplate` prefab. `ZoneRenderer.LoadEntity` clones the prototype when the hull names a `Visual`. Gameplay reads a mod ship's records from the derived catalog; the package From da2a299baa0587244c3b0eff8ac1cb02237baf99 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 14:48:51 +0200 Subject: [PATCH 087/113] Ship mounts: hide the thruster emitter with the invisible material forceRenderingOff is not serialized, so the prototype's clone drew the emitter again; the preview smoke's clone check caught it. The emitter's materials become the template's invisible material, which survives Instantiate and which EntityInstance keeps as the renderer's own material. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/ShipModPreview.cs | 3 ++- Assets/Scripts/Gameplay/ShipModShips.cs | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/Editor/ShipModPreview.cs b/Assets/Scripts/Editor/ShipModPreview.cs index 090917d9e..64f9ea6b7 100644 --- a/Assets/Scripts/Editor/ShipModPreview.cs +++ b/Assets/Scripts/Editor/ShipModPreview.cs @@ -65,7 +65,8 @@ public static class ShipModPreview ship.HullColliders[0].GetComponent() != null, "one hull collider with a MeshCollider"); Require(Inside(ship.MapIcon) && Inside(ship.Shield) && Inside(ship.TractorBeam), "map icon, shield and tractor inside the ship"); Require(ship.ThrusterHardpoints.Select(node => node.name).SequenceEqual(plan.Thrusters) && - ship.ThrusterHardpoints.All(node => Inside(node) && Inside(node.Emitter) && node.Emitter.forceRenderingOff), + ship.ThrusterHardpoints.All(node => Inside(node) && Inside(node.Emitter) && + node.Emitter.sharedMaterials.All(material => material == ship.InvisibleMaterial)), "thruster hardpoints with unrendered emitter meshes"); Require(ship.RadiatorHardpoints.Select(node => node.name).SequenceEqual(plan.Radiators) && ship.RadiatorHardpoints.All(node => Inside(node) && Inside(node.Mesh)), "radiator hardpoints with meshes"); diff --git a/Assets/Scripts/Gameplay/ShipModShips.cs b/Assets/Scripts/Gameplay/ShipModShips.cs index d60dc2a8c..354a1e4a6 100644 --- a/Assets/Scripts/Gameplay/ShipModShips.cs +++ b/Assets/Scripts/Gameplay/ShipModShips.cs @@ -123,9 +123,11 @@ public static class ShipModShips var thrusters = plan.Thrusters.Select(id => { // The thruster's mesh is its exhaust's emission surface, never drawn (ruling thrusters-radiators-are-meshes). + // The invisible material is serialized, so it survives the prototype's clone, and EntityInstance keeps it as + // this renderer's visible material; forceRenderingOff would not survive Instantiate. var hardpoint = AddOrGet(Node(id).gameObject); hardpoint.Emitter = Renderer(id); - hardpoint.Emitter.forceRenderingOff = true; + hardpoint.Emitter.sharedMaterials = Enumerable.Repeat(template.Invisible, hardpoint.Emitter.sharedMaterials.Length).ToArray(); return hardpoint; }).ToArray(); var radiators = plan.Radiators.Select(id => From 80de88f2674a4799c9d1256b3a89e7e23e42ccbf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 14:54:03 +0200 Subject: [PATCH 088/113] Ship mounts: each weapon needs a muzzle of its own Kills the Stryker survivor that armed every weapon with any muzzle on the ship (the && in the per-weapon muzzle check turned into ||): a second weapon mount with no muzzle is refused even when the first has one. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 349411ea2..8855475ae 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -356,6 +356,13 @@ public sealed class ShipAuthoringTests unarmed.Visual.Anchors.RemoveAll(anchor => anchor.Role == "weapon-muzzle"); Assert.Contains("mod.skiff: weapon hardpoint gun needs at least one muzzle anchor", Refusal(unarmed)); + // Each weapon needs a muzzle of its own: another weapon's muzzle does not arm it. + var second = Armed(); + second.Hull.Shape.Cells[0, 1] = true; + second.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Launcher, Position = new int2(0, 1), Shape = new Shape(), Transform = "tube" }); + second.Visual.Anchors.Add(new ShipAnchor { Id = "tube", Role = "weapon-mount", ModelNodeId = "tube" }); + Assert.Contains("mod.skiff: weapon hardpoint tube needs at least one muzzle anchor", Refusal(second)); + // A valid pair plans without a refusal of the plan's own; muzzles by Order, then id ordinally. var valid = Armed(); var (mount, muzzles) = Assert.Single(ShipModPlan.Build(valid.Hull, valid.Visual).Weapons); From 5ea59ac6b5cbcfbdfefe9fbaa3a008e2c3b3b210 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 14:57:27 +0200 Subject: [PATCH 089/113] Entity: an equipped item keeps the hardpoint it occupies The placement rule (HardpointAt) already finds the one hardpoint an item lies in at equip; TryEquip threw that answer away and eighteen readers recomputed it from the item's origin cell, which need not lie in the hardpoint (Soul's scenarios batch-4 P30). EquippedItem.Hardpoint now carries the answer, null for Tool gear and the hull, and every origin-cell lookup reads it instead: FireControl.ArcFor, the eleven weapon managers, EntityInstance, ShipInstance and ActionGameManager's articulation grouping. Entity.Hardpoints[,] stays the per-cell map for armour and display. TryFindSpace's hardpoint search tries every origin the fit rule can accept, including ones up and left of the hardpoint, so a design whose shape lacks its origin cell is found wherever ItemFits would place it. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 6 +-- Assets/Scripts/Gameplay/EntityInstance.cs | 4 +- Assets/Scripts/Gameplay/ShipInstance.cs | 2 +- .../Gameplay/Weapons/ChargeEffectManager.cs | 2 +- .../Gameplay/Weapons/ConstantLaserManager.cs | 2 +- .../Weapons/ConstantLightningManager.cs | 2 +- .../Weapons/ConstantParticleWeaponManager.cs | 2 +- .../Weapons/GuidedProjectileManager.cs | 4 +- .../Gameplay/Weapons/HitscanManager.cs | 2 +- .../Scripts/Gameplay/Weapons/LaserManager.cs | 2 +- .../Gameplay/Weapons/LightningGunManager.cs | 2 +- .../Scripts/Gameplay/Weapons/MineManager.cs | 2 +- .../Gameplay/Weapons/ProjectileManager.cs | 2 +- Assets/Scripts/ServerShared/Entity.cs | 42 ++++++++++++------- Assets/Scripts/ServerShared/FireControl.cs | 2 +- 15 files changed, 45 insertions(+), 33 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 1576e88b9..c1ee9cac4 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1053,12 +1053,12 @@ public class ActionGameManager : MonoBehaviour _articulationGroups = CurrentEntity.Equipment .Where(item => item.Behaviors.Any(x => x.Data is WeaponData && !(x.Data is LauncherData))) .GroupBy(item => ZoneRenderer.EntityInstances[CurrentEntity] - .GetBarrel(CurrentEntity.Hardpoints[item.Position.x, item.Position.y]) + .GetBarrel(item.Hardpoint) .GetComponentInParent()?.Group ?? -1) .Select((group, index) => { return ( - group.Select(item => CurrentEntity.Hardpoints[item.Position.x, item.Position.y]).ToArray(), - group.Select(item => ZoneRenderer.EntityInstances[CurrentEntity].GetBarrel(CurrentEntity.Hardpoints[item.Position.x, item.Position.y])).ToArray(), + group.Select(item => item.Hardpoint).ToArray(), + group.Select(item => ZoneRenderer.EntityInstances[CurrentEntity].GetBarrel(item.Hardpoint)).ToArray(), Crosshairs[index] ); }).ToArray(); diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index f3d977c8c..40505efa3 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -242,7 +242,7 @@ public class EntityInstance : MonoBehaviour } } - var hp = Entity.Hardpoints[item.Position.x, item.Position.y]; + var hp = item.Hardpoint; if (hp != null && item.Data.SoundBank != 0) { var hardpointTransform = EquipmentHardpoints.FirstOrDefault(x => x.name == hp.Transform); @@ -257,7 +257,7 @@ public class EntityInstance : MonoBehaviour BarrelIndices = new Dictionary(); foreach (var radiator in entity.GetBehaviors()) { - var hp = Entity.Hardpoints[radiator.Item.Position.x, radiator.Item.Position.y]; + var hp = radiator.Item.Hardpoint; if (hp != null && hp.Type == HardpointType.Radiator) { var mesh = RadiatorHardpoints.FirstOrDefault(x => x.name == hp.Transform); diff --git a/Assets/Scripts/Gameplay/ShipInstance.cs b/Assets/Scripts/Gameplay/ShipInstance.cs index 8a0b22b6e..a81668af4 100644 --- a/Assets/Scripts/Gameplay/ShipInstance.cs +++ b/Assets/Scripts/Gameplay/ShipInstance.cs @@ -67,7 +67,7 @@ public class ShipInstance : EntityInstance var particles = Instantiate(EngineAssets.Load(effectData.ParticlesPrefab), transform, false); var particlesShape = particles.shape; var thrusterHardpoint = ThrusterHardpoints - .FirstOrDefault(t => t.name == ship.Hardpoints[thruster.Item.Position.x, thruster.Item.Position.y].Transform); + .FirstOrDefault(t => t.name == thruster.Item.Hardpoint.Transform); particlesShape.meshRenderer = thrusterHardpoint?.Emitter; // if (!string.IsNullOrEmpty(thruster.Item.Data.SoundEffectTrigger) && thrusterHardpoint != null) // { diff --git a/Assets/Scripts/Gameplay/Weapons/ChargeEffectManager.cs b/Assets/Scripts/Gameplay/Weapons/ChargeEffectManager.cs index f7aae8368..b7f8a2118 100644 --- a/Assets/Scripts/Gameplay/Weapons/ChargeEffectManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/ChargeEffectManager.cs @@ -11,7 +11,7 @@ public class ChargeEffectManager : MonoBehaviour public void StartCharging(ChargedWeapon weapon, EquippedItem item, EntityInstance source) { var effect = EffectPrototype.Instantiate(); - var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; + var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); var t = effect.transform; t.SetParent(barrel); diff --git a/Assets/Scripts/Gameplay/Weapons/ConstantLaserManager.cs b/Assets/Scripts/Gameplay/Weapons/ConstantLaserManager.cs index 039e9344b..f4b860278 100644 --- a/Assets/Scripts/Gameplay/Weapons/ConstantLaserManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/ConstantLaserManager.cs @@ -12,7 +12,7 @@ public class ConstantLaserManager : ConstantWeaponEffectManager { var p = LaserPrototype.Instantiate(); p.SourceEntity = source.Entity; - var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; + var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); p.Range = item.Evaluate(data.Range); var t = p.transform; diff --git a/Assets/Scripts/Gameplay/Weapons/ConstantLightningManager.cs b/Assets/Scripts/Gameplay/Weapons/ConstantLightningManager.cs index 111df4433..9c948172b 100644 --- a/Assets/Scripts/Gameplay/Weapons/ConstantLightningManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/ConstantLightningManager.cs @@ -13,7 +13,7 @@ public class ConstantLightningManager : ConstantWeaponEffectManager { var p = LightningPrototype.Instantiate(); p.Source = source; - var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; + var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); p.Barrel = barrel; p.Range = item.Evaluate(data.Range); diff --git a/Assets/Scripts/Gameplay/Weapons/ConstantParticleWeaponManager.cs b/Assets/Scripts/Gameplay/Weapons/ConstantParticleWeaponManager.cs index 8b0095975..2fbd177d6 100644 --- a/Assets/Scripts/Gameplay/Weapons/ConstantParticleWeaponManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/ConstantParticleWeaponManager.cs @@ -13,7 +13,7 @@ public class ConstantParticleWeaponManager : ConstantWeaponEffectManager var p = WeaponPrototype.Instantiate(); p.Source = source; p.Target = target; - var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; + var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); var t = p.transform; t.SetParent(barrel); diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index e5eb74b79..a3302a93e 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -45,7 +45,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager if (target != null) p.Target = target.transform; else p.TargetPosition = () => burst; p.Frequency = launcher.DodgeFrequency; - var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; + var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); p.StartPosition = (p.transform.position = barrel.position).ToCultMath(); p.Range = weapon.Range; @@ -66,7 +66,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.Source = source.transform; p.SourceEntity = source.Entity; p.Frequency = guidance.DodgeFrequency; - var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; + var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); p.StartPosition = (p.transform.position = barrel.position).ToCultMath(); p.Range = weapon.Range; diff --git a/Assets/Scripts/Gameplay/Weapons/HitscanManager.cs b/Assets/Scripts/Gameplay/Weapons/HitscanManager.cs index 5075c3344..ada88e1f3 100644 --- a/Assets/Scripts/Gameplay/Weapons/HitscanManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/HitscanManager.cs @@ -7,7 +7,7 @@ public class HitscanManager : InstantWeaponEffectManager { var p = Prototype.Instantiate(); p.SourceEntity = source.Entity; - var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; + var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); var t = p.transform; t.SetParent(barrel, false); diff --git a/Assets/Scripts/Gameplay/Weapons/LaserManager.cs b/Assets/Scripts/Gameplay/Weapons/LaserManager.cs index de70054ac..9af0f4b99 100644 --- a/Assets/Scripts/Gameplay/Weapons/LaserManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/LaserManager.cs @@ -9,7 +9,7 @@ public class LaserManager : InstantWeaponEffectManager public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = ProjectilePrototype.Instantiate(); - var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; + var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); var t = p.transform; t.SetParent(barrel); diff --git a/Assets/Scripts/Gameplay/Weapons/LightningGunManager.cs b/Assets/Scripts/Gameplay/Weapons/LightningGunManager.cs index 24689ee8f..eff757682 100644 --- a/Assets/Scripts/Gameplay/Weapons/LightningGunManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/LightningGunManager.cs @@ -9,7 +9,7 @@ public class LightningGunManager : InstantWeaponEffectManager public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = Prototype.Instantiate(); - var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; + var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); p.ShotId = shotId; p.Barrel = barrel; diff --git a/Assets/Scripts/Gameplay/Weapons/MineManager.cs b/Assets/Scripts/Gameplay/Weapons/MineManager.cs index 303fab395..0e9347e28 100644 --- a/Assets/Scripts/Gameplay/Weapons/MineManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/MineManager.cs @@ -13,7 +13,7 @@ public class MineManager : InstantWeaponEffectManager public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = ProjectilePrototype.Instantiate(); - var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; + var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); var angle = weapon.Spread / 2; p.Source = source; diff --git a/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs index c9fc2e13f..56cfdcf80 100644 --- a/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs @@ -11,7 +11,7 @@ public class ProjectileManager : InstantWeaponEffectManager public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = ProjectilePrototype.Instantiate(); - var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; + var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); var angle = weapon.Spread / 2; p.ShotId = shotId; diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index d0641b695..aeeee7efc 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -601,7 +601,7 @@ public abstract class Entity private void MapEntity() { var hullData = ItemManager.GetData(Hull) as HullData; - EquippedHull = new EquippedItem(ItemManager, Hull, int2.zero, this); + EquippedHull = new EquippedItem(ItemManager, Hull, int2.zero, this, null); Equipment.Add(EquippedHull); // A bare hull, one TryEquip never ran on, still updates: its ordered equipment is the hull alone. _orderedEquipment = Equipment.ToArray(); @@ -787,9 +787,11 @@ public abstract class Entity return item.EquippableItem; } - // Check whether the given item will fit when its origin is placed at the given coordinate - private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord) + // Check whether the given item will fit when its origin is placed at the given coordinate. For hardpoint gear, + // hardpoint is the one hardpoint the item would occupy there (HardpointAt); Tool gear occupies none. + private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord, out HardpointData hardpoint) { + hardpoint = null; // Items without specific hardpoints on the ship can be freely rotated and placed anywhere if (itemData.HardpointType == HardpointType.Tool) { @@ -806,7 +808,7 @@ public abstract class Entity } else { - var hardpoint = HardpointAt(itemData, hullData, hullCoord); + hardpoint = HardpointAt(itemData, hullData, hullCoord); if (hardpoint == null) return false; item.Rotation = hardpoint.Rotation; } @@ -842,7 +844,7 @@ public abstract class Entity var itemData = ItemManager.GetData(item); var hullData = ItemManager.GetData(Hull) as HullData; - return ItemFits(itemData, hullData, item, hullCoord); + return ItemFits(itemData, hullData, item, hullCoord, out _); } public bool TryFindSpace(EquippableItem item, out int2 hullCoord) @@ -862,7 +864,7 @@ public abstract class Entity { foreach (var hullCoord2 in hullData.InteriorCells.Coordinates) { - if (ItemFits(itemData, hullData, item, hullCoord2)) + if (ItemFits(itemData, hullData, item, hullCoord2, out _)) { hullCoord = hullCoord2; return true; @@ -874,16 +876,21 @@ public abstract class Entity // Search the ship for an empty hardpoint that matches the type and shape of the item else { - // Every offset within a hardpoint of the item's type, the same offsets the fit rule tries + // Every origin the fit rule (HardpointData.TakesAt) can accept: the item, turned to the hardpoint's rotation, + // may sit as far up and left as its own extent allows, so a design whose shape lacks its origin cell is + // found wherever it fits, not only where its origin lies inside the hardpoint. foreach (var hardpoint in hullData.Hardpoints) { if(hardpoint.Type == itemData.HardpointType) { - for (var x = 0; x < hardpoint.Shape.Width; x++) - for (var y = 0; y < hardpoint.Shape.Height; y++) + var turned = hardpoint.Rotation == ItemRotation.Clockwise || hardpoint.Rotation == ItemRotation.CounterClockwise + ? int2(itemData.Shape.Height, itemData.Shape.Width) + : int2(itemData.Shape.Width, itemData.Shape.Height); + for (var x = 1 - turned.x; x < hardpoint.Shape.Width; x++) + for (var y = 1 - turned.y; y < hardpoint.Shape.Height; y++) { var hullCoord2 = hardpoint.Position + int2(x, y); - if (ItemFits(itemData, hullData, item, hullCoord2)) + if (ItemFits(itemData, hullData, item, hullCoord2, out _)) { hullCoord = hullCoord2; return true; @@ -909,7 +916,7 @@ public abstract class Entity var itemData = ItemManager.GetData(item); var hullData = ItemManager.GetData(Hull) as HullData; - if (!ItemFits(itemData, hullData, item, hullCoord)) return false; + if (!ItemFits(itemData, hullData, item, hullCoord, out var hardpoint)) return false; EquippedItem equippedItem; if (itemData.HardpointType == HardpointType.Tool) @@ -929,13 +936,13 @@ public abstract class Entity } else { - equippedItem = new EquippedItem(ItemManager, item, hullCoord, this); + equippedItem = new EquippedItem(ItemManager, item, hullCoord, this, null); Equipment.Add(equippedItem); } } else { - equippedItem = new EquippedItem(ItemManager, item, hullCoord, this); + equippedItem = new EquippedItem(ItemManager, item, hullCoord, this, hardpoint); Equipment.Add(equippedItem); } @@ -1358,6 +1365,10 @@ public class EquippedItem : IStatContext public float Wear { get; private set; } public Shape InsetShape { get; } public Entity Entity { get; } + // The one hardpoint this item occupies, decided once at equip by the placement rule (Entity.HardpointAt); + // null for Tool gear and the hull. The item's origin cell need not lie in it: Entity.Hardpoints[,] is a + // per-cell map for armour and display, never an item lookup. + public HardpointData Hardpoint { get; } public EquippableItemData Data { get; } public Lot Lot { get; } @@ -1454,11 +1465,12 @@ public class EquippedItem : IStatContext SetAudioParameter(metaObject.Id, v, true); } - public EquippedItem(ItemManager itemManager, EquippableItem item, int2 position, Entity entity) + public EquippedItem(ItemManager itemManager, EquippableItem item, int2 position, Entity entity, HardpointData hardpoint) { ItemManager = itemManager; Data = ItemManager.GetData(item); Entity = entity; + Hardpoint = hardpoint; EquippableItem = item; Lot = ItemManager.GetLot(item); Position = position; @@ -1707,7 +1719,7 @@ public class EquippedCargoBay : EquippedItem } } - public EquippedCargoBay(ItemManager itemManager, EquippableItem item, int2 position, Entity entity, string name) : base(itemManager, item, position, entity) + public EquippedCargoBay(ItemManager itemManager, EquippableItem item, int2 position, Entity entity, string name) : base(itemManager, item, position, entity, null) { Data = ItemManager.GetData(EquippableItem) as CargoBayData; Name = name; diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 06c9d3ca5..7a3586803 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -35,7 +35,7 @@ public static class FireControl // GameplaySettings.FiringArc. Zero on the hardpoint means "use the default," not "no arc." public static float ArcFor(EquippedItem item) { - var hardpoint = item.Entity.Hardpoints[item.Position.x, item.Position.y]; + var hardpoint = item.Hardpoint; var arc = hardpoint?.FiringArc ?? 0f; return arc > 0 ? arc : item.ItemManager.GameplaySettings.FiringArc; } From bba169cac5adc22295842b2aedc374c76af78193 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 15:00:00 +0200 Subject: [PATCH 090/113] Tests: an equipped item knows its hardpoint (Soul's P30 fixtures) Soul's scenarios batch-4 probe P30 lived only in a session scratchpad. Its fixtures are now tests: an L gun in an L hardpoint whose origin lies outside it, an edge gun whose origin lies in a neighbouring radiator hardpoint, TryFindSpace finding what ItemFits accepts (unrotated and turned), reload into the same hardpoint, and Tool gear holding none. Three batch-4 minors are pinned too: IsFilledBy against a cell oracle written in the test, the last-row placement search, and billing independent of heater equip order. The file is a part of RunStartTests, so it shares that fixture's scratch-hull and catalog helpers instead of a second copy of them. Co-Authored-By: Claude Opus 5.5 --- .../HardpointOwnershipTests.cs | 254 ++++++++++++++++++ tests/Aetheria.Shared.Tests/RunStartTests.cs | 2 +- 2 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs new file mode 100644 index 000000000..5e529170c --- /dev/null +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -0,0 +1,254 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; + +// An equipped item knows the one hardpoint it occupies (EquippedItem.Hardpoint), decided once by the placement rule at +// equip. Its origin cell need not lie in that hardpoint: these are Soul's scenarios batch-4 P30 fixtures, where reading +// the hardpoint from the origin cell found nothing, or found a neighbour. +public sealed partial class RunStartTests +{ + // A 2x2 L whose origin cell (0, 0) is empty. + private static Shape LShape() + { + var l = new Shape(2, 2); + l[int2(1, 0)] = true; + l[int2(0, 1)] = true; + l[int2(1, 1)] = true; + return l; + } + + // A 2x1 design whose only cell is (1, 0): its origin lies one cell left of wherever it is placed. + private static Shape EdgeShape() + { + var edge = new Shape(2, 1); + edge[int2(1, 0)] = true; + return edge; + } + + private static HardpointData Mount(HardpointType type, int x, int y, Shape shape = null, float arc = 0, + ItemRotation rotation = ItemRotation.None) => + new HardpointData { Type = type, Position = int2(x, y), Shape = shape ?? new Shape(), FiringArc = arc, Rotation = rotation }; + + private GearData ScratchGear(string name, HardpointType type, Shape shape) + { + var design = ScratchGear(name, type, 1, 1); + design.Shape = shape; + return design; + } + + private static bool InHardpoint(HardpointData hardpoint, int2 hullCell) => hardpoint.Shape[hullCell - hardpoint.Position]; + + // P30 (a): an L gun in an L hardpoint sits at origin (1, 1), a cell outside the hardpoint, yet knows its hardpoint, and + // FireControl reads that hardpoint's authored arc rather than the default. + [Fact] + public void AnLShapedGunInAnLHardpointKnowsItsHardpoint() + { + var hull = ScratchHull("P30 L Mount", 9, Mount(HardpointType.Ballistic, 1, 1, LShape(), arc: 360)); + var gun = ScratchGear("P30 L Gun", HardpointType.Ballistic, LShape()); + var entity = BareHull(hull); + Assert.True(entity.TryEquip(Instance(gun))); + var item = entity.Equipment.Single(i => i.Data == gun); + + Assert.False(InHardpoint(hull.Hardpoints[0], item.Position), $"the fixture's origin {item.Position} lies outside the hardpoint"); + Assert.Same(hull.Hardpoints[0], item.Hardpoint); + Assert.NotEqual(360f, _items.GameplaySettings.FiringArc); + Assert.Equal(360f, FireControl.ArcFor(item)); + } + + // P30 (c) shape: an edge gun whose origin cell lies in a radiator hardpoint reports its ballistic hardpoint. + [Fact] + public void AnItemWhoseOriginIsInAnotherHardpointKnowsItsOwn() + { + var radiator = Mount(HardpointType.Radiator, 0, 4); + var ballistic = Mount(HardpointType.Ballistic, 1, 4, arc: 360); + var hull = ScratchHull("P30 Neighbours", 9, radiator, ballistic); + var gun = ScratchGear("P30 Edge Gun", HardpointType.Ballistic, EdgeShape()); + var entity = BareHull(hull); + Assert.True(entity.TryEquip(Instance(gun), int2(0, 4))); + var item = entity.Equipment.Single(i => i.Data == gun); + + Assert.True(InHardpoint(radiator, item.Position), "the fixture's origin lies in the radiator hardpoint"); + Assert.Same(ballistic, item.Hardpoint); + Assert.Equal(360f, FireControl.ArcFor(item)); + } + + // P30 (b): for a design lacking its origin cell, TryFindSpace finds a placement wherever ItemFits accepts one, and the + // origin it returns passes ItemFits and equips into that hardpoint. Unrotated, and turned so the item's extent swaps + // axes (a counter-clockwise edge gun lies one cell below its origin). + [Fact] + public void TryFindSpaceFindsEveryPlacementTheFitRuleAccepts() + { + var gun = ScratchGear("P30 Edge Search Gun", HardpointType.Ballistic, EdgeShape()); + var cases = new[] + { + (hull: ScratchHull("P30 Edge Mount", 9, Mount(HardpointType.Ballistic, 0, 4)), fits: int2(-1, 4)), + (hull: ScratchHull("P30 Turned Mount", 9, Mount(HardpointType.Ballistic, 4, 0, rotation: ItemRotation.CounterClockwise)), fits: int2(4, -1)), + }; + foreach (var (hull, fits) in cases) + { + var entity = BareHull(hull); + var unit = Instance(gun); + Assert.True(entity.ItemFits(unit, fits), $"{hull.Name}: the fit rule accepts {fits}"); + Assert.True(entity.TryFindSpace(unit, out var found), $"{hull.Name}: TryFindSpace finds what ItemFits accepts"); + Assert.True(entity.ItemFits(unit, found), $"{hull.Name}: the found origin {found} passes ItemFits"); + Assert.True(entity.TryEquip(unit, found)); + Assert.Same(hull.Hardpoints[0], entity.Equipment.Single(i => i.Data == gun).Hardpoint); + } + } + + // A saved entity reloads each item into the same hardpoint, the L gun and the edge gun at a negative origin alike. + [Fact] + public void AnEquippedItemReloadsWithTheSameHardpoint() + { + var hull = ScratchHull("P30 Reload Mounts", 9, Mount(HardpointType.Ballistic, 1, 1, LShape()), Mount(HardpointType.Ballistic, 0, 6)); + var lGun = ScratchGear("P30 Reload L Gun", HardpointType.Ballistic, LShape()); + var edgeGun = ScratchGear("P30 Reload Edge Gun", HardpointType.Ballistic, EdgeShape()); + var entity = BareHull(hull); + Assert.True(entity.TryEquip(Instance(lGun), int2(1, 1))); + Assert.True(entity.TryEquip(Instance(edgeGun), int2(-1, 6))); + + var reloaded = EntitySerializer.Unpack(_items, null, EntitySerializer.Pack(entity)); + foreach (var design in new[] { lGun, edgeGun }) + { + var before = hull.Hardpoints.IndexOf(entity.Equipment.Single(i => i.Data == design).Hardpoint); + var after = reloaded.HullData.Hardpoints.IndexOf(reloaded.Equipment.Single(i => i.Data == design).Hardpoint); + Assert.True(before >= 0, $"{design.Name} holds a hardpoint"); + Assert.Equal(before, after); + } + } + + // Tool gear occupies no hardpoint, even in a hardpoint's leftover cells; the hardpoint item beside it keeps its own. + [Fact] + public void ToolGearHasNoHardpoint() + { + var shape = new Shape(2, 1); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var hull = ScratchHull("P30 Shared Mount", 9, Mount(HardpointType.Ballistic, 1, 1, shape)); + var gun = ScratchGear("P30 Small Gun", HardpointType.Ballistic, 1, 1); + var tool = ScratchGear("P30 Tool", HardpointType.Tool, 1, 1); + var entity = BareHull(hull); + Assert.True(entity.TryEquip(Instance(gun), int2(1, 1))); + Assert.True(entity.TryEquip(Instance(tool), int2(2, 1)), "tool gear in the hardpoint's leftover cell"); + + Assert.Null(entity.Equipment.Single(i => i.Data == tool).Hardpoint); + Assert.Same(hull.Hardpoints[0], entity.Equipment.Single(i => i.Data == gun).Hardpoint); + Assert.Null(entity.EquippedHull.Hardpoint); + } + + // The design's cells, turned to a rotation, moved so the lowest x and y are zero. Written here from the cells alone + // (the game's turn directions, up to translation), so it is not Shape.Rotate checking itself. + private static HashSet<(int x, int y)> Normalized(IEnumerable cells, ItemRotation rotation) + { + var turned = cells.Select(c => rotation switch + { + ItemRotation.Clockwise => (x: c.y, y: -c.x), + ItemRotation.Reversed => (x: -c.x, y: -c.y), + ItemRotation.CounterClockwise => (x: -c.y, y: c.x), + _ => (x: c.x, y: c.y) + }).ToList(); + if (turned.Count == 0) return new HashSet<(int, int)>(); + var minX = turned.Min(c => c.x); + var minY = turned.Min(c => c.y); + return turned.Select(c => (c.x - minX, c.y - minY)).ToHashSet(); + } + + // IsFilledBy against an independent oracle: a design fills a hardpoint when it is of the hardpoint's type and its + // cells, turned to the hardpoint's rotation, are exactly the hardpoint's cells. Every catalog design against every + // catalog hardpoint of its type, plus the L fixtures, whose rotations a rectangle-only catalog would not tell apart. + [Fact] + public void IsFilledByAgreesWithACellOracle() + { + var l = LShape(); + var hardpoints = _cache.GetAll().SelectMany(hull => hull.Hardpoints).ToList(); + hardpoints.AddRange(Enum.GetValues(typeof(ItemRotation)).Cast() + .Select(rotation => Mount(HardpointType.Ballistic, 0, 0, l, rotation: rotation))); + var designs = _cache.GetAll().ToList(); + designs.Add(new GearData { Name = "Oracle L", Hardpoint = HardpointType.Ballistic, Shape = l }); + designs.Add(new GearData { Name = "Oracle Edge", Hardpoint = HardpointType.Ballistic, Shape = EdgeShape() }); + + int compared = 0, filled = 0; + var mismatches = new List(); + foreach (var hardpoint in hardpoints) + foreach (var design in designs.Where(d => d.HardpointType == hardpoint.Type)) + { + var oracle = Normalized(design.Shape.Coordinates, hardpoint.Rotation).SetEquals(Normalized(hardpoint.Shape.Coordinates, ItemRotation.None)); + compared++; + if (oracle) filled++; + if (hardpoint.IsFilledBy(design) != oracle && mismatches.Count < 10) + mismatches.Add($"{design.Name} in a {hardpoint.Shape.Width}x{hardpoint.Shape.Height} {hardpoint.Type} hardpoint turned {hardpoint.Rotation}: oracle {oracle}"); + } + Assert.True(mismatches.Count == 0, string.Join("; ", mismatches)); + Assert.True(filled > 0 && filled < compared, $"the oracle separates: {filled} of {compared} fill"); + } + + // Placement searches the hardpoint's last row and column: in a 3x3 hardpoint whose other cells hold tool gear, a + // one-cell gun is placed in the bottom-right cell. + [Fact] + public void PlacementSearchesTheLastRow() + { + var mount = new Shape(3, 3); + foreach (var cell in mount.AllCoordinates) mount[cell] = true; + var hull = ScratchHull("P30 Crowded Mount", 9, Mount(HardpointType.Ballistic, 1, 1, mount)); + var gun = ScratchGear("P30 Corner Gun", HardpointType.Ballistic, 1, 1); + var tool = ScratchGear("P30 Filler", HardpointType.Tool, 1, 1); + var entity = BareHull(hull); + var last = int2(3, 3); + foreach (var cell in mount.Coordinates.Select(c => int2(1, 1) + c).Where(c => !c.Equals(last))) + Assert.True(entity.TryEquip(Instance(tool), cell), $"filler at {cell}"); + + Assert.True(entity.TryFindSpace(Instance(gun), out var found)); + Assert.Equal(last, found); + } + + // Billing does not depend on equip order: two heaters with different targets and draws, equipped in either order, + // bill the bus the same at every step of a 60 s temperature ramp that crosses both targets. + [Fact] + public void ThermostatOrderDoesNotChangeBilling() + { + GearData Heater(string name, float target, float draw) + { + var design = ScratchGear(name, HardpointType.Tool, 1, 1); + design.MinimumTemperature = 100; + design.MaximumTemperature = 500; + design.OptimalTemperature = 280; + design.PlateauWidth = 200; // online at every temperature of the ramp + design.Behaviors.Add(new ThermotoggleData { Group = 0, TargetTemperature = target }); + design.Behaviors.Add(new EnergyDrawData { Group = 0, EnergyDraw = new PerformanceStat { Min = draw, Max = draw }, PerSecond = true }); + return design; + } + var low = Heater("P30 Low Heater", 260, 10); + var high = Heater("P30 High Heater", 320, 7); + var hull = Hull("Djinni"); + var zone = Arena(new Scenario { Ambient = false }); + + List Bills(params GearData[] order) + { + var ship = BareHull(hull); + ship.Zone = zone; + foreach (var design in order) Assert.True(ship.TryEquip(Instance(design)), $"{design.Name} equips"); + zone.Admit(ship, piloted: false); + for (var tick = 0; tick < 5; tick++) zone.Update(.1f); + var bills = new List(); + for (var tick = 0; tick < 600; tick++) + { + var temperature = 220 + 140 * tick / 599f; + foreach (var v in ship.HullData.Shape.Coordinates) ship.Temperature[v.x, v.y] = temperature; + ship.PowerBus.Step(.1f); + bills.Add(ship.PowerBus.TotalDemand); + } + return bills; + } + var lowFirst = Bills(low, high); + var highFirst = Bills(high, low); + + Assert.Equal(lowFirst, highFirst); + Assert.True(lowFirst.Distinct().Count() >= 3, $"the ramp crosses both thermostats: bills {string.Join(", ", lowFirst.Distinct())}"); + } +} diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 7a83b8f89..c3ac81ff5 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -20,7 +20,7 @@ using float3 = CultMath.float3; // admission into a zone. Every test stages into a real arena: the entrance zone of a prelude galaxy at a fixed seed, // built from the shipped catalog and the authored settings (Assets/Resources/Settings.asset). The presets and // scenarios live in a scratch copy of the catalog, because the shipped one holds none. -public sealed class RunStartTests : IDisposable +public sealed partial class RunStartTests : IDisposable { private const uint GalaxySeed = 1; From 9787df6f4b784a749dc8e6d185864ba529d22918 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 15:35:51 +0200 Subject: [PATCH 091/113] Tests: a full hardpoint offers no space (Stryker survivor at TryFindSpace's last return) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 5e529170c..1d592bcea 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -189,7 +189,7 @@ public sealed partial class RunStartTests } // Placement searches the hardpoint's last row and column: in a 3x3 hardpoint whose other cells hold tool gear, a - // one-cell gun is placed in the bottom-right cell. + // one-cell gun is placed in the bottom-right cell, and once it is, the search finds no space. [Fact] public void PlacementSearchesTheLastRow() { @@ -203,8 +203,11 @@ public sealed partial class RunStartTests foreach (var cell in mount.Coordinates.Select(c => int2(1, 1) + c).Where(c => !c.Equals(last))) Assert.True(entity.TryEquip(Instance(tool), cell), $"filler at {cell}"); - Assert.True(entity.TryFindSpace(Instance(gun), out var found)); + var corner = Instance(gun); + Assert.True(entity.TryFindSpace(corner, out var found)); Assert.Equal(last, found); + Assert.True(entity.TryEquip(corner, found)); + Assert.False(entity.TryFindSpace(Instance(gun), out _), "a full hardpoint offers no space"); } // Billing does not depend on equip order: two heaters with different targets and draws, equipped in either order, From fc73d7abcd92928718547f8c0256ea8a431f299e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 15:37:33 +0200 Subject: [PATCH 092/113] Ship render: the hull collider carries the invisible material, so clones draw nothing forceRenderingOff is not serialized, so Instantiate and SaveAsPrefabAsset dropped it and every cloned mod ship (and every FBX-built prefab) drew its collision hull. The collider renderer now takes the template's invisible material, as the thruster emitter does. The preview smoke checks the collider on the clone and the prefab smoke checks it on the configured prefab. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/ShipModPreview.cs | 3 +++ Assets/Scripts/Editor/ShipPrefabAuthoring.cs | 3 ++- Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs | 2 ++ Assets/Scripts/Gameplay/ShipModShips.cs | 10 ++++++++-- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/Editor/ShipModPreview.cs b/Assets/Scripts/Editor/ShipModPreview.cs index 64f9ea6b7..677253561 100644 --- a/Assets/Scripts/Editor/ShipModPreview.cs +++ b/Assets/Scripts/Editor/ShipModPreview.cs @@ -63,6 +63,9 @@ public static class ShipModPreview bool Inside(Component part) => part != null && part.transform.IsChildOf(ship.transform); Require(ship.HullColliders.Length == 1 && Inside(ship.HullColliders[0]) && ship.HullColliders[0].GetComponent() != null, "one hull collider with a MeshCollider"); + Require(ship.HullColliders[0].GetComponentsInChildren(true).All(renderer => + renderer.sharedMaterials.All(material => material == ship.InvisibleMaterial)), + "a hull collider that draws nothing"); Require(Inside(ship.MapIcon) && Inside(ship.Shield) && Inside(ship.TractorBeam), "map icon, shield and tractor inside the ship"); Require(ship.ThrusterHardpoints.Select(node => node.name).SequenceEqual(plan.Thrusters) && ship.ThrusterHardpoints.All(node => Inside(node) && Inside(node.Emitter) && diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs index 8a419c6e0..5d5b7352c 100644 --- a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs +++ b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs @@ -160,7 +160,8 @@ public static class ShipPrefabAuthoring mapIcon.gameObject.layer = minimapLayer; hull.gameObject.layer = combatLayer; var hullRenderer = hull.GetComponent(); - if (hullRenderer != null) hullRenderer.forceRenderingOff = true; + // Serialized, so it survives SaveAsPrefabAsset and every clone; forceRenderingOff survives neither. + if (hullRenderer != null) ShipModShips.Hide(hullRenderer, invisible); var collider = AddOrGet(hull.gameObject); collider.sharedMesh = hullMesh; collider.convex = true; diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs index 34f5abf83..9f740f557 100644 --- a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs +++ b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs @@ -33,6 +33,8 @@ public static class ShipPrefabAuthoringSmoke Require(ship.RadiatorHardpoints.Length == 1 && ship.RadiatorHardpoints[0].Mesh != null, "radiator mesh"); Require(ship.ArticulationPoints.Length == 1 && ship.ArticulationPoints[0].Group == 2, "articulation"); Require(ship.HullColliders.Length == 1 && ship.HullColliders[0].GetComponent().convex, "hull collision"); + Require(Array.TrueForAll(ship.HullColliders[0].GetComponent().sharedMaterials, + material => material == ship.InvisibleMaterial), "hull collider drawn with the invisible material only"); Require(ship.Shield != null && ship.Shield.GetComponent() != null, "shield envelope"); Require(ship.TractorBeam != null && ship.PingPrefab != null && ship.DestroyEffect != null, "shared assets"); Require(ship.MapIcon != null && ship.MapIcon.gameObject.layer == LayerMask.NameToLayer("Minimap"), "map icon"); diff --git a/Assets/Scripts/Gameplay/ShipModShips.cs b/Assets/Scripts/Gameplay/ShipModShips.cs index 354a1e4a6..7bc64bd37 100644 --- a/Assets/Scripts/Gameplay/ShipModShips.cs +++ b/Assets/Scripts/Gameplay/ShipModShips.cs @@ -103,8 +103,10 @@ public static class ShipModShips if (!hullMesh.isReadable) throw new InvalidOperationException($"{ship.Id}: the hull-collider mesh imported unreadable, so it cannot back a MeshCollider."); hullNode.gameObject.layer = combat; + // The collision hull is never drawn. Its renderer takes the invisible material, which is serialized and so survives + // the prototype's clone; forceRenderingOff would not survive Instantiate. var hullRenderer = hullNode.GetComponent(); - if (hullRenderer != null) hullRenderer.forceRenderingOff = true; + if (hullRenderer != null) Hide(hullRenderer, template.Invisible); var collider = AddOrGet(hullNode.gameObject); collider.sharedMesh = hullMesh; collider.convex = true; @@ -127,7 +129,7 @@ public static class ShipModShips // this renderer's visible material; forceRenderingOff would not survive Instantiate. var hardpoint = AddOrGet(Node(id).gameObject); hardpoint.Emitter = Renderer(id); - hardpoint.Emitter.sharedMaterials = Enumerable.Repeat(template.Invisible, hardpoint.Emitter.sharedMaterials.Length).ToArray(); + Hide(hardpoint.Emitter, template.Invisible); return hardpoint; }).ToArray(); var radiators = plan.Radiators.Select(id => @@ -162,6 +164,10 @@ public static class ShipModShips return instance; } + // Every material slot takes the invisible material, so the renderer draws nothing on the prototype and on every clone. + public static void Hide(Renderer renderer, Material invisible) => + renderer.sharedMaterials = Enumerable.Repeat(invisible, renderer.sharedMaterials.Length).ToArray(); + private static void Reset(Transform transform) { transform.localPosition = Vector3.zero; From 6709b06e2efbea3995313b635e1755060cdadf00 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 15:38:35 +0200 Subject: [PATCH 093/113] Ship preload: a mod ship that fails to load or assemble is skipped, not fatal PreloadAsync rethrew on the first failing hull, so one bad package faulted Loading and no mod ship could spawn. Each hull now loads in its own try: a failure destroys its partial visual, is logged with the hull's name, and the rest still preload, as Compose skips a bad package. The play smoke installs the package beside a broken sibling that composes but cannot assemble (its map icon moved onto a meshless muzzle node) and proves the sibling has no prototype while the good ship spawns. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/ShipModPlaySmoke.cs | 68 ++++++++++++++++++++--- Assets/Scripts/Gameplay/ShipModShips.cs | 15 +++-- Assets/Scripts/UI/MainMenu.cs | 2 +- 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/Editor/ShipModPlaySmoke.cs b/Assets/Scripts/Editor/ShipModPlaySmoke.cs index a4fb708b5..f0753e4e6 100644 --- a/Assets/Scripts/Editor/ShipModPlaySmoke.cs +++ b/Assets/Scripts/Editor/ShipModPlaySmoke.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using GameCult.Caching; using UnityEditor; using UnityEngine; @@ -9,6 +10,10 @@ using UnityEngine; // Body() calls for a hull with a Visual. A plain parent transform stands in for ZoneRoot; nothing else in LoadEntity // touches the body before it takes its ShipInstance. // +// The package is installed beside a broken sibling: the same ship under another id, with its map-icon anchor moved onto +// its first muzzle's meshless node. The sibling composes, because no compose-time check reads meshes, and then fails +// assembly. The smoke proves the preload skips it and the good package still spawns. +// // Unity -batchmode -nographics -projectPath -executeMethod ShipModPlaySmoke.Run // -shipModPath //ship.cc [-shippedCatalog GameData/Aetheria.cc] -logFile // @@ -24,22 +29,32 @@ public static class ShipModPlaySmoke return at >= 0 && at + 1 < args.Length ? args[at + 1] : fallback; } var packagePath = Option("-shipModPath"); - var derived = Path.Combine(Path.GetTempPath(), $"ship-mod-play-smoke-{Guid.NewGuid():N}.cc"); + var work = Path.Combine(Path.GetTempPath(), $"ship-mod-play-smoke-{Guid.NewGuid():N}"); + var derived = Path.Combine(work, "Aetheria.modded.cc"); + var modsRoot = Path.Combine(work, "Mods"); GameObject zone = null; try { if (packagePath == null) throw new InvalidOperationException("-shipModPath is required"); var package = ShipModCatalog.ReadPackage(packagePath); - var modsRoot = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(packagePath))); + var broken = InstallWithBrokenSibling(packagePath, package, modsRoot); var composition = ShipModCatalog.Compose(Path.GetFullPath(Option("-shippedCatalog", "GameData/Aetheria.cc")), derived, modsRoot); - if (!composition.Included.Contains(package.Visual.Id)) - throw new InvalidOperationException($"{package.Visual.Id} was not composed into the catalog: " + - string.Join("; ", composition.Excluded.Select(excluded => $"{excluded.Package}: {excluded.Reason}"))); + foreach (var id in new[] { package.Visual.Id, broken.Visual.Id }) + if (!composition.Included.Contains(id)) + throw new InvalidOperationException($"{id} was not composed into the catalog: " + + string.Join("; ", composition.Excluded.Select(excluded => $"{excluded.Package}: {excluded.Reason}"))); using (var catalog = AetheriaStores.Open(derived)) { await ShipModShips.Preload(catalog, modsRoot); - var hull = catalog.GetAll().Single(candidate => candidate.Visual.IsSet() && candidate.Name == package.Hull.Name); + HullData Hull(string name) => catalog.GetAll().Single(candidate => candidate.Visual.IsSet() && candidate.Name == name); + var hull = Hull(package.Hull.Name); + + // The broken sibling has no prototype, and Loading did not fault for it (await would have thrown). + var refused = false; + try { ShipModShips.Instantiate(Hull(broken.Hull.Name), null); } + catch (InvalidOperationException error) { refused = error.Message.Contains("no mod ship prototype"); } + if (!refused) throw new InvalidOperationException($"{broken.Visual.Id} assembled, or was refused for the wrong reason."); zone = new GameObject("Play Smoke Zone"); var ship = ShipModShips.Instantiate(hull, zone.transform).GetComponent(); @@ -56,16 +71,51 @@ public static class ShipModPlaySmoke throw new InvalidOperationException("The hull collider is not a MeshCollider over a readable mesh."); if (ship.MapIcon == null || ship.MapIcon.sharedMaterial == null || ship.MapIcon.gameObject.layer != LayerMask.NameToLayer("Minimap")) throw new InvalidOperationException("The map icon is missing its renderer, material or Minimap layer."); - Debug.Log($"SHIP_MOD_PLAY_SMOKE thrusters={thrusters} weapons={weapons} collider=ok mapicon=ok"); + Debug.Log($"SHIP_MOD_PLAY_SMOKE thrusters={thrusters} weapons={weapons} collider=ok mapicon=ok skipped={broken.Visual.Id}"); } - if (zone != null) UnityEngine.Object.DestroyImmediate(zone); + Clean(zone, work); EditorApplication.Exit(0); } catch (Exception exception) { Debug.LogException(exception); - if (zone != null) UnityEngine.Object.DestroyImmediate(zone); + Clean(zone, work); EditorApplication.Exit(1); } } + + // Copies the package into modsRoot and writes its broken sibling beside it. Returns the sibling's records. + private static (HullData Hull, ShipAuthoring Visual) InstallWithBrokenSibling(string packagePath, ShipModCatalog.Package package, string modsRoot) + { + void Install(string id, string shipFile) + { + var model = Path.Combine(modsRoot, id, package.Visual.ModelAsset); + Directory.CreateDirectory(Path.GetDirectoryName(model)); + File.Copy(package.ModelPath, model); + if (shipFile != null) File.Copy(shipFile, Path.Combine(modsRoot, id, "ship.cc")); + } + Install(package.Visual.Id, Path.GetFullPath(packagePath)); + + var (hull, ship) = ShipAuthoringStore.Read(Path.Combine(modsRoot, package.Visual.Id, "ship.cc")); + ship.Id += ".broken"; + hull.Name += " Broken"; + hull.Visual = new CultRecordRef(ShipModCatalog.AuthoringKey(ship.Id)); + var mapIcon = ship.Anchors.Single(anchor => anchor.Role == "map-icon"); + var muzzle = ship.Anchors.First(anchor => anchor.Role == "weapon-muzzle"); + (mapIcon.ModelNodeId, muzzle.ModelNodeId) = (muzzle.ModelNodeId, mapIcon.ModelNodeId); + Install(ship.Id, null); + using (var cache = ShipAuthoringStore.Open(Path.Combine(modsRoot, ship.Id, "ship.cc"), writable: true)) + { + ShipAuthoringStore.Write(cache, hull, ship); + cache.FlushAsync().GetAwaiter().GetResult(); + } + return (hull, ship); + } + + private static void Clean(GameObject zone, string work) + { + if (zone != null) UnityEngine.Object.DestroyImmediate(zone); + try { if (Directory.Exists(work)) Directory.Delete(work, true); } + catch (Exception error) { Debug.LogWarning($"Play smoke left {work}: {error.Message}"); } + } } diff --git a/Assets/Scripts/Gameplay/ShipModShips.cs b/Assets/Scripts/Gameplay/ShipModShips.cs index 7bc64bd37..94177e0a5 100644 --- a/Assets/Scripts/Gameplay/ShipModShips.cs +++ b/Assets/Scripts/Gameplay/ShipModShips.cs @@ -14,7 +14,9 @@ public static class ShipModShips private static readonly Dictionary Prototypes = new Dictionary(); private static CultCache _catalog; - // Set by Preload; complete when every prototype is ready, faulted when any mod ship failed to import or assemble. + // Set by Preload; complete when every hull that could be built has its prototype. A mod ship that fails to import or + // assemble is skipped and logged, as Compose skips a bad package, so it never stops the rest; Loading faults only + // when the preload itself cannot run. public static Task Loading { get; private set; } = Task.CompletedTask; public static Task Preload(CultCache catalog, string modsRoot) @@ -39,18 +41,19 @@ public static class ShipModShips if (Application.isPlaying) Object.DontDestroyOnLoad(root); foreach (var hull in hulls) { - var package = ShipModCatalog.PackageOf(catalog, hull, modsRoot); - var visual = await ShipModVisual.LoadAsync(package, root.transform); + ShipModVisual.Instance visual = null; try { + var package = ShipModCatalog.PackageOf(catalog, hull, modsRoot); + visual = await ShipModVisual.LoadAsync(package, root.transform); visual.Root.SetActive(false); Assemble(visual, package.Hull, package.Visual, template); Prototypes[catalog.RefOf(hull).Key] = visual.Root; } - catch + catch (Exception error) { - visual.Destroy(); - throw; + visual?.Destroy(); + Debug.LogError($"Mod ship {hull.Name} skipped: {error.Message}"); } } } diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 9d17af138..859d17c95 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -193,7 +193,7 @@ public class MainMenu : MonoBehaviour } // The one way into the game scene. Mod ship prototypes import asynchronously at boot, so the scene waits for them, - // and a failed import stops here with its reason rather than in the first zone. + // and a preload that could not run at all stops here with its reason rather than in the first zone. private void EnterGame() => StartCoroutine(EnterGameWhenModShipsAreReady()); private IEnumerator EnterGameWhenModShipsAreReady() From 239f3599904c0478b4ba35c82d8629705e788f30 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 15:39:14 +0200 Subject: [PATCH 094/113] Ship exhaust: wiring an exhaust to an emitter turns Use Mesh Colors off The emitter carries the invisible material, whose alpha is 0, so an exhaust prefab left at Unity's default (Use Mesh Colors on) emitted nothing. The shipped thruster prefabs only worked because theirs is off. ThrusterHardpoint .WireExhaust now owns the wiring and turns it off; ShipInstance calls it. The preview smoke wires a default particle system to each emitter on the clone and requires every particle emitted and none transparent. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/ShipModPreview.cs | 40 +++++++++++++++++++- Assets/Scripts/Gameplay/ShipInstance.cs | 3 +- Assets/Scripts/Gameplay/ThrusterHardpoint.cs | 14 +++++-- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/Editor/ShipModPreview.cs b/Assets/Scripts/Editor/ShipModPreview.cs index 677253561..9ed4fcec4 100644 --- a/Assets/Scripts/Editor/ShipModPreview.cs +++ b/Assets/Scripts/Editor/ShipModPreview.cs @@ -51,9 +51,9 @@ public static class ShipModPreview // Instantiate clones the assembled root, and Unity must remap every array reference into the clone. var clone = UnityEngine.Object.Instantiate(ship.gameObject).GetComponent(); - try { CheckShip(clone, plan, "clone"); } + try { CheckShip(clone, plan, "clone"); CheckExhaust(clone); } finally { UnityEngine.Object.DestroyImmediate(clone.gameObject); } - Debug.Log($"SHIP_MOD_ASSEMBLY_SMOKE thrusters={plan.Thrusters.Length} " + + Debug.Log($"SHIP_MOD_ASSEMBLY_SMOKE thrusters={plan.Thrusters.Length} exhaust=ok collider=hidden " + $"weapons={plan.Weapons.Length} radiators={plan.Radiators.Length}"); } @@ -79,6 +79,42 @@ public static class ShipModPreview node.FiringPoint.All(Inside)).All(ok => ok), "weapon hardpoints with their muzzles in order"); } + // Exhaust from each hidden emitter, wired as ShipInstance wires it, from a particle system left at Unity's defaults + // (Use Mesh Colors on). The emitter's invisible material has alpha 0, so mesh colours would make every particle + // transparent; the wiring must turn them off. + private static void CheckExhaust(ShipInstance ship) + { + const int count = 200; + foreach (var hardpoint in ship.ThrusterHardpoints) + { + var exhaust = new GameObject("Smoke Exhaust").AddComponent(); + try + { + exhaust.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); + var main = exhaust.main; + main.playOnAwake = false; + main.startSpeed = 0; + main.startLifetime = 10; + main.maxParticles = count * 2; + var emission = exhaust.emission; + emission.enabled = false; + var shape = exhaust.shape; + shape.enabled = true; + shape.shapeType = ParticleSystemShapeType.MeshRenderer; + if (!shape.useMeshColors) throw new InvalidOperationException("Unity's default Use Mesh Colors is off; the exhaust check proves nothing."); + ThrusterHardpoint.WireExhaust(exhaust, hardpoint); + exhaust.Emit(count); + if (exhaust.particleCount != count) + throw new InvalidOperationException($"clone: exhaust from {hardpoint.name} emitted {exhaust.particleCount} of {count} particles."); + var alive = new ParticleSystem.Particle[count]; + exhaust.GetParticles(alive); + if (alive.Any(particle => particle.GetCurrentColor(exhaust).a == 0)) + throw new InvalidOperationException($"clone: exhaust from {hardpoint.name} has transparent particles."); + } + finally { UnityEngine.Object.DestroyImmediate(exhaust.gameObject); } + } + } + [MenuItem("Aetheria/Preview Mod Ship Package")] private static async void Open() { diff --git a/Assets/Scripts/Gameplay/ShipInstance.cs b/Assets/Scripts/Gameplay/ShipInstance.cs index 8a0b22b6e..8b2540974 100644 --- a/Assets/Scripts/Gameplay/ShipInstance.cs +++ b/Assets/Scripts/Gameplay/ShipInstance.cs @@ -65,10 +65,9 @@ public class ShipInstance : EntityInstance { var effectData = (ThrusterData) thruster.Data; var particles = Instantiate(EngineAssets.Load(effectData.ParticlesPrefab), transform, false); - var particlesShape = particles.shape; var thrusterHardpoint = ThrusterHardpoints .FirstOrDefault(t => t.name == ship.Hardpoints[thruster.Item.Position.x, thruster.Item.Position.y].Transform); - particlesShape.meshRenderer = thrusterHardpoint?.Emitter; + ThrusterHardpoint.WireExhaust(particles, thrusterHardpoint); // if (!string.IsNullOrEmpty(thruster.Item.Data.SoundEffectTrigger) && thrusterHardpoint != null) // { // AkSoundEngine.RegisterGameObj(thrusterHardpoint.gameObject); diff --git a/Assets/Scripts/Gameplay/ThrusterHardpoint.cs b/Assets/Scripts/Gameplay/ThrusterHardpoint.cs index d3c3dbb98..b0b1d0f26 100644 --- a/Assets/Scripts/Gameplay/ThrusterHardpoint.cs +++ b/Assets/Scripts/Gameplay/ThrusterHardpoint.cs @@ -1,8 +1,16 @@ -using System.Collections; -using System.Collections.Generic; -using UnityEngine; +using UnityEngine; public class ThrusterHardpoint : MonoBehaviour { public MeshRenderer Emitter; + + // Points an exhaust's shape at a hardpoint's emitter, or at nothing when the ship has no such hardpoint. The emitter + // carries the invisible material (alpha 0), so the particles never take their colour from its mesh: with Use Mesh + // Colors on, Unity's default, they would all be born transparent. + public static void WireExhaust(ParticleSystem exhaust, ThrusterHardpoint hardpoint) + { + var shape = exhaust.shape; + shape.useMeshColors = false; + shape.meshRenderer = hardpoint != null ? hardpoint.Emitter : null; + } } From ed15d34b35c340a4237ae14258f9780067061c37 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 16:14:10 +0200 Subject: [PATCH 095/113] Tests: generated loadouts give every reader its hardpoint (Soul's headless probe) Soul's scenarios-adopt probe S4 as a committed test: on generated ships, stations and turrets every weapon, thruster and radiator holds a non-null Hardpoint containing all its cells, and Tool gear and the hull hold none. It pins the input the muzzle, arc, thruster-emitter and radiator readers dereference (finding headless-reader-input). Co-Authored-By: Claude Opus 5.5 --- .../HardpointOwnershipTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 1d592bcea..383425c60 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -254,4 +254,38 @@ public sealed partial class RunStartTests Assert.Equal(lowFirst, highFirst); Assert.True(lowFirst.Distinct().Count() >= 3, $"the ramp crosses both thermostats: bills {string.Join(", ", lowFirst.Distinct())}"); } + + // The readers that bind muzzles, arcs, thruster emitters and radiators dereference item.Hardpoint: on generated + // ships, stations and turrets every weapon, thruster and radiator holds a hardpoint containing all its cells, and + // Tool gear and the hull hold none (Soul's scenarios-adopt probe S4, finding headless-reader-input). + [Fact] + public void GeneratedLoadoutsGiveEveryReaderItsHardpoint() + { + var sold = _cache.GetAll().Select(p => p.Design.Key).ToHashSet(); + var shipHulls = _cache.GetAll() + .Where(h => h.HullType == HullType.Ship && sold.Contains(_cache.RefOf(h).Key)).Select(h => h.Name).ToList(); + var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); + var packs = shipHulls.SelectMany(name => Enumerable.Range(0, 2).Select(_ => generator.GenerateShipLoadout(h => h.Name == name))) + .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateStationLoadout())) + .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateTurretLoadout())) + .ToList(); + var readers = 0; + foreach (var pack in packs) + { + var entity = EntitySerializer.Unpack(_items, null, pack); + Assert.Null(entity.EquippedHull.Hardpoint); + foreach (var item in entity.Equipment.Where(i => i != entity.EquippedHull)) + { + var label = $"{entity.HullData.Name}/{item.Data.Name}"; + if (item.Data.HardpointType == HardpointType.Tool) Assert.True(item.Hardpoint == null, $"{label}: Tool gear holds no hardpoint"); + if (!item.Behaviors.Any(b => b is Weapon || b is Thruster || b is Radiator)) continue; + readers++; + var hp = item.Hardpoint; + Assert.True(hp != null, $"{label}: a reader item holds a hardpoint"); + var cells = item.Data.Shape.Coordinates.Select(c => item.Position + item.Data.Shape.Rotate(c, item.EquippableItem.Rotation)); + Assert.True(cells.All(c => hp.Shape[c - hp.Position]), $"{label}: its hardpoint contains every cell"); + } + } + Assert.True(readers > 0, "the generated loadouts carry weapons, thrusters or radiators"); + } } From f5743cbad2c7200e1e741fc2f8ba16dabd4d76b3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 17:07:08 +0200 Subject: [PATCH 096/113] Ship packages: a thruster or radiator anchor on a mesh-less GLB node is refused at bind ReadNodeIds is the one GLB parse and now records, per aetheria.id node, whether it carries a mesh. Bind refuses a thruster-emitter or radiator-mesh anchor whose node has none, naming the ship and anchor (ruling thrusters-radiators-are-meshes), so Compose and validate refuse such a package before the catalog sees it. AetherDb 'ship-authoring validate' judges the GLB when it exists (next commit). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ShipModCatalog.cs | 20 ++++-- tests/Aetheria.Shared.Tests/ShipFixture.cs | 13 ++-- .../ShipModCatalogTests.cs | 70 +++++++++++++++++-- 3 files changed, 88 insertions(+), 15 deletions(-) diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index ee80add89..38216f481 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -177,12 +177,22 @@ public static class ShipModCatalog foreach (var anchor in ship.Anchors) if (!modelNodes.ContainsKey(anchor.ModelNodeId)) throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}."); + // A thruster's mesh is its exhaust emission surface and a radiator's mesh is what glows (ruling + // thrusters-radiators-are-meshes), so either on an empty node is refused here, before any catalog sees the ship. + foreach (var anchor in ship.Anchors) + if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh") && !modelNodes[anchor.ModelNodeId].HasMesh) + throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} needs a mesh, but model node {anchor.ModelNodeId} has none."); CultRecordRefs.Validate(hull); - return new Package { Hull = hull, Visual = ship, ModelPath = modelPath, NodeIndices = modelNodes }; + return new Package + { + Hull = hull, Visual = ship, ModelPath = modelPath, + NodeIndices = modelNodes.ToDictionary(node => node.Key, node => node.Value.Index, StringComparer.Ordinal) + }; } - // GLB is the xenos asset boundary. Only its node extras are read here; geometry belongs to the runtime importer. - public static Dictionary ReadNodeIds(string path) + // GLB is the xenos asset boundary. Only its node extras, and whether each node names a mesh, are read here; geometry + // belongs to the runtime importer. + public static Dictionary ReadNodeIds(string path) { using var stream = File.OpenRead(path); using var reader = new BinaryReader(stream); @@ -193,14 +203,14 @@ public static class ShipModCatalog if (reader.ReadUInt32() != 0x4E4F534A || chunkLength > stream.Length - stream.Position) throw new InvalidOperationException($"{path}: missing GLB JSON chunk."); var json = JObject.Parse(Encoding.UTF8.GetString(reader.ReadBytes((int)chunkLength))); - var ids = new Dictionary(StringComparer.Ordinal); + var ids = new Dictionary(StringComparer.Ordinal); var nodes = json["nodes"]?.OfType() ?? Enumerable.Empty(); uint index = 0; foreach (var node in nodes) { var id = (string)node["extras"]?["aetheria.id"]; if (string.IsNullOrEmpty(id)) { index++; continue; } - if (!ids.TryAdd(id, index)) + if (!ids.TryAdd(id, (index, node["mesh"] != null && node["mesh"].Type != JTokenType.Null))) throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}."); index++; } diff --git a/tests/Aetheria.Shared.Tests/ShipFixture.cs b/tests/Aetheria.Shared.Tests/ShipFixture.cs index 2e8d4994e..9f2e1c75f 100644 --- a/tests/Aetheria.Shared.Tests/ShipFixture.cs +++ b/tests/Aetheria.Shared.Tests/ShipFixture.cs @@ -25,18 +25,19 @@ internal static class ShipFixture public static byte[] MeshBytes() => Tetrahedron.SelectMany(BitConverter.GetBytes).ToArray(); // The GLB carries a glTF scene naming every node: glTFast instantiates a scene, and a scene-less file has none. - // Nodes in MeshNodes get the shared tetrahedron mesh, whose vertices live in the BIN chunk (MeshBytes). - public static string GlbJson(string[] nodes) + // Nodes in meshNodes (default MeshNodes) get the shared tetrahedron mesh, whose vertices live in the BIN chunk (MeshBytes). + public static string GlbJson(string[] nodes, string[] meshNodes = null) { + meshNodes ??= MeshNodes; var length = MeshBytes().Length; - var mesh = nodes.Any(MeshNodes.Contains) + var mesh = nodes.Any(meshNodes.Contains) ? @",""meshes"":[{""primitives"":[{""attributes"":{""POSITION"":0}}]}]," + @"""accessors"":[{""bufferView"":0,""componentType"":5126,""count"":12,""type"":""VEC3"",""min"":[0,0,0],""max"":[1,1,1]}]," + $@"""bufferViews"":[{{""buffer"":0,""byteLength"":{length}}}],""buffers"":[{{""byteLength"":{length}}}]" : ""; return @"{""asset"":{""version"":""2.0""},""scene"":0,""scenes"":[{""nodes"":[" + string.Join(",", nodes.Select((_, index) => index)) + @"]}]" + mesh + @",""nodes"":[" + string.Join(",", nodes.Select(node => - $@"{{""name"":""{node}""{(MeshNodes.Contains(node) ? @",""mesh"":0" : "")},""extras"":{{""aetheria.id"":""{node}""}}}}")) + "]}"; + $@"{{""name"":""{node}""{(meshNodes.Contains(node) ? @",""mesh"":0" : "")},""extras"":{{""aetheria.id"":""{node}""}}}}")) + "]}"; } public static byte[] Glb(string json, byte[] bin = null) @@ -63,7 +64,7 @@ internal static class ShipFixture // //ship.cc plus the GLB at the record's ModelAsset path. public static string WritePackage(string modsRoot, string id, string hullName = "Skiff", string[] nodes = null, - string modelAsset = "skiff.glb", Action tweak = null) + string modelAsset = "skiff.glb", Action tweak = null, string[] meshNodes = null) { var ship = ShipAuthoringTests.Fixture(); // A playable rig: the fixture thruster plus one energy weapon mount on the hull's other cell, with its muzzle. @@ -81,7 +82,7 @@ internal static class ShipFixture ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual); cache.FlushAsync().Wait(); } - File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(GlbJson(nodes ?? Nodes), MeshBytes())); + File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(GlbJson(nodes ?? Nodes, meshNodes), MeshBytes())); return directory; } } diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 82cca60f5..a2426906b 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -370,12 +370,71 @@ public sealed class ShipModCatalogTests : IDisposable ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); } + // Ruling thrusters-radiators-are-meshes: a thruster's mesh is its emission surface and a radiator's is what glows, so + // either on an empty node is refused before compose; a weapon mount (and shield, tractor) may be an empty. + [Fact] + public void AMeshRoleAnchorOnAnEmptyNodeIsRefused() + { + var withoutThrusterMesh = ShipFixture.MeshNodes.Where(node => node != "thruster-port").ToArray(); + WritePackage("mod.skiff", meshNodes: withoutThrusterMesh); + Assert.Contains("thruster-emitter anchor thruster.port needs a mesh, but model node thruster-port has none", Assert.Throws(() => + ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); + + Directory.Delete(Path.Combine(Mods, "mod.skiff"), true); + void AsRadiator(ShipParts ship) + { + ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "thruster.port").Type = HardpointType.Radiator; + ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Role = "radiator-mesh"; + } + WritePackage("mod.skiff", tweak: AsRadiator, meshNodes: withoutThrusterMesh); + Assert.Contains("radiator-mesh anchor thruster.port needs a mesh", Assert.Throws(() => + ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); + + // The same radiator on a mesh node, and the weapon mount, shield and tractor on empty nodes, are accepted. + Directory.Delete(Path.Combine(Mods, "mod.skiff"), true); + WritePackage("mod.skiff", tweak: AsRadiator); + var nodes = ShipModCatalog.ReadNodeIds(Path.Combine(Mods, "mod.skiff", "skiff.glb")); + Assert.False(nodes["gun"].HasMesh || nodes["shield"].HasMesh || nodes["tractor"].HasMesh); + Assert.Equal("mod.skiff", ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc")).Visual.Id); + } + + // AetherDb's validate judges the GLB once it exists, so Blender's Package sees what Compose would refuse. + [Fact] + public void ValidateCommandReadsTheGlbWhenPresent() + { + WritePackage("mod.skiff", nodes: FixtureNodes.Where(node => node != "thruster-port").ToArray()); + var path = Path.Combine(Mods, "mod.skiff", "ship.cc"); + var (code, output, error) = Run("validate", path); + Assert.Equal(1, code); + Assert.Contains("model has no node with aetheria.id=thruster-port for anchor thruster.port", error); + + File.Delete(Path.Combine(Mods, "mod.skiff", "skiff.glb")); + (code, output, error) = Run("validate", path); + Assert.Equal(0, code); + Assert.Contains("records only", output); + + WritePackage("mod.good"); + (code, output, _) = Run("validate", Path.Combine(Mods, "mod.good", "ship.cc")); + Assert.Equal(0, code); + Assert.Contains("records and model", output); + } + + private static (int Code, string Output, string Error) Run(params string[] args) + { + var (output, error) = (new StringWriter(), new StringWriter()); + var (previousOut, previousError) = (Console.Out, Console.Error); + Console.SetOut(output); + Console.SetError(error); + try { return (ShipAuthoringCommands.Run(args), output.ToString(), error.ToString()); } + finally { Console.SetOut(previousOut); Console.SetError(previousError); } + } + [Fact] public void NodeIdsMapToTheirGlbNodeIndexSkippingNodesWithoutOne() { var path = Write("nodes.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[ - {""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""extras"":{""aetheria.id"":""b""}}]}")); - Assert.Equal(new Dictionary { ["a"] = 1, ["b"] = 4 }, ShipModCatalog.ReadNodeIds(path)); + {""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""mesh"":0,""extras"":{""aetheria.id"":""b""}}]}")); + Assert.Equal(new Dictionary { ["a"] = (1, false), ["b"] = (4, true) }, ShipModCatalog.ReadNodeIds(path)); } [Fact] @@ -414,7 +473,7 @@ public sealed class ShipModCatalogTests : IDisposable BitConverter.GetBytes((uint)json.Length).CopyTo(bytes, 12); BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16); json.CopyTo(bytes, 20); - Assert.Equal(new Dictionary { ["a"] = 0 }, ShipModCatalog.ReadNodeIds(Write("full.glb", bytes))); + Assert.Equal(new Dictionary { ["a"] = 0 }, Indices(ShipModCatalog.ReadNodeIds(Write("full.glb", bytes)))); } // A run store references a mod hull by its key, so the same packages must derive the same records on every launch, @@ -537,7 +596,10 @@ public sealed class ShipModCatalogTests : IDisposable } private void WritePackage(string id, string hullName = "Skiff", string[] nodes = null, string modelAsset = "skiff.glb", - Action tweak = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak); + Action tweak = null, string[] meshNodes = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak, meshNodes); + + private static Dictionary Indices(Dictionary nodes) => + nodes.ToDictionary(node => node.Key, node => node.Value.Index); private static int Occurrences(byte[] haystack, byte[] needle) { From f98be434f0b874298276cbe9b9c7b11cb74b594c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 17:07:09 +0200 Subject: [PATCH 097/113] AetherDb ship-authoring: create --like a shipped hull, and validate reads the GLB when present ShipAuthoringStore.HullLike copies a reference hull through its MessagePack round trip and resets Name, Shape, Hardpoints, Prefab, Schematic and Visual. 'create ... --like ' opens the shipped catalog read-only and refuses an unknown or non-ship hull. 'validate' runs ReadPackage when ModelAsset names an existing file and says which it judged. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 17 +++++++ .../ShipAuthoringTests.cs | 26 +++++++++++ tools/AetherDb/ShipAuthoringCommands.cs | 44 ++++++++++++++----- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index 837bcefc2..ec5f2a3c2 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -88,6 +88,23 @@ public static class ShipAuthoringStore cache.UpsertAsync(typeof(ShipAuthoring), visual, ShipModCatalog.AuthoringKey(visual.Id)).GetAwaiter().GetResult(); } + // A new mod hull that flies like a shipped one. Every member of the reference is copied through the type's own + // MessagePack round trip, so a member added to HullData later is copied too; then the six that are the shipped ship's + // own body and layout are reset: Name, Shape (one occupied cell, so the record loads), Hardpoints, Prefab, Schematic, + // and Visual, which names this mod's visual record. + public static HullData HullLike(HullData reference, string name, string shipId) + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var hull = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(reference, options), options); + hull.Name = name; + hull.Shape = new Shape(); + hull.Hardpoints = new List(); + hull.Prefab = null; + hull.Schematic = null; + hull.Visual = new CultRecordRef(ShipModCatalog.AuthoringKey(shipId)); + return hull; + } + // The structure of a ship file, without judging its content (drafts load): exactly one hull and one visual, each at // its deterministic key, the hull naming that visual by its typed ref. public static (HullData Hull, ShipAuthoring Visual) Load(string path) diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 8855475ae..f969ff187 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -420,6 +420,32 @@ public sealed class ShipAuthoringTests } }; } + + // create --like: the shipped hull's stats, never its body or layout. Every member a reference carries is copied, + // including one this test does not name, because the copy goes through the type's own serializer. + [Fact] + public void HullLikeCopiesStatsButNotTheBodyOrLayout() + { + var reference = new HullData + { + Name = "Djinni", Price = 1234, Durability = 56, Mass = 78, Armor = 9, Drag = 0.25f, CanTow = true, + HullType = HullType.Ship, Shape = new Shape(3, 2), Prefab = "prefab-guid", Schematic = "schematic-guid", + Hardpoints = new List { new HardpointData { Type = HardpointType.Thruster, Shape = new Shape(), Transform = "aft" } }, + }; + reference.Shape.Cells[2, 1] = true; + var hull = ShipAuthoringStore.HullLike(reference, "Gale", "mod.gale"); + + Assert.Equal((1234, 56f, 78f, 9f, 0.25f, true, HullType.Ship), + (hull.Price, hull.Durability, hull.Mass, hull.Armor, hull.Drag, hull.CanTow, hull.HullType)); + Assert.Equal("Gale", hull.Name); + Assert.Equal((1, 1, true), (hull.Shape.Width, hull.Shape.Height, hull.Shape.Cells[0, 0])); + Assert.Empty(hull.Hardpoints); + Assert.Null(hull.Prefab); + Assert.Null(hull.Schematic); + Assert.Equal(ShipModCatalog.AuthoringKey("mod.gale"), hull.Visual.Key); + // A copy, not the reference: the reference keeps its own body. + Assert.Equal(("Djinni", "prefab-guid", 1), (reference.Name, reference.Prefab, reference.Hardpoints.Count)); + } } // One ship's two records: the hull and the visual it names. diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index 52a8f75fd..dc5dcfb8b 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -7,11 +7,13 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { + private const string Usage = "ship-authoring create [--like ] | inspect | validate | compose "; + public static int Run(string[] args) { if (args.Length == 0) { - Console.Error.WriteLine("ship-authoring create | inspect | validate | compose "); + Console.Error.WriteLine(Usage); return 1; } try @@ -31,22 +33,31 @@ public static class ShipAuthoringCommands } if (args[0] == "validate" && args.Length == 2) { - var (hull, ship) = ShipAuthoringStore.Read(args[1]); - Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints.Count} hardpoints, {ship.SchematicLines.Count} lines"); + // The package's GLB is judged too once it exists (node ids, mesh roles); a draft without one is judged on + // its records alone, and the output says which. + var (hull, ship) = ShipAuthoringStore.Load(args[1]); + var model = string.IsNullOrWhiteSpace(ship.ModelAsset) ? null + : Path.Combine(Path.GetDirectoryName(Path.GetFullPath(args[1])), ship.ModelAsset); + var judged = model != null && File.Exists(model) ? "records and model" : "records only, no model yet"; + if (judged == "records and model") ShipModCatalog.ReadPackage(args[1]); + else ShipAuthoringStore.Validate(hull, ship); + Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints.Count} hardpoints, {ship.Anchors.Count} anchors, {ship.SchematicLines.Count} lines ({judged})"); return 0; } - if (args[0] == "create" && args.Length == 4) + if (args[0] == "create" && (args.Length == 4 || args.Length == 6 && args[4] == "--like")) { var path = Path.GetFullPath(args[1]); if (File.Exists(path)) throw new InvalidOperationException($"Refusing to replace {path}"); Directory.CreateDirectory(Path.GetDirectoryName(path)); var ship = new ShipAuthoring { Id = args[2], ModelAsset = "ship.glb", Anchors = new List() }; - var hull = new HullData - { - Name = args[3], - Shape = new Shape(), - Visual = new CultRecordRef(ShipModCatalog.AuthoringKey(ship.Id)) - }; + var hull = args.Length == 6 + ? ShipAuthoringStore.HullLike(ShippedShipHull(args[5]), args[3], ship.Id) + : new HullData + { + Name = args[3], + Shape = new Shape(), + Visual = new CultRecordRef(ShipModCatalog.AuthoringKey(ship.Id)) + }; using (var cache = ShipAuthoringStore.Open(path, writable: true)) { ShipAuthoringStore.Write(cache, hull, ship); @@ -55,7 +66,7 @@ public static class ShipAuthoringCommands Console.WriteLine($"Created draft {path}; fill its model bindings and schematic in Blender."); return 0; } - Console.Error.WriteLine("ship-authoring create | inspect | validate | compose "); + Console.Error.WriteLine(Usage); return 1; } catch (Exception ex) @@ -64,4 +75,15 @@ public static class ShipAuthoringCommands return 1; } } + + // A shipped ship hull by name, from GameData/Aetheria.cc opened read-only. + private static HullData ShippedShipHull(string name) + { + using var catalog = AetherDb.Open().Cache; + var hull = catalog.GetAll().FirstOrDefault(candidate => candidate.Name == name) ?? + throw new InvalidOperationException($"--like: the shipped catalog has no hull named '{name}'."); + if (hull.HullType != HullType.Ship) + throw new InvalidOperationException($"--like: '{name}' is a {hull.HullType} hull, not a ship."); + return hull; + } } From aca1d53e6fa782ad01fda4620a7870bc9c13dacd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 17:07:10 +0200 Subject: [PATCH 098/113] ship_cc: replace_visual writes ShipAuthoring's model asset and anchors, pinned to the C# keys MODEL_ASSET_SLOT, ANCHORS_SLOT and ANCHOR_MEMBERS sit beside the other slot constants and ShipSchemaPinTests binds them to ShipAuthoring's and ShipAnchor's [Key]s. replace_visual carries an anchor's slots past Order by Id, refuses a blank or duplicate id, a wrong-length row and a changed ship id, and leaves the hull record and the lines alone. Co-Authored-By: Claude Opus 5.5 --- .../ShipSchemaPinTests.cs | 11 ++++ tools/blender/aetheria_ships/ship_cc.py | 31 +++++++++++ tools/blender/tests/test_ship_cc.py | 53 +++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index 418c8f31a..e674ed23a 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -17,6 +17,8 @@ public sealed class ShipSchemaPinTests private static readonly string Source = File.ReadAllText(Locate("tools", "blender", "aetheria_ships", "ship_cc.py")); [Theory] + [InlineData("MODEL_ASSET_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.ModelAsset))] + [InlineData("ANCHORS_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.Anchors))] [InlineData("SCHEMATIC_LINES_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.SchematicLines))] [InlineData("HULL_SHAPE_SLOT", typeof(HullData), nameof(HullData.Shape))] [InlineData("HULL_HARDPOINTS_SLOT", typeof(HullData), nameof(HullData.Hardpoints))] @@ -56,6 +58,15 @@ public sealed class ShipSchemaPinTests Assert.Equal(slot, KeyOf(typeof(HardpointData), members[slot])); } + [Fact] + public void PythonAnchorMembersAreShipAnchorMembersAtTheirPositions() + { + var members = Strings("ANCHOR_MEMBERS"); + Assert.Equal(5, members.Length); + for (var slot = 0; slot < members.Length; slot++) + Assert.Equal(slot, KeyOf(typeof(ShipAnchor), members[slot])); + } + [Fact] public void PythonEnumNamesAreTheEnumsInValueOrder() { diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 16f48dbbc..f16fe72a5 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -21,12 +21,16 @@ HULL_SCHEMA = "aetheria.hulldata" # 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 # disagrees with those types' [Key] attributes, so a renumbered or added member cannot drift silently. RETIRED_HULL_SLOT = 1 +MODEL_ASSET_SLOT = 2 # ShipAuthoring.ModelAsset +ANCHORS_SLOT = 3 # ShipAuthoring.Anchors SCHEMATIC_LINES_SLOT = 4 # ShipAuthoring.SchematicLines HULL_SHAPE_SLOT = 5 # ItemData.Shape HULL_HARDPOINTS_SLOT = 23 # HullData.Hardpoints # HardpointData member names in slot order; a hardpoint row is one value per name, and any slot past the last # name belongs to a later schema and is carried through untouched. HARDPOINT_MEMBERS = ("Type", "Position", "Shape", "Transform", "Rotation", "Armor", "FiringArc") +# ShipAnchor member names in slot order; as for hardpoints, slots past the last name are carried through by anchor Id. +ANCHOR_MEMBERS = ("Id", "Role", "ModelNodeId", "ParentId", "Order") # Enum member names in value order (HardpointType, ItemRotation). HARDPOINT_TYPE_NAMES = ("Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Reactor", "Radiator", "Shield", "Sensors", "Energy", "Ballistic", "Launcher", "ControlModule", "AetherDrive") @@ -109,6 +113,33 @@ def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> i return len(lines) +def replace_visual(path: str, cultlib_packages: str, expected_id: str, model_asset: str, + anchors: list[list[Any]]) -> int: + """Writes the visual's model asset and anchor list (ShipAuthoring slots 2 and 3) and nothing else. Each anchor row is + ANCHOR_MEMBERS in order; the slots past Order that the same anchor Id carried before are appended to it unchanged.""" + file = read(path, cultlib_packages) + if file.ship.body[0] != expected_id: + raise ValueError("The bound ship ID changed; rebind the collection") + known = len(ANCHOR_MEMBERS) + ids = set() + for anchor in anchors: + if len(anchor) != known: + raise ValueError(f"Each anchor row needs exactly {', '.join(ANCHOR_MEMBERS)}") + anchor_id = anchor[0] + if not isinstance(anchor_id, str) or not anchor_id.strip(): + raise ValueError("Every anchor needs an ID") + if anchor_id in ids: + raise ValueError(f"Anchor ID {anchor_id} is used twice") + ids.add(anchor_id) + previous = file.ship.body[ANCHORS_SLOT] if len(file.ship.body) > ANCHORS_SLOT else None + later = {row[0]: row[known:] for row in previous or [] + if isinstance(row, list) and len(row) > known and isinstance(row[0], str)} + file.ship.body[MODEL_ASSET_SLOT] = model_asset + file.ship.body[ANCHORS_SLOT] = [list(anchor) + later.get(anchor[0], []) for anchor in anchors] + file.store.push(_stamped(file.ship, file.msgpack)) + return len(anchors) + + def _hull_body(file: ShipFile) -> list: if len(file.hull.body) <= HULL_HARDPOINTS_SLOT: raise ValueError("Ship hull has an incompatible typed payload") diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 0ab0c9877..d0f80150a 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -230,6 +230,59 @@ class ReplaceLinesTests(ShipFileCase): self.assertEqual(before, self.bytes()) +class ReplaceVisualTests(ShipFileCase): + def anchor(self, anchor_id, role="map-icon", parent=None, order=0): + return [anchor_id, role, anchor_id, parent, order] + + def test_writes_the_model_asset_and_anchor_rows_in_member_order(self): + rows = [self.anchor("map"), self.anchor("gun", "weapon-mount"), self.anchor("gun.muzzle", "weapon-muzzle", "gun", 1)] + self.assertEqual(3, ship_cc.replace_visual(self.path, PACKAGES, "mod.skiff", "ship.glb", rows)) + ship = self.ship() + self.assertEqual("ship.glb", ship[ship_cc.MODEL_ASSET_SLOT]) + self.assertEqual(rows, ship[ship_cc.ANCHORS_SLOT]) + self.assertEqual(dict(zip(ship_cc.ANCHOR_MEMBERS, rows[2])), + {"Id": "gun.muzzle", "Role": "weapon-muzzle", "ModelNodeId": "gun.muzzle", "ParentId": "gun", "Order": 1}) + + def test_replace_visual_carries_unknown_anchor_slots_by_id(self): + ship = ship_body() + ship[ship_cc.ANCHORS_SLOT] = [["map", "map-icon", "map", None, 0, "future-anchor-slot"], + ["shield", "shield", "shield", None, 0, "shield-future"]] + self.write(ship, hull_body()) + # Reordered, with one new anchor: each known Id keeps its own tail, the new one gets none. + ship_cc.replace_visual(self.path, PACKAGES, "mod.skiff", "ship.glb", + [self.anchor("shield", "shield"), self.anchor("tractor", "tractor"), self.anchor("map")]) + self.assertEqual([["shield", "shield", "shield", None, 0, "shield-future"], + ["tractor", "tractor", "tractor", None, 0], + ["map", "map-icon", "map", None, 0, "future-anchor-slot"]], self.ship()[ship_cc.ANCHORS_SLOT]) + + def test_replace_visual_refuses_blank_or_duplicate_ids_and_a_changed_ship(self): + before = self.bytes() + cases = (("blank", "mod.skiff", [self.anchor(" ")], "needs an ID"), + ("none", "mod.skiff", [[None, "map-icon", "x", None, 0]], "needs an ID"), + ("duplicate", "mod.skiff", [self.anchor("map"), self.anchor("map", "shield")], "used twice"), + ("arity", "mod.skiff", [["map", "map-icon", "map", None]], "exactly"), + ("ship", "mod.other", [self.anchor("map")], "ship ID changed")) + for name, ship_id, rows, message in cases: + with self.subTest(name): + with self.assertRaisesRegex(ValueError, message): + ship_cc.replace_visual(self.path, PACKAGES, ship_id, "ship.glb", rows) + self.assertEqual(before, self.bytes()) + + def test_replace_visual_touches_only_model_asset_and_anchors(self): + hull_before = self.envelopes()[(ship_cc.HULL_SCHEMA, "mod-hull:mod.skiff")] + ship_before = self.ship() + ship_cc.replace_visual(self.path, PACKAGES, "mod.skiff", "ship.glb", [self.anchor("hull", "hull-collider")]) + after = self.envelopes() + self.assertEqual(hull_before, after[(ship_cc.HULL_SCHEMA, "mod-hull:mod.skiff")]) + ship_after = self.ship() + for slot, (old, new) in enumerate(zip(ship_before, ship_after)): + if slot not in (ship_cc.MODEL_ASSET_SLOT, ship_cc.ANCHORS_SLOT): + self.assertEqual(old, new, f"slot {slot}") + self.assertEqual(len(ship_before), len(ship_after)) + self.assertEqual(LINES, ship_after[ship_cc.SCHEMATIC_LINES_SLOT]) + self.assertEqual(CATALOG.schema_id, after[(ship_cc.SCHEMA, "mod-ship:mod.skiff")].schema_id) + + class LayoutTests(ShipFileCase): def layout(self): return ship_cc.read_layout(self.path, PACKAGES) From 82ca9e9be161b95518789866a00e45a0161f20fd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 17:13:42 +0200 Subject: [PATCH 099/113] aetheria_ships: Package Ship derives anchors and ship.glb from the bound collection and runs AetherDb validate Every object tagged aetheria.role becomes a ShipAnchor whose Id and ModelNodeId are its aetheria.id (a muzzle's ParentId is its parent's). The collection, Grease Pencil excluded, exports to ship.glb with custom properties as node extras; replace_visual writes the anchors, a lone Grease Pencil object's lines are captured, and AetherDb's verdict shows in the panel. The add-on preference 'Aetheria repo' names the checkout; by default it is the nearest folder above the .cc. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 155 ++++++++++++++++++++++- 1 file changed, 152 insertions(+), 3 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 1c21ded35..c591d7b8e 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -6,14 +6,20 @@ bl_info = { "version": (0, 1, 0), "blender": (4, 3, 0), "location": "View3D > Sidebar > Brokkr > Aetheria Ship", - "description": "Write Grease Pencil ship lines into a typed Aetheria .cc ship record", + "description": "Author a typed Aetheria ship package: layout, lines, anchors and its GLB", "category": "Object", } +import subprocess +from pathlib import Path + import bpy from .ship_cc import (HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, - encode_hardpoint, read, read_layout, replace_layout, replace_lines) + encode_hardpoint, read, read_layout, replace_layout, replace_lines, replace_visual) + +MODEL_ASSET = "ship.glb" +AETHERDB_TIMEOUT = 600 # seconds; the first run builds AetherDb HARDPOINT_TYPES = tuple((str(i), name, name) for i, name in enumerate(HARDPOINT_TYPE_NAMES)) @@ -262,6 +268,134 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): return {"CANCELLED"} +class AETHERIA_AP_preferences(bpy.types.AddonPreferences): + bl_idname = __package__ + + aetheria_repo: bpy.props.StringProperty( + name="Aetheria repo", subtype="DIR_PATH", + description="Aetheria checkout whose tools/AetherDb validates packages. " + "Empty: the nearest folder above the bound .cc that holds tools/AetherDb") + + def draw(self, context): + self.layout.prop(self, "aetheria_repo") + + +def _aetheria_repo(context, ship_cc): + addon = context.preferences.addons.get(__package__) + configured = addon.preferences.aetheria_repo if addon and addon.preferences else "" + if configured: + repo = Path(bpy.path.abspath(configured)) + if not (repo / "tools" / "AetherDb").is_dir(): + raise ValueError(f"The Aetheria repo preference {repo} has no tools/AetherDb") + return repo + for folder in Path(ship_cc).resolve().parents: + if (folder / "tools" / "AetherDb").is_dir(): + return folder + raise ValueError("No Aetheria repo above the ship .cc; set it in the add-on's preferences") + + +def _aetherdb(context, ship_cc, *args): + """Runs one AetherDb ship-authoring command; returns its exit code and its output, stdout then stderr. The compiler + warnings 'dotnet run' prints when it rebuilds AetherDb are left out; errors are kept.""" + repo = _aetheria_repo(context, ship_cc) + result = subprocess.run( + ["dotnet", "run", "--project", str(repo / "tools" / "AetherDb"), "--", "ship-authoring", *args], + cwd=repo, capture_output=True, text=True, timeout=AETHERDB_TIMEOUT) + lines = (result.stdout + "\n" + result.stderr).splitlines() + return result.returncode, "\n".join(line for line in lines if line.strip() and ": warning " not in line) + + +def _layer_collection(layer, collection): + if layer.collection == collection: + return layer + for child in layer.children: + found = _layer_collection(child, collection) + if found: + return found + return None + + +def _anchors(collection): + """One ShipAnchor row per object tagged aetheria.role. Its aetheria.id is both the anchor id and the GLB node id.""" + rows = [] + for obj in sorted(collection.all_objects, key=lambda candidate: candidate.name): + role = obj.get("aetheria.role") + if role is None: + continue + anchor_id = obj.get("aetheria.id") + if not isinstance(anchor_id, str) or not anchor_id.strip(): + raise ValueError(f"{obj.name} has aetheria.role {role} but no aetheria.id") + parent = None + if role == "weapon-muzzle": + parent = obj.parent.get("aetheria.id") if obj.parent else None + if not parent: + raise ValueError(f"Muzzle {obj.name} must be parented to its weapon mount object") + rows.append([anchor_id, str(role), anchor_id, parent, int(obj.get("aetheria.order", 0))]) + return rows + + +def _export_glb(context, collection, filepath): + """Exports the collection's objects, Grease Pencil excluded, with custom properties as node extras.""" + view_layer = context.view_layer + layer = _layer_collection(view_layer.layer_collection, collection) + if layer is None: + raise ValueError(f"{collection.name} is not in the current view layer") + previous_layer = view_layer.active_layer_collection + previous_selection = [obj for obj in view_layer.objects if obj.select_get()] + previous_active = view_layer.objects.active + try: + view_layer.active_layer_collection = layer + for obj in previous_selection: + obj.select_set(False) + for obj in collection.all_objects: + if obj.type != "GREASEPENCIL" and obj.name in view_layer.objects: + obj.select_set(True) + bpy.ops.export_scene.gltf( + filepath=filepath, export_format="GLB", use_active_collection=True, use_selection=True, + export_extras=True, export_yup=True, export_apply=True) + finally: + for obj in view_layer.objects: + obj.select_set(obj in previous_selection) + view_layer.objects.active = previous_active + view_layer.active_layer_collection = previous_layer + + +class AETHERIA_OT_package_ship(bpy.types.Operator): + bl_idname = "aetheria.package_ship" + bl_label = "Package Ship" + bl_description = ("Write the anchors and ship.glb from the bound collection, capture its LineArt, " + "and run AetherDb's validator") + bl_options = {"REGISTER"} + + def execute(self, context): + scene = context.scene + try: + collection, path = _layout_path(context) + cultlib = _brokkr_cultlib(context) + ship_id = collection["aetheria.id"] + if read(path, cultlib).ship.body[0] != ship_id: + raise ValueError("The bound collection ID does not match its .cc ship ID") + anchors = _anchors(collection) + _export_glb(context, collection, str(Path(path).with_name(MODEL_ASSET))) + replace_visual(path, cultlib, ship_id, MODEL_ASSET, anchors) + pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] + if len(pencils) == 1: + replace_lines(path, cultlib, capture_grease_pencil( + pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, + evaluated=scene.aetheria_capture_evaluated_lines)) + code, message = _aetherdb(context, path, "validate", path) + except (OSError, ValueError, RuntimeError, ImportError, KeyError, subprocess.SubprocessError) as exc: + scene.aetheria_package_report = f"Package failed: {exc}" + self.report({"ERROR"}, scene.aetheria_package_report) + return {"CANCELLED"} + scene.aetheria_package_report = message or f"validate exited {code}" + if code != 0: + self.report({"ERROR"}, f"Packaged {len(anchors)} anchors; the validator refused it: {scene.aetheria_package_report}") + return {"CANCELLED"} + self.report({"INFO"}, scene.aetheria_package_report) + return {"FINISHED"} + + class AETHERIA_PT_ship(bpy.types.Panel): bl_label = "Aetheria Ship" bl_idname = "AETHERIA_PT_ship" @@ -287,6 +421,7 @@ class AETHERIA_PT_ship(bpy.types.Panel): layout.operator("aetheria.load_ship_layout", icon="IMPORT") state = context.scene.aetheria_layout if not state.ship_id: + self._draw_package(context, layout) return layout.label(text=f"Layout: {state.ship_id}") dims = layout.row(align=True) @@ -317,13 +452,24 @@ class AETHERIA_PT_ship(bpy.types.Panel): box.prop(hp, "firing_arc") layout.operator("aetheria.add_ship_hardpoint", icon="ADD") layout.operator("aetheria.save_ship_layout", icon="FILE_TICK") + self._draw_package(context, layout) + + @staticmethod + def _draw_package(context, layout): + layout.separator() + layout.operator("aetheria.package_ship", icon="PACKAGE") + report = context.scene.aetheria_package_report + if report: + box = layout.box() + for line in report.splitlines(): + box.label(text=line) classes = (AETHERIA_PG_cell, AETHERIA_PG_hardpoint, AETHERIA_PG_layout, AETHERIA_OT_load_layout, AETHERIA_OT_resize_layout, AETHERIA_OT_add_hardpoint, AETHERIA_OT_remove_hardpoint, AETHERIA_OT_save_layout, AETHERIA_OT_bind_ship_collection, - AETHERIA_OT_capture_ship_lines, AETHERIA_PT_ship) + AETHERIA_OT_capture_ship_lines, AETHERIA_OT_package_ship, AETHERIA_AP_preferences, AETHERIA_PT_ship) def register(): @@ -333,6 +479,8 @@ def register(): bpy.types.Scene.aetheria_ship_cc_path = bpy.props.StringProperty( name="Ship .cc", subtype="FILE_PATH", description="Existing typed ship authoring record" ) + bpy.types.Scene.aetheria_package_report = bpy.props.StringProperty( + name="Package report", description="AetherDb's verdict on the last Package Ship") bpy.types.Scene.aetheria_capture_evaluated_lines = bpy.props.BoolProperty( name="Evaluated LineArt", default=True, description="Capture the visible modifier result at the current frame", @@ -344,4 +492,5 @@ def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls) del bpy.types.Scene.aetheria_capture_evaluated_lines + del bpy.types.Scene.aetheria_package_report del bpy.types.Scene.aetheria_ship_cc_path From 7d649d5c312573ef19ed652089e78e6e395133d8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 17:13:44 +0200 Subject: [PATCH 100/113] Package Ship: headless Blender smoke, and the authoring doc says how Package and create --like are used smoke_package.py builds a primitive ship from role-tagged objects, creates it --like Djinni, saves a layout with an internal reactor, packages it and requires validate to pass; then swaps the thruster disc for an empty and requires the refusal naming the anchor; then restores and packages again so the left-behind package is good. Proof gate 2's outstanding authoring action is now Package. Co-Authored-By: Claude Opus 5.5 --- docs/moddable-ship-authoring.md | 28 ++++-- tools/blender/tests/smoke_package.py | 139 +++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 tools/blender/tests/smoke_package.py diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 6072e1afb..650286d75 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -34,7 +34,8 @@ hull shape and hardpoint slots of the `aetheria.hulldata` record beside it. To t to Blender's add-on search path, enable `brokkr_bridge` then `aetheria_ships`, and install `msgpack` into Blender's Python environment. Create a draft with `dotnet run --project tools/AetherDb -- ship-authoring -create `, choose that `.cc` in the **Aetheria +create [--like ]`; `--like Djinni` +copies that shipped ship's stats and leaves the body and layout empty. Choose that `.cc` in the **Aetheria Ship** panel, select an object in its ship collection, and press **Bind Ship Collection**. The collection stores `aetheria.asset_kind=ship`, the stable `aetheria.id`, and a relative `.cc` path. Select a Grease Pencil object in @@ -51,8 +52,20 @@ assets, lines, the record key, and schema. Save rejects a changed ship ID or layout revision; reload first if another editor changed those fields. A line capture leaves the loaded layout revision intact. The saved layout is a draft until its mount IDs resolve to visual anchors and C# validation passes. +**Package Ship** turns the bound collection into the package. Tag each anchor +object with the custom properties `aetheria.role` (`map-icon`, `hull-collider`, +`shield`, `tractor`, `thruster-emitter`, `radiator-mesh`, `weapon-mount`, +`weapon-muzzle`) and `aetheria.id` (the hardpoint's mount ID for a mount), +plus `aetheria.order` if a weapon has several muzzles. Parent a muzzle to its +weapon mount object. Thrusters and radiators are mesh objects; the others may +be empties. Package exports the collection, Grease Pencil excluded, to +`ship.glb` beside the `.cc`, writes the anchors, captures the lines when the +collection holds exactly one Grease Pencil object, and shows AetherDb's +verdict in the panel. It runs `dotnet run --project /tools/AetherDb`; +the repo is the add-on preference **Aetheria repo**, or by default the nearest +folder above the `.cc` holding `tools/AetherDb`. `ship-authoring inspect ` reads drafts; `validate` requires complete -hull, model, and anchor authoring. +hull, model, and anchor authoring, and judges `ship.glb` once it exists. ## Objective @@ -186,10 +199,13 @@ catalog. Continue refuses a run naming a design the catalog no longer holds 1. A typed `.cc` record round-trips between C# and Blender's Python runtime, preserving unknown future fields and record identity. -2. Blender edits schematic cells and hardpoints in that record. Stable visual - node IDs still need a collection-to-package authoring action; the C# - validator checks their association with mount IDs and GLB nodes. The model - supplies geometry, not a second copy of hull semantics. +2. Blender edits schematic cells and hardpoints in that record. **Package + Ship** writes the anchors and `ship.glb` from the bound collection: each + object tagged `aetheria.role` becomes an anchor whose id, and GLB node + `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. 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/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py new file mode 100644 index 000000000..f015a6ebf --- /dev/null +++ b/tools/blender/tests/smoke_package.py @@ -0,0 +1,139 @@ +"""Headless Blender smoke for the Package Ship action, end to end. + + blender --background --factory-startup --python tools/blender/tests/smoke_package.py + +Environment: CULTLIB_PACKAGES (CultLib's packages directory, as test_ship_cc.py), and whatever AetherDb's build needs +(CULTLIB_ROOT and CULTMATH_ROOT at the pinned revisions), since the add-on runs 'dotnet run --project tools/AetherDb'. +SMOKE_MODS names the mods directory to write into (default: a new temp directory); the package is left there so the +Unity play smoke can load it, and its path is printed as SMOKE_PACKAGE=. + +Pass 1 packages a primitive ship built from role-tagged objects and requires the C# validator to accept it. Pass 2 swaps +the thruster disc for an empty of the same id and requires the validator to refuse it, naming the anchor. Pass 3 puts +the disc back and packages again, so the package left behind is the good one. +""" + +import os +import sys +import tempfile +import traceback +from pathlib import Path + +import addon_utils +import bpy + +REPO = Path(__file__).resolve().parents[3] +PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory") +SHIP_ID = "smoke.gale" + + +def tetrahedron(name): + mesh = bpy.data.meshes.new(name) + mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)], [], [(0, 2, 1), (0, 1, 3), (0, 3, 2), (1, 2, 3)]) + return mesh + + +def quad(name, size=1.0): + mesh = bpy.data.meshes.new(name) + half = size / 2 + mesh.from_pydata([(-half, -half, 0), (half, -half, 0), (half, half, 0), (-half, half, 0)], [], [(0, 1, 2, 3)]) + return mesh + + +def tagged(collection, name, data, role=None, anchor_id=None, location=(0, 0, 0), parent=None): + obj = bpy.data.objects.new(name, data) + obj.location = location + if role: + obj["aetheria.role"] = role + obj["aetheria.id"] = anchor_id + obj.parent = parent + collection.objects.link(obj) + return obj + + +def package(addon): + # An operator that reports an error raises it out of bpy.ops; the panel's report is the verdict either way. + try: + result = bpy.ops.aetheria.package_ship() + except RuntimeError: + result = {"CANCELLED"} + report = bpy.context.scene.aetheria_package_report + print(f"PACKAGE {sorted(result)}: {report}") + return result, report + + +def main(): + sys.path.insert(0, str(REPO / "tools" / "blender")) + addon_utils.enable("aetheria_ships", default_set=True, handle_error=None) + import aetheria_ships + # Brokkr is the Blender host's CultLib path provider and is not installed in a factory-startup Blender. + aetheria_ships._brokkr_cultlib = lambda context: PACKAGES + bpy.context.preferences.addons["aetheria_ships"].preferences.aetheria_repo = str(REPO) + + mods = Path(os.environ.get("SMOKE_MODS") or tempfile.mkdtemp(prefix="aetheria-smoke-mods-")) + path = str(mods / SHIP_ID / "ship.cc") + if Path(path).exists(): + raise SystemExit(f"{path} already exists; point SMOKE_MODS at an empty directory") + code, message = aetheria_ships._aetherdb(bpy.context, path, "create", path, SHIP_ID, "Smoke Gale", "--like", "Djinni") + print(f"CREATE {code}: {message}") + if code != 0: + raise SystemExit("create --like Djinni failed") + + scene = bpy.context.scene + ship = bpy.data.collections.new("Smoke Gale") + scene.collection.children.link(ship) + hull = tagged(ship, "Hull", tetrahedron("Hull")) + tagged(ship, "Map Icon", quad("Map Icon"), "map-icon", "map", (0, 0, -1)) + tagged(ship, "Hull Collider", tetrahedron("Hull Collider"), "hull-collider", "collider") + tagged(ship, "Shield", None, "shield", "shield") + tagged(ship, "Tractor", None, "tractor", "tractor", (0, 2, 0)) + disc = tagged(ship, "Thruster", quad("Thruster", 0.4), "thruster-emitter", "thruster", (0, -1, 0)) + gun = tagged(ship, "Gun", None, "weapon-mount", "gun", (1, 0, 0)) + tagged(ship, "Gun Muzzle", None, "weapon-muzzle", "gun.muzzle", (0, 0.5, 0), parent=gun) + tagged(ship, "Radiator", quad("Radiator", 0.5), "radiator-mesh", "radiator", (-1, 0, 0)) + + scene.aetheria_ship_cc_path = path + bpy.context.view_layer.objects.active = hull + if bpy.ops.aetheria.bind_ship_collection() != {"FINISHED"}: + raise SystemExit("Bind Ship Collection failed") + + ship_cc = aetheria_ships.ship_cc + _, _, revision = ship_cc.read_layout(path, PACKAGES) + + def hardpoint(kind, x, mount): + return ship_cc.encode_hardpoint(Type=ship_cc.HARDPOINT_TYPE_NAMES.index(kind), Position=[x, 0], + Shape=[[1, 1, [True]]], Transform=mount, Rotation=0, Armor=0.0, FiringArc=0.0) + + ship_cc.replace_layout(path, PACKAGES, SHIP_ID, revision, [4, 1, [True] * 4], [ + hardpoint("Thruster", 0, "thruster"), hardpoint("Radiator", 1, "radiator"), + hardpoint("Energy", 2, "gun"), hardpoint("Reactor", 3, "reactor")]) + + result, report = package(aetheria_ships) + if result != {"FINISHED"}: + raise SystemExit("Pass 1: the validator refused the primitive ship") + + ship.objects.unlink(disc) + hollow = tagged(ship, "Thruster Empty", None, "thruster-emitter", "thruster", (0, -1, 0)) + result, report = package(aetheria_ships) + if result != {"CANCELLED"} or "thruster-emitter anchor thruster needs a mesh" not in report: + raise SystemExit("Pass 2: an empty thruster node was not refused by name") + + ship.objects.unlink(hollow) + ship.objects.link(disc) + result, report = package(aetheria_ships) + if result != {"FINISHED"}: + raise SystemExit("Pass 3: the restored ship was refused") + print(f"SMOKE_PACKAGE={path}") + print("SMOKE_PACKAGE_OK") + + +try: + main() +except SystemExit as stop: + print(f"SMOKE_PACKAGE_FAILED: {stop}", flush=True) + os._exit(1) +except Exception: + traceback.print_exc() + print("SMOKE_PACKAGE_FAILED", flush=True) + os._exit(1) +sys.stdout.flush() +os._exit(0) From 4895c7523692664ca50c231bdd520baed7728e4f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 17:37:21 +0200 Subject: [PATCH 101/113] Bind Ship Collection: compute the stored .cc path before binding, absolute when relative cannot hold The bind called bpy.path.relpath after setting aetheria.asset_kind and aetheria.id, so a .cc on another drive than the .blend raised and left the collection half-bound, and an unsaved .blend stored a path relative to the working directory. The stored path is now computed first: absolute for an unsaved .blend, '//'-relative when relpath succeeds, absolute when it raises ValueError. Every reader already resolves both through bpy.path.abspath. smoke_package.py gains the bind cases: unsaved stores absolute and survives two Save As moves, a failing bind leaves no aetheria.* property, same-drive stores '//'-relative, and with SMOKE_OTHER_DRIVE the final bind crosses drives before the package passes. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 8 ++- tools/blender/tests/smoke_package.py | 68 ++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index c591d7b8e..bcf9f6f7f 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -230,9 +230,15 @@ class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): raise ValueError(f"{collection.name} has another aetheria.asset_kind") if collection.get("aetheria.id") not in (None, ship_id): raise ValueError(f"{collection.name} is bound to another ship ID") + stored = path + if bpy.data.filepath: + try: + stored = bpy.path.relpath(path) + except ValueError: + pass collection["aetheria.asset_kind"] = "ship" collection["aetheria.id"] = ship_id - collection["aetheria.ship_cc"] = bpy.path.relpath(path) + collection["aetheria.ship_cc"] = stored self.report({"INFO"}, f"Bound {collection.name} to {ship_id}") return {"FINISHED"} except (OSError, ValueError, RuntimeError, ImportError) as exc: diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py index f015a6ebf..de5958ad1 100644 --- a/tools/blender/tests/smoke_package.py +++ b/tools/blender/tests/smoke_package.py @@ -1,6 +1,6 @@ """Headless Blender smoke for the Package Ship action, end to end. - blender --background --factory-startup --python tools/blender/tests/smoke_package.py + blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_package.py Environment: CULTLIB_PACKAGES (CultLib's packages directory, as test_ship_cc.py), and whatever AetherDb's build needs (CULTLIB_ROOT and CULTMATH_ROOT at the pinned revisions), since the add-on runs 'dotnet run --project tools/AetherDb'. @@ -10,6 +10,11 @@ Unity play smoke can load it, and its path is printed as SMOKE_PACKAGE= Pass 1 packages a primitive ship built from role-tagged objects and requires the C# validator to accept it. Pass 2 swaps the thruster disc for an empty of the same id and requires the validator to refuse it, naming the anchor. Pass 3 puts the disc back and packages again, so the package left behind is the good one. + +Before packaging, the bind cases: a bind that fails while computing the stored path leaves no aetheria.* property; an +unsaved .blend stores the absolute path, which still resolves after Save As to two other folders; a saved .blend on the +.cc's drive stores a '//'-relative path. SMOKE_OTHER_DRIVE names a directory on another drive than SMOKE_MODS; when set, +the .blend is saved there before the final bind, so the bind crosses drives and the package passes prove it resolves. """ import os @@ -50,6 +55,64 @@ def tagged(collection, name, data, role=None, anchor_id=None, location=(0, 0, 0) return obj +BIND_KEYS = ("aetheria.asset_kind", "aetheria.id", "aetheria.ship_cc") + + +def same_file(a, b): + return os.path.normcase(os.path.normpath(a)) == os.path.normcase(os.path.normpath(b)) + + +def bind(ship, label): + for key in BIND_KEYS: + if key in ship: + del ship[key] + try: + result = bpy.ops.aetheria.bind_ship_collection() + except RuntimeError: + result = {"CANCELLED"} + stored = ship.get("aetheria.ship_cc") + print(f"BIND {label} {sorted(result)}: blend={bpy.data.filepath!r} stored={stored!r}") + return result, stored + + +def bind_cases(ship, path, mods): + if bpy.data.filepath: + raise SystemExit("Bind cases need an unsaved .blend to start from") + result, stored = bind(ship, "unsaved") + if result != {"FINISHED"} or not os.path.isabs(stored) or not same_file(stored, path): + raise SystemExit("Bind case unsaved: the stored path is not the absolute .cc path") + for folder in ("blend-a", "blend-b"): + (mods / folder).mkdir() + bpy.ops.wm.save_as_mainfile(filepath=str(mods / folder / "ship.blend")) + if not same_file(bpy.path.abspath(ship["aetheria.ship_cc"]), path): + raise SystemExit(f"Bind case unsaved: the stored path stopped resolving after Save As to {folder}") + + relpath = bpy.path.relpath + bpy.path.relpath = lambda *args, **kwargs: (_ for _ in ()).throw(OSError("probe: relpath failed")) + try: + result, _ = bind(ship, "failing") + finally: + bpy.path.relpath = relpath + if result != {"CANCELLED"} or any(key in ship for key in BIND_KEYS): + raise SystemExit("Bind case failing: a failed bind left aetheria.* properties behind") + + result, stored = bind(ship, "same-drive") + if result != {"FINISHED"} or not stored.startswith("//") or not same_file(bpy.path.abspath(stored), path): + raise SystemExit("Bind case same-drive: the stored path is not a resolving '//'-relative path") + + other = os.environ.get("SMOKE_OTHER_DRIVE") + if not other: + print("BIND cross-drive skipped: SMOKE_OTHER_DRIVE is not set") + return + if os.path.splitdrive(other)[0].lower() == os.path.splitdrive(path)[0].lower(): + raise SystemExit("SMOKE_OTHER_DRIVE is on the same drive as SMOKE_MODS") + Path(other).mkdir(parents=True, exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=str(Path(other) / "ship.blend")) + result, stored = bind(ship, "cross-drive") + if result != {"FINISHED"} or not os.path.isabs(stored) or not same_file(stored, path): + raise SystemExit("Bind case cross-drive: the stored path is not the absolute .cc path") + + def package(addon): # An operator that reports an error raises it out of bpy.ops; the panel's report is the verdict either way. try: @@ -93,8 +156,7 @@ def main(): scene.aetheria_ship_cc_path = path bpy.context.view_layer.objects.active = hull - if bpy.ops.aetheria.bind_ship_collection() != {"FINISHED"}: - raise SystemExit("Bind Ship Collection failed") + bind_cases(ship, path, mods) ship_cc = aetheria_ships.ship_cc _, _, revision = ship_cc.read_layout(path, PACKAGES) From c37d6adfa8c5750fdc1b0ad5a0cc0583ce37e486 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 20:25:26 +0200 Subject: [PATCH 102/113] Galaxy derives a prelude's halved faction influence instead of writing it into the catalog's Faction A prelude halved every faction's InfluenceDistance on the shared catalog object, so each later generation in the same session, prelude or main, started from an already-halved value, and a continued prelude read the unhalved one. A fixed-seed scenario relaunched in one session therefore could not reproduce its galaxy. Galaxy.InfluenceOf derives the reach from IsPrelude; Galaxy and ZoneGenerator read it. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Galaxy.cs | 33 ++++++++++---------- Assets/Scripts/ServerShared/ZoneGenerator.cs | 2 +- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 730daadf0..89fa80794 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -139,6 +139,11 @@ public class Galaxy return _cache.GetAll().FirstOrDefault(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase)); } + // How many jumps a faction's influence reaches in this galaxy: its authored distance, halved (rounding up) in a + // prelude. Derived, never written back to the catalog's Faction, so one galaxy's generation cannot change the next. + public int InfluenceOf(Faction faction) => + IsPrelude ? (faction.InfluenceDistance + 1) / 2 : faction.InfluenceDistance; + public Galaxy( TutorialGenerationSettings settings, SectorBackgroundSettings background, @@ -177,11 +182,7 @@ public class Galaxy factions.AddRange(neutralFactions); Factions = factions.ToArray(); - foreach (var faction in Factions) - { - FactionRelationships[faction] = FactionRelationship.Neutral; - faction.InfluenceDistance = (faction.InfluenceDistance + 1) / 2; - } + foreach (var faction in Factions) FactionRelationships[faction] = FactionRelationship.Neutral; Zones = GenerateZones(settings.ZoneCount, ref random, progressCallback); @@ -190,13 +191,13 @@ public class Galaxy CalculateDistanceMatrix(progressCallback); HomeZones[protagonistFaction] = Zones - .MaxBy(z => ConnectedRegion(z, protagonistFaction.InfluenceDistance).Count); + .MaxBy(z => ConnectedRegion(z, InfluenceOf(protagonistFaction)).Count); HomeZones[antagonistFaction] = Zones - .MaxBy(z => ConnectedRegion(z, antagonistFaction.InfluenceDistance).Count * z.Distance[HomeZones[protagonistFaction]]); + .MaxBy(z => ConnectedRegion(z, InfluenceOf(antagonistFaction)).Count * z.Distance[HomeZones[protagonistFaction]]); HomeZones[protagonistFaction] = Zones - .MaxBy(z => ConnectedRegion(z, protagonistFaction.InfluenceDistance).Count * sqrt(z.Distance[HomeZones[antagonistFaction]])); + .MaxBy(z => ConnectedRegion(z, InfluenceOf(protagonistFaction)).Count * sqrt(z.Distance[HomeZones[antagonistFaction]])); // var antagonistRegion = ConnectedRegion(HomeZones[antagonistFaction], antagonistFaction.InfluenceDistance); // var protagonistRegion = ConnectedRegion(HomeZones[protagonistFaction], protagonistFaction.InfluenceDistance); @@ -205,13 +206,13 @@ public class Galaxy var bufferDistance = Zones.Min(z => abs(z.Distance[HomeZones[antagonistFaction]] - z.Distance[HomeZones[protagonistFaction]])); var potentialBufferZones = Zones .Where(z => abs(z.Distance[HomeZones[antagonistFaction]] - z.Distance[HomeZones[protagonistFaction]]) == bufferDistance); - HomeZones[bufferFaction] = potentialBufferZones.MaxBy(z => ConnectedRegion(z, bufferFaction.InfluenceDistance).Count); + HomeZones[bufferFaction] = potentialBufferZones.MaxBy(z => ConnectedRegion(z, InfluenceOf(bufferFaction)).Count); // Place neutral headquarters away from existing factions while also maximizing territory foreach (var faction in neutralFactions) { HomeZones[faction] = Zones.MaxBy(z => - ConnectedRegion(z, faction.InfluenceDistance).Count * + ConnectedRegion(z, InfluenceOf(faction)).Count * HomeZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z]))); } @@ -221,7 +222,7 @@ public class Galaxy .Where(z => z.Factions.Contains(antagonistFaction) && z.Factions.Contains(bufferFaction)); if (potentialQuestZones.Any()) HomeZones[questFaction] = potentialQuestZones - .MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, questFaction.InfluenceDistance).Count); + .MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, InfluenceOf(questFaction)).Count); else HomeZones[questFaction] = Zones .Where(z => z.Factions.Contains(antagonistFaction)) @@ -287,9 +288,9 @@ public class Galaxy // While occupying as much territory as possible foreach (var mega in bossMegas) { - HomeZones[mega] = ConnectedRegion(BossZones[mega], mega.InfluenceDistance) + HomeZones[mega] = ConnectedRegion(BossZones[mega], InfluenceOf(mega)) .MaxBy(z => - ConnectedRegion(z, mega.InfluenceDistance).Count * + ConnectedRegion(z, InfluenceOf(mega)).Count * HomeZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z]))); } @@ -297,7 +298,7 @@ public class Galaxy foreach (var mega in Factions.Where(m => !bossMegas.Contains(m))) { HomeZones[mega] = Zones.MaxBy(z => - pow(ConnectedRegion(z, mega.InfluenceDistance).Count, HomeZones.Count) * + pow(ConnectedRegion(z, InfluenceOf(mega)).Count, HomeZones.Count) * Exit.Distance[z] * Entrance.Distance[z] * HomeZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])) * BossZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z]))); @@ -315,14 +316,14 @@ public class Galaxy // Factions are present in all zones within their sphere of influence zone.Factions = Factions .Where(f => HomeZones.ContainsKey(f)) - .Where(f => zone.Distance[HomeZones[f]] <= f.InfluenceDistance) + .Where(f => zone.Distance[HomeZones[f]] <= InfluenceOf(f)) .ToArray(); // Owner of a zone is the faction with the nearest headquarters var nearestFaction = Factions .Where(f => HomeZones.ContainsKey(f)) .MinBy(f => (float)zone.Distance[HomeZones[f]]); - if (zone.Distance[HomeZones[nearestFaction]] <= nearestFaction.InfluenceDistance) + if (zone.Distance[HomeZones[nearestFaction]] <= InfluenceOf(nearestFaction)) zone.Owner = nearestFaction; } } diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index d57939168..ff25223fe 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -206,7 +206,7 @@ public static class ZoneGenerator var nearestFaction = galaxy.Factions.MinBy(f => galaxy.HomeZones[f].Distance[galaxyZone]); var nearestFactionHomeZone = galaxy.HomeZones[nearestFaction]; - var factionPresence = nearestFaction.InfluenceDistance - nearestFactionHomeZone.Distance[galaxyZone] + 1; + var factionPresence = galaxy.InfluenceOf(nearestFaction) - nearestFactionHomeZone.Distance[galaxyZone] + 1; var storyStations = galaxyZone.Locations.Where(story => story.Type == LocationType.Station).ToArray(); var stationCount = (int)(random.NextFloat() * (factionPresence + 1)) + storyStations.Length; From 8a0a29e0deefad920e7ef1da7298ddd1de6a7418 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 20:26:30 +0200 Subject: [PATCH 103/113] Galaxy.IsPrelude is the one owner of whether a run is the prelude: ActionGameManager.IsTutorial and PlayerSettings.TutorialPassed are cut IsTutorial duplicated Galaxy.IsPrelude; its readers read CurrentGalaxy.IsPrelude, and RunSave.Capture writes SavedGame.IsTutorial from galaxy.IsPrelude with no isTutorial argument. TutorialPassed was never written and its only reader was New Game's unreachable main-galaxy branch, which goes with it (S2 brings the main galaxy back as a scenario). spawnturret's two identical generator branches collapse to one. Old player.cc files still load. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 21 ++------ Assets/Scripts/ServerShared/PlayerSettings.cs | 2 +- Assets/Scripts/ServerShared/SavedGame.cs | 4 +- Assets/Scripts/UI/MainMenu.cs | 25 --------- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 2 +- tests/Aetheria.Shared.Tests/ScenarioTests.cs | 52 +++++++++++++++++++ 6 files changed, 60 insertions(+), 46 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/ScenarioTests.cs diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index c1ee9cac4..de34c830d 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -106,7 +106,6 @@ public class ActionGameManager : MonoBehaviour } public static Galaxy CurrentGalaxy; - public static bool IsTutorial; public GameSettings Settings; //public string StarterShipTemplate = "Longinus"; @@ -250,7 +249,7 @@ public class ActionGameManager : MonoBehaviour { if (CurrentGalaxy != null) { - var (game, zones) = RunSave.Capture(CultCache, CurrentGalaxy, Zone, DockedEntity ?? CurrentEntity, IsTutorial, + var (game, zones) = RunSave.Capture(CultCache, CurrentGalaxy, Zone, DockedEntity ?? CurrentEntity, _actionBarSlots.Select(s => s.Save()).ToArray()); RunSave.Commit(CultCache, game, zones, ItemManager.Lots); } @@ -578,19 +577,7 @@ public class ActionGameManager : MonoBehaviour { var nearestFaction = CurrentGalaxy.Factions.MinBy(f => CurrentGalaxy.HomeZones[f].Distance[Zone.GalaxyZone]); - var loadoutGenerator = IsTutorial ? new LoadoutGenerator( - ref ItemManager.Random, - ItemManager, - CurrentGalaxy, - Zone.GalaxyZone, - nearestFaction, - .5f) : new LoadoutGenerator( - ref ItemManager.Random, - ItemManager, - CurrentGalaxy, - Zone.GalaxyZone, - nearestFaction, - .5f); + var loadoutGenerator = new LoadoutGenerator(ref ItemManager.Random, ItemManager, CurrentGalaxy, Zone.GalaxyZone, nearestFaction, .5f); var turret = EntitySerializer.Unpack(ItemManager, Zone, loadoutGenerator.GenerateTurretLoadout()); turret.Position.xz = _currentEntity.Position.xz + @@ -728,7 +715,7 @@ public class ActionGameManager : MonoBehaviour Settings.ZoneSettings, CurrentGalaxy, galaxyZone, - IsTutorial + CurrentGalaxy.IsPrelude ); galaxyZone.Contents = new Zone(ItemManager, Settings.PlanetSettings, galaxyZone.PackedContents, galaxyZone, CurrentGalaxy); } @@ -815,7 +802,7 @@ public class ActionGameManager : MonoBehaviour PopulateLevel(CurrentGalaxy.Entrance); var failures = new List(); var staged = RunStart.Stage(ItemManager, Zone, null, Settings.StartingHullName, - IsTutorial ? CurrentGalaxy.ResolveFaction(Settings.TutorialGenerationSettings.ProtagonistFaction) : null, failures) + CurrentGalaxy.IsPrelude ? CurrentGalaxy.ResolveFaction(Settings.TutorialGenerationSettings.ProtagonistFaction) : null, failures) ?? throw new InvalidOperationException($"The new run could not stage: {string.Join("; ", failures)}"); BindToEntity(staged.Player); } diff --git a/Assets/Scripts/ServerShared/PlayerSettings.cs b/Assets/Scripts/ServerShared/PlayerSettings.cs index d3c809fed..2566dcf59 100644 --- a/Assets/Scripts/ServerShared/PlayerSettings.cs +++ b/Assets/Scripts/ServerShared/PlayerSettings.cs @@ -10,7 +10,7 @@ using static CultMath.math; public class PlayerSettings { [Key(0)] public string Name = "Anonymous"; - [Key(2)] public bool TutorialPassed; + // Keys 1 and 2 are retired; an older player.cc that still carries them loads, and they are dropped. [Key(3)] public Dictionary HashedStoryFiles = new Dictionary(); [Key(4)] public PlayerGameplaySettings GameplaySettings = new PlayerGameplaySettings(); [Key(5)] public PlayerInputSettings InputSettings = new PlayerInputSettings(); diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index 71bd625af..a674df81d 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -55,7 +55,7 @@ public static class RunSave { // The live run as plain documents. Writes nothing. public static (SavedGame Game, SavedZone[] Zones) Capture(CultCache cache, Galaxy galaxy, Zone currentZone, - Entity currentEntity, bool isTutorial, SavedActionBarBinding[] actionBar) + Entity currentEntity, SavedActionBarBinding[] actionBar) { var factions = galaxy.HomeZones.Keys.ToArray(); var game = new SavedGame @@ -74,7 +74,7 @@ public static class RunSave CurrentZoneEntity = currentZone.Entities.IndexOf(currentEntity), Entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance), Exit = Array.IndexOf(galaxy.Zones, galaxy.Exit), - IsTutorial = isTutorial, + IsTutorial = galaxy.IsPrelude, ActionBarBindings = actionBar }; diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 859d17c95..9fcff8f14 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -111,7 +111,6 @@ public class MainMenu : MonoBehaviour { try { RunSave.RequireDesigns(cache, saved); } catch (InvalidOperationException error) { Refuse("Cannot continue this run", error.Message); return; } - ActionGameManager.IsTutorial = saved.IsTutorial; ActionGameManager.CurrentGalaxy = new Galaxy(cache, saved, Debug.Log); EnterGame(); }); @@ -130,29 +129,6 @@ public class MainMenu : MonoBehaviour Dialog.AddProperty(() => generatorState); Dialog.Show(); - if (ActionGameManager.PlayerSettings.TutorialPassed) - { - var backgroundSettings = MessagePackSerializer.Deserialize( - MessagePackSerializer.Serialize(Settings.SectorBackgroundSettings)); - backgroundSettings.NoisePosition = Random.value * 1000; - ActionGameManager.IsTutorial = false; - Task.Run(() => - { - var sector = new Galaxy( - Settings.SectorGenerationSettings, - backgroundSettings, - Settings.NameGeneratorSettings, - ActionGameManager.CultCache, - Debug.Log, - setState); - Observable.NextFrame().Subscribe(_ => - { - ActionGameManager.CurrentGalaxy = sector; - EnterGame(); - }); - }).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted); - } - else { var backgroundSettings = MessagePackSerializer.Deserialize( MessagePackSerializer.Serialize(Settings.TutorialBackgroundSettings)); @@ -163,7 +139,6 @@ public class MainMenu : MonoBehaviour setState($"Finding Galaxy Position: iteration {iteration++}"); } while (backgroundSettings.CloudDensity(float2(0.5f)) < .5f); - ActionGameManager.IsTutorial = true; Task.Run(() => { var sector = new Galaxy( diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 9384b93ed..6ce426995 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -91,7 +91,7 @@ public sealed class RunSaveTests : IDisposable var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); - var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, false, new SavedActionBarBinding[0]); + var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0]); RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); } diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs new file mode 100644 index 000000000..f35cded44 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Buffers; +using System.Collections.Generic; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; + +// Scenarios S2 (docs/scenarios-cut.md, R): every new run is a scenario. The fixture is RunStartTests': the shipped +// catalog, the authored settings and a prelude galaxy at a fixed seed. +public sealed partial class RunStartTests +{ + // Whether a run is the prelude is the galaxy's fact (Galaxy.IsPrelude); a save records it with no second owner. + [Fact] + public void SaveReadsPrelude() + { + var arena = Arena(null); + var (prelude, _) = RunSave.Capture(_cache, _galaxy, arena, null, new SavedActionBarBinding[0]); + Assert.True(prelude.IsTutorial); + + var (main, _) = RunSave.Capture(_cache, MainGalaxy(), arena, null, new SavedActionBarBinding[0]); + Assert.False(main.IsTutorial); + } + + // PlayerSettings Key 2 (TutorialPassed) is retired: a player.cc written with it still loads, every other field intact. + [Fact] + public void OldPlayerSettingsLoad() + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(PlayerSettings).Assembly); + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(7); + writer.Write("Pilot"); + writer.WriteNil(); // Key 1, retired before this cut + writer.Write(true); // Key 2, TutorialPassed + MessagePackSerializer.Serialize(ref writer, new Dictionary { ["story"] = "hash" }, options); + MessagePackSerializer.Serialize(ref writer, new PlayerGameplaySettings { TemperatureUnit = TemperatureUnit.Kelvin, SignificantDigits = 5 }, options); + MessagePackSerializer.Serialize(ref writer, new PlayerInputSettings { ActionBarInputs = { "/1" } }, options); + MessagePackSerializer.Serialize(ref writer, new PlayerGraphicsSettings { NebulaQuality = Quality.High, ShowAsteroidsInMinimap = true }, options); + writer.Flush(); + + var settings = MessagePackSerializer.Deserialize(buffer.WrittenMemory, options); + + Assert.Equal("Pilot", settings.Name); + Assert.Equal("hash", settings.HashedStoryFiles["story"]); + Assert.Equal((TemperatureUnit.Kelvin, 5), (settings.GameplaySettings.TemperatureUnit, settings.GameplaySettings.SignificantDigits)); + Assert.Equal("/1", Assert.Single(settings.InputSettings.ActionBarInputs)); + Assert.Equal((Quality.High, true), (settings.GraphicsSettings.NebulaQuality, settings.GraphicsSettings.ShowAsteroidsInMinimap)); + } +} From d0782a7d14df241e0eec39f9986130a71ffe84ba Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 20:33:55 +0200 Subject: [PATCH 104/113] Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit, all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario branch are cut. New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown, Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a new run; ActionGameManager.PendingScenario carries the scenario to StartGame once. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 13 +- Assets/Scripts/ServerShared/AetheriaStores.cs | 2 +- Assets/Scripts/ServerShared/GalaxyStage.cs | 86 ++++ .../Scripts/ServerShared/GalaxyStage.cs.meta | 11 + Assets/Scripts/ServerShared/RunStart.cs | 139 ++---- Assets/Scripts/ServerShared/Scenario.cs | 53 +-- Assets/Scripts/ServerShared/ScenarioStage.cs | 168 +++++++ .../ServerShared/ScenarioStage.cs.meta | 11 + Assets/Scripts/ServerShared/Scenarios.meta | 8 + Assets/Scripts/ServerShared/Scenarios/Arcs.cs | 46 ++ .../ServerShared/Scenarios/Arcs.cs.meta | 11 + .../ServerShared/Scenarios/DjinniShakedown.cs | 15 + .../Scenarios/DjinniShakedown.cs.meta | 11 + Assets/Scripts/ServerShared/Scenarios/Duel.cs | 35 ++ .../ServerShared/Scenarios/Duel.cs.meta | 11 + .../ServerShared/Scenarios/LauncherAngles.cs | 33 ++ .../Scenarios/LauncherAngles.cs.meta | 11 + .../ServerShared/Scenarios/LongHaul.cs | 14 + .../ServerShared/Scenarios/LongHaul.cs.meta | 11 + .../ServerShared/Scenarios/MainGalaxy.cs | 13 + .../ServerShared/Scenarios/MainGalaxy.cs.meta | 11 + .../ServerShared/Scenarios/Scenarios.cs | 11 + .../ServerShared/Scenarios/Scenarios.cs.meta | 11 + .../ServerShared/Scenarios/StarvedReactor.cs | 29 ++ .../Scenarios/StarvedReactor.cs.meta | 11 + .../ServerShared/Scenarios/TutorialGalaxy.cs | 16 + .../Scenarios/TutorialGalaxy.cs.meta | 11 + Assets/Scripts/UI/MainMenu.cs | 108 +++-- .../HardpointOwnershipTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 446 ++---------------- tests/Aetheria.Shared.Tests/ScenarioTests.cs | 426 ++++++++++++++++- 31 files changed, 1173 insertions(+), 611 deletions(-) create mode 100644 Assets/Scripts/ServerShared/GalaxyStage.cs create mode 100644 Assets/Scripts/ServerShared/GalaxyStage.cs.meta create mode 100644 Assets/Scripts/ServerShared/ScenarioStage.cs create mode 100644 Assets/Scripts/ServerShared/ScenarioStage.cs.meta create mode 100644 Assets/Scripts/ServerShared/Scenarios.meta create mode 100644 Assets/Scripts/ServerShared/Scenarios/Arcs.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/Arcs.cs.meta create mode 100644 Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs.meta create mode 100644 Assets/Scripts/ServerShared/Scenarios/Duel.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/Duel.cs.meta create mode 100644 Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs.meta create mode 100644 Assets/Scripts/ServerShared/Scenarios/LongHaul.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/LongHaul.cs.meta create mode 100644 Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs.meta create mode 100644 Assets/Scripts/ServerShared/Scenarios/Scenarios.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/Scenarios.cs.meta create mode 100644 Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs.meta create mode 100644 Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs.meta diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index de34c830d..8ebcf413a 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -106,6 +106,8 @@ public class ActionGameManager : MonoBehaviour } public static Galaxy CurrentGalaxy; + // The scenario a new run starts from: MainMenu.Launch sets it, and StartGame's new-run branch consumes it once. + public static Scenario PendingScenario; public GameSettings Settings; //public string StarterShipTemplate = "Longinus"; @@ -796,14 +798,15 @@ public class ActionGameManager : MonoBehaviour var saved = CultCache.GetGlobal(); if (saved == null) { + var scenario = PendingScenario ?? throw new InvalidOperationException("A new run needs a scenario; MainMenu.Launch sets one."); + PendingScenario = null; SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance)); - // RunStart owns what a new run starts with (docs/scenarios-cut.md, 2.2); this only enters and binds. - RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, CurrentGalaxy, null); + // RunStart owns what a new run starts with (docs/scenarios-cut.md, R.4); this only enters and binds. + RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, CurrentGalaxy, scenario); PopulateLevel(CurrentGalaxy.Entrance); var failures = new List(); - var staged = RunStart.Stage(ItemManager, Zone, null, Settings.StartingHullName, - CurrentGalaxy.IsPrelude ? CurrentGalaxy.ResolveFaction(Settings.TutorialGenerationSettings.ProtagonistFaction) : null, failures) - ?? throw new InvalidOperationException($"The new run could not stage: {string.Join("; ", failures)}"); + var staged = RunStart.Stage(ItemManager, Zone, scenario, Settings.StartingHullName, Settings.TutorialGenerationSettings, failures) + ?? throw new InvalidOperationException($"Scenario {scenario.Name} could not stage: {string.Join("; ", failures)}"); BindToEntity(staged.Player); } else diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 55d8c2f92..8f0816ef9 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -6,7 +6,7 @@ using GameCult.Caching.MessagePack; // Aetheria's stores: one home store per type, composed here and nowhere else. public static class AetheriaStores { - public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout), typeof(Scenario) }; + public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) }; public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) }; public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; diff --git a/Assets/Scripts/ServerShared/GalaxyStage.cs b/Assets/Scripts/ServerShared/GalaxyStage.cs new file mode 100644 index 000000000..f4ac7d5f8 --- /dev/null +++ b/Assets/Scripts/ServerShared/GalaxyStage.cs @@ -0,0 +1,86 @@ +using System; +using System.IO; +using GameCult.Caching; +using MessagePack; +using static CultMath.math; +using Random = CultMath.Random; + +// The galaxy half of a scenario's vocabulary (docs/scenarios-cut.md, R.4): the two kinds of galaxy the game generates, +// at the scenario's seed. The menu hands it the generation inputs; RunStart.Generate sets the seed and hands it to +// Scenario.Generate. The background's noise position derives from the same seed, so a fixed seed fixes the whole +// galaxy, and a clock seed makes every launch a new one. +public sealed class GalaxyStage +{ + private readonly SectorGenerationSettings _sector; + private readonly SectorBackgroundSettings _sectorBackground; + private readonly TutorialGenerationSettings _prelude; + private readonly SectorBackgroundSettings _preludeBackground; + private readonly NameGeneratorSettings _names; + private readonly CultCache _cache; + private readonly PlayerSettings _playerSettings; + private readonly DirectoryInfo _narrativeDirectory; + private readonly Action _log; + private readonly Action _progress; + private readonly Func _clock; + + public GalaxyStage( + SectorGenerationSettings sector, + SectorBackgroundSettings sectorBackground, + TutorialGenerationSettings prelude, + SectorBackgroundSettings preludeBackground, + NameGeneratorSettings names, + CultCache cache, + PlayerSettings playerSettings, + DirectoryInfo narrativeDirectory, + Action log, + Action progress = null, + Func clock = null) + { + _sector = sector; + _sectorBackground = sectorBackground; + _prelude = prelude; + _preludeBackground = preludeBackground; + _names = names; + _cache = cache; + _playerSettings = playerSettings; + _narrativeDirectory = narrativeDirectory; + _log = log; + _progress = progress; + _clock = clock ?? (() => (uint) (DateTime.Now.Ticks % uint.MaxValue)); + } + + // The seed this generation runs at, never zero: the scenario's, or the clock's when the scenario's is zero. + public uint Seed { get; private set; } + + internal void SeedFrom(Scenario scenario) + { + var clock = _clock(); + Seed = scenario.Seed != 0 ? scenario.Seed : clock != 0 ? clock : 1; + } + + // A main galaxy: the sector settings, factions drawn from the catalog, an exit to reach. + public Galaxy Main() + { + var background = Copy(_sectorBackground); + var random = new Random(Seed); + background.NoisePosition = random.NextFloat() * 1000; + return new Galaxy(_sector, background, _names, _cache, _log, _progress, Seed); + } + + // A prelude galaxy: the tutorial settings and their fixed factions, centred in dense cloud. + public Galaxy Prelude() + { + var background = Copy(_preludeBackground); + var random = new Random(Seed); + var iteration = 1; + do + { + background.NoisePosition = random.NextFloat() * 1000; + _progress?.Invoke($"Finding Galaxy Position: iteration {iteration++}"); + } while (background.CloudDensity(float2(.5f)) < .5f); + return new Galaxy(_prelude, background, _names, _cache, _playerSettings, _narrativeDirectory, _log, _progress, Seed); + } + + private static SectorBackgroundSettings Copy(SectorBackgroundSettings settings) => + MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(settings)); +} diff --git a/Assets/Scripts/ServerShared/GalaxyStage.cs.meta b/Assets/Scripts/ServerShared/GalaxyStage.cs.meta new file mode 100644 index 000000000..9d23d3f4a --- /dev/null +++ b/Assets/Scripts/ServerShared/GalaxyStage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f5cf458f47074786bf11e72b5d93c545 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index 53807b562..1eab125bb 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -1,131 +1,54 @@ using System; using System.Collections.Generic; using System.Linq; -using CultMath; -using static CultMath.math; -using float2 = CultMath.float2; -using float3 = CultMath.float3; -// The owner of what a new run starts with (docs/scenarios-cut.md, 2.2): how the arena zone is generated, the player -// ship, and a scenario's other entities with their stance, pilot and cargo. A plain New Game is the absent-scenario -// case of the same path. Nothing else generates, unpacks or admits a new run's player; everything it stages joins the -// arena through Zone.Admit. It holds no Unity type. +// The owner of a new run (docs/scenarios-cut.md, R.4). Every new run is a scenario: RunStart generates its galaxy, +// generates its arena (the galaxy's entrance zone) under the scenario's Ambient rule, and admits what the scenario +// stages through Zone.Admit, all of it or none of it. A scenario only declares; nothing else generates a new run's +// galaxy, generates or admits its player, or chooses its hull or faction. It holds no Unity type. public static class RunStart { public sealed class Staged { public Ship Player; - public List Entities; // the scenario's entities, in its order; empty with no scenario + public List Entities; // what the scenario placed besides the player, in its order + } + + // The scenario's galaxy, at the scenario's seed or, when that is zero, the clock's. + public static Galaxy Generate(Scenario scenario, GalaxyStage stage) + { + stage.SeedFrom(scenario); + return scenario.Generate(stage) ?? throw new InvalidOperationException($"Scenario {scenario.Name} generated no galaxy."); } - // The arena is the galaxy's entrance zone, generated under the scenario's Ambient rule; with no scenario, ambient. public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, Galaxy galaxy, Scenario scenario) => galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone( - itemManager, zoneSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario?.Ambient ?? true); + itemManager, zoneSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario.Ambient); - // The check entry: every failure staging this scenario would hit, found with no zone. With no galaxy there is no - // availability to filter by (LoadoutGenerator.IsAvailable), and a scenario's arena is a prelude, which offers every - // product anyway. It touches no store, so the menu can call it before it clears the saved run. - public static List Check(ItemManager itemManager, Scenario scenario) - { - var failures = new List(); - if (scenario != null) Build(itemManager, null, scenario, _ => true, failures); - return failures; - } - - // Stages a new run into its arena, all-or-nothing: null, with every failure listed and the zone unchanged, unless - // everything builds. With no scenario: a generated ship of the startingHull (any ship hull when it is empty) for - // defaultFaction, at the origin. With one: its player and entities, each at its authored position and heading with - // its cargo, each entity's stance set both ways with the player, and a Minion for each piloted one. + // Runs the scenario's Stage, then admits its player and everything it placed: each entity's stance set both ways + // with the player, a pilot for each piloted one. Returns null, with every failure listed and the arena unchanged, + // unless the whole scenario staged. public static Staged Stage(ItemManager itemManager, Zone arena, Scenario scenario, string startingHull, - Faction defaultFaction, List failures) - { - if (scenario == null) - { - var generator = new LoadoutGenerator(ref itemManager.Random, itemManager, arena.Galaxy, arena.GalaxyZone, defaultFaction, 2); - var pack = generator.GenerateShipLoadout(data => string.IsNullOrEmpty(startingHull) || data.Name == startingHull); - if (pack == null) - { - failures.Add($"player: no ship hull named {startingHull}"); - return null; - } - - var ship = (Ship) EntitySerializer.Unpack(itemManager, arena, pack); - ship.IsPlayerShip = true; - ship.Position = float3.zero; - arena.Admit(ship, piloted: false); - return new Staged { Player = ship, Entities = new List() }; - } - - // Presets carry no faction, so availability is the galaxy's alone. - var availability = new LoadoutGenerator(ref itemManager.Random, itemManager, arena.Galaxy, arena.GalaxyZone, null, 2); - var built = Build(itemManager, arena, scenario, availability.IsAvailable, failures); - if (built == null) return null; - - var (player, entities) = built.Value; - player.IsPlayerShip = true; - arena.Admit(player, piloted: false); - for (var i = 0; i < entities.Count; i++) - { - var hostile = scenario.Entities[i].Stance == ScenarioStance.Hostile; - entities[i].SetIff(player, hostile); - player.SetIff(entities[i], hostile); - arena.Admit(entities[i], scenario.Entities[i].Piloted); - } - return new Staged { Player = player, Entities = entities }; - } - - // Everything a scenario places, built and positioned but admitted nowhere; null when anything failed. - private static (Ship player, List entities)? Build(ItemManager itemManager, Zone zone, Scenario scenario, - Predicate isAvailable, List failures) + TutorialGenerationSettings tutorialGenerationSettings, List failures) { + var stage = new ScenarioStage(itemManager, arena, startingHull, tutorialGenerationSettings); + scenario.Stage(stage); var reported = failures.Count; - Entity player = null; - if (scenario.Player == null) failures.Add("player: the scenario places no player"); - else - { - player = Place(itemManager, zone, scenario.Player, "player", isAvailable, failures); - if (player != null && !(player is Ship)) failures.Add($"player: {player.Name} is not a ship hull"); - } - - var entities = new List(); - for (var i = 0; i < scenario.Entities.Count; i++) - { - var where = $"entity {i}"; - var entity = Place(itemManager, zone, scenario.Entities[i], where, isAvailable, failures); - if (entity != null && scenario.Entities[i].Piloted && !(entity is Ship)) - failures.Add($"{where}: {entity.Name} is not a ship hull, so it cannot be piloted"); - entities.Add(entity); - } - return failures.Count > reported ? null : ((Ship) player, entities); - } - - private static Entity Place(ItemManager itemManager, Zone zone, ScenarioShip placed, string where, - Predicate isAvailable, List failures) - { - var loadout = itemManager.ItemData.Get(placed.Loadout); - if (loadout == null) - { - failures.Add($"{where}: preset {placed.Loadout.Key} is not in the catalog"); - return null; - } - - var reported = failures.Count; - var materialized = new List(); - var entity = Loadouts.Materialize(itemManager, zone, loadout, isAvailable, materialized); - failures.AddRange(materialized.Select(failure => $"{where} ({loadout.Name}): {failure}")); - var cargo = placed.Cargo.Select(design => Loadouts.Resolve(itemManager, design, isAvailable, $"{where} cargo", failures)).ToList(); + failures.AddRange(stage.Failures); + if (!stage.PlayerPlaced) + failures.Add("player: the scenario places no player"); if (failures.Count > reported) return null; - entity.Position.xz = placed.Position; - if (lengthsq(placed.Direction) > 0) entity.Direction = normalize(placed.Direction); - var bay = entity.CargoBays.FirstOrDefault(); - foreach (var build in cargo) + var player = stage.PlayerShip; + player.IsPlayerShip = true; + arena.Admit(player, piloted: false); + foreach (var placed in stage.Placed) { - var item = build(); - if (bay == null || !bay.TryStore(item)) - failures.Add($"{where} cargo: no room for {itemManager.GetData(item).Name}"); + var hostile = placed.Stance == ScenarioStance.Hostile; + placed.Entity.SetIff(player, hostile); + player.SetIff(placed.Entity, hostile); + arena.Admit(placed.Entity, placed.Piloted); } - return failures.Count > reported ? null : entity; + return new Staged { Player = player, Entities = stage.Placed.Select(placed => placed.Entity).ToList() }; } } diff --git a/Assets/Scripts/ServerShared/Scenario.cs b/Assets/Scripts/ServerShared/Scenario.cs index d2d0da48f..ba7165a19 100644 --- a/Assets/Scripts/ServerShared/Scenario.cs +++ b/Assets/Scripts/ServerShared/Scenario.cs @@ -1,38 +1,27 @@ -using System.Collections.Generic; -using GameCult.Caching; -using MessagePack; -using CultMath; - -// A scenario is an arena whose conditions are present at spawn (docs/scenarios-cut.md): the player's ship and every -// other entity already fitted and placed. Authored catalog data, read at runtime; RunStart stages it into a new run. -// It sets conditions and nothing else: once staged, its entities are ordinary run state. -[CultDocument("aetheria.scenario", "1"), MessagePackObject] -public class Scenario +// A scenario is one way to set up a new run (docs/scenarios-cut.md, R): a script, compiled with the game, that says +// which galaxy to generate and what the run starts with. New Game lists them (Scenarios). RunStart runs one: it +// generates the galaxy through GalaxyStage, the arena under Ambient, and admits what Stage placed through a +// ScenarioStage. A scenario only declares; once staged, everything it placed is ordinary run state. +public abstract class Scenario { - [CultName, Key(0)] public string Name; - [Key(1)] public string Brief; // the conditions in a line or two, shown in the menu - [Key(2)] public uint Seed; // the arena galaxy derives from it, so every launch has one layout - [Key(3)] public bool Ambient; // false: planets, orbits and stations, but no generated ship or turret - [Key(4)] public ScenarioShip Player; - [Key(5)] public List Entities = new List(); -} + public abstract string Name { get; } -// A preset placed in the arena. A ship hull stages a Ship; a turret hull an orbital entity with no orbit, which stays -// where it is put. -[MessagePackObject] -public class ScenarioShip -{ - [Key(0)] public CultRecordRef Loadout; - [Key(1)] public float2 Position; // zone xz - [Key(2)] public float2 Direction; // zero keeps the entity's default heading - [Key(3)] public List> Cargo = new List>(); // first cargo bay -} + // The conditions and what to verify in them, shown under the scenario's button. + public abstract string Brief { get; } -[MessagePackObject] -public class ScenarioEntity : ScenarioShip -{ - [Key(4)] public ScenarioStance Stance; // applied both ways between this entity and the player - [Key(5)] public bool Piloted; // a Minion flies it; otherwise it has no agent and stays put + // Zero draws the seed from the clock, so each launch is a new galaxy. Anything else fixes the galaxy, its + // background and its arena, so every launch has the same layout. + public virtual uint Seed => 0; + + // False: the arena keeps its planets, orbits and stations but generates no ship or turret, so nothing wanders into + // the scenario's conditions. + public virtual bool Ambient => true; + + // Which galaxy the run is in: stage.Main() or stage.Prelude(). + public abstract Galaxy Generate(GalaxyStage stage); + + // What the run starts with: exactly one stage.Player, and anything else the scenario places. + public abstract void Stage(ScenarioStage stage); } public enum ScenarioStance diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs new file mode 100644 index 000000000..241684a35 --- /dev/null +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// The run half of a scenario's vocabulary (docs/scenarios-cut.md, R.4). A scenario's Stage builds its fits and places +// them here; nothing joins the arena until Stage returns with no failure, and then RunStart admits it all at once. +// Every verb names what it could not do instead of throwing, so one bad name in a script lists every bad name. +// Designs and hulls are named as the catalog names them. Only RunStart builds a stage. +public sealed class ScenarioStage +{ + private readonly ItemManager _items; + private readonly Predicate _available; + private readonly List _failures = new List(); + private readonly List _placed = new List(); + private int _placements; + + internal ScenarioStage(ItemManager items, Zone arena, string startingHull, TutorialGenerationSettings tutorialGenerationSettings) + { + _items = items; + Arena = arena; + StartingHull = startingHull; + TutorialGenerationSettings = tutorialGenerationSettings; + // A fit names designs, not manufacturers, so what the galaxy offers decides which product builds each one. + _available = new LoadoutGenerator(ref items.Random, items, arena.Galaxy, arena.GalaxyZone, null, 2).IsAvailable; + } + + public Galaxy Galaxy => Arena.Galaxy; + public Zone Arena { get; } + public string StartingHull { get; } + public TutorialGenerationSettings TutorialGenerationSettings { get; } + + internal IReadOnlyList Failures => _failures; + internal bool PlayerPlaced { get; private set; } + internal Ship PlayerShip { get; private set; } + internal IReadOnlyList Placed => _placed; + + internal sealed class Placement + { + public Entity Entity; + public ScenarioStance Stance; + public bool Piloted; + } + + // A hull fitted slot by slot: each slot a design, the hull cell it goes in, and its rotation. + public ScenarioFit Fit(string hull, params (string design, int2 cell, ItemRotation rotation)[] slots) + { + var where = $"fit {hull}"; + var hullData = Named(hull, where); + var loadout = new Loadout { Name = hull, WeaponGroups = new int[0][] }; + if (hullData != null) loadout.Hull = _items.ItemData.RefOf(hullData); + foreach (var (design, cell, rotation) in slots) + { + var data = Named(design, where); + if (data == null) continue; + loadout.Slots.Add(new LoadoutSlot { Design = _items.ItemData.RefOf(data), Position = cell, Rotation = rotation }); + } + return hullData != null && loadout.Slots.Count == slots.Length ? new ScenarioFit(loadout) : null; + } + + // A hull with nothing fitted. + public ScenarioFit Bare(string hull) => Fit(hull); + + // A fit generated as the game generates its ships, for the faction when one is given. An empty hull means any + // ship hull. + public ScenarioFit Generated(string hull, Faction faction = null) + { + var generator = new LoadoutGenerator(ref _items.Random, _items, Galaxy, Arena.GalaxyZone, faction, 2); + var pack = generator.GenerateShipLoadout(data => string.IsNullOrEmpty(hull) || data.Name == hull); + if (pack != null) return new ScenarioFit(pack); + _failures.Add($"generated: no ship hull named {hull}"); + return null; + } + + // A loadout preset from the catalog, as capturepreset or Studio wrote it. + public ScenarioFit Preset(string name) + { + var preset = _items.ItemData.GetAll().FirstOrDefault(loadout => loadout.Name == name); + if (preset != null) return new ScenarioFit(preset); + _failures.Add($"preset {name}: no such preset"); + return null; + } + + // The player's ship. A run has exactly one, and it is a ship hull. + public Ship Player(ScenarioFit fit, float2 at, float2 facing = default) + { + if (PlayerPlaced) + { + _failures.Add("player: the scenario places a second player"); + return null; + } + PlayerPlaced = true; + var entity = Build(fit, "player", at, facing); + if (entity == null) return null; + if (entity is Ship ship) return PlayerShip = ship; + _failures.Add($"player: {entity.Name} is not a ship hull"); + return null; + } + + // Anything else in the arena. A stance holds both ways between it and the player. A piloted ship gets the zone's + // pilot; an unpiloted entity has none and goes nowhere on its own. A turret hull stays where it is put. + public Entity Place(ScenarioFit fit, float2 at, float2 facing = default, ScenarioStance stance = ScenarioStance.Neutral, bool piloted = false) + { + var where = $"entity {_placements++}"; + var entity = Build(fit, where, at, facing); + if (entity == null) return null; + if (piloted && !(entity is Ship)) _failures.Add($"{where}: {entity.Name} is not a ship hull, so it cannot be piloted"); + _placed.Add(new Placement { Entity = entity, Stance = stance, Piloted = piloted }); + return entity; + } + + // Items in the entity's first cargo bay, one per design named. + public void Cargo(Entity entity, params string[] designs) + { + if (entity == null) return; + var where = $"cargo of {entity.Name}"; + var bay = entity.CargoBays.FirstOrDefault(); + foreach (var design in designs) + { + var data = Named(design, where); + if (data == null) continue; + var build = Loadouts.Resolve(_items, _items.ItemData.RefOf(data), _available, where, _failures); + if (build != null && (bay == null || !bay.TryStore(build()))) + _failures.Add($"{where}: no room for {design}"); + } + } + + private Entity Build(ScenarioFit fit, string where, float2 at, float2 facing) + { + if (fit == null) return null; // the verb that made it named why + + Entity entity; + if (fit.Pack != null) entity = EntitySerializer.Unpack(_items, Arena, fit.Pack); + else + { + var failures = new List(); + entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _available, failures); + _failures.AddRange(failures.Select(failure => $"{where} ({fit.Loadout.Name}): {failure}")); + if (entity == null) return null; + } + + entity.Position = float3(at.x, 0, at.y); + if (lengthsq(facing) > 0) entity.Direction = normalize(facing); + return entity; + } + + private T Named(string name, string where) where T : EquippableItemData + { + var named = _items.ItemData.GetAll().Where(data => data.Name == name).Take(2).ToArray(); + if (named.Length == 1) return named[0]; + _failures.Add(named.Length == 0 ? $"{where}: no design named {name}" : $"{where}: more than one design is named {name}"); + return null; + } +} + +// What a scenario places: a fit built from named designs or a preset, or a ship generated as the game generates them. +// Only ScenarioStage makes one. +public sealed class ScenarioFit +{ + internal readonly Loadout Loadout; + internal readonly EntityPack Pack; + + internal ScenarioFit(Loadout loadout) => Loadout = loadout; + internal ScenarioFit(EntityPack pack) => Pack = pack; +} diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs.meta b/Assets/Scripts/ServerShared/ScenarioStage.cs.meta new file mode 100644 index 000000000..91b73afb6 --- /dev/null +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d8cd00dc23604dfb91e128e1a9a4a3d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios.meta b/Assets/Scripts/ServerShared/Scenarios.meta new file mode 100644 index 000000000..d6472c75c --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 35866d82219241ae9fdb0030039afbed +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs new file mode 100644 index 000000000..d1ceb61e0 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -0,0 +1,46 @@ +using static CultMath.math; +using static ItemRotation; + +public sealed class Arcs : Scenario +{ + public override string Name => "Arcs"; + public override string Brief => "Forward mounts, a bare hull off the bow, one off the stern, a hostile turret. Verify: the bow " + + "hull can be hit, nothing fires at the stern one through the ship, the turret tracks you all the way round."; + public override uint Seed => 103; + public override bool Ambient => false; + + // Where the targets sit: the player is at the origin facing +z, and every mount fires forward. + public static readonly CultMath.float2 Bow = float2(0, 400), Stern = float2(0, -400), Turret = float2(600, 0); + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); + + public override void Stage(ScenarioStage stage) + { + var fighter = stage.Fit("LonginusX", + ("Cockpit 2x2", int2(2, 6), None), + ("Traction", int2(2, 3), None), + ("Core Power", int2(2, 1), None), + ("GT 3K", int2(0, 5), None), + ("GT 3K", int2(5, 5), None), + ("FastBlast+-", int2(1, 8), None), + ("FastBlast+-", int2(4, 8), None), + ("Iapyx", int2(1, 2), CounterClockwise), + ("Iapyx", int2(4, 2), Clockwise), + ("not if i see you first", int2(3, 10), None), + ("Fire Control Array", int2(2, 5), None)); + var turret = stage.Fit("Turret", + ("ClearPath", int2(1, 2), None), + ("ClearPath", int2(5, 2), None), + ("Skiron", int2(0, 2), CounterClockwise), + ("Skiron", int2(7, 2), Clockwise), + ("Core Power", int2(3, 3), None), + ("Turret Control Module", int2(3, 5), None), + ("not if i see you first", int2(4, 5), None), + ("Fire Control Array", int2(3, 6), None)); + + stage.Player(fighter, float2(0, 0), facing: float2(0, 1)); + stage.Place(stage.Bare("LonginusX"), Bow); + stage.Place(stage.Bare("LonginusX"), Stern); + stage.Place(turret, Turret, stance: ScenarioStance.Hostile); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs.meta b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs.meta new file mode 100644 index 000000000..70bff8250 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 724a07b881f34e8c9b32b3f2f338ac78 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs b/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs new file mode 100644 index 000000000..720fa2d08 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs @@ -0,0 +1,15 @@ +using float2 = CultMath.float2; + +public sealed class DjinniShakedown : Scenario +{ + public override string Name => "Djinni Shakedown"; + public override string Brief => "A fitted Djinni in empty space. Verify: its thrusters move and turn it; with the hull " + + "offline and thrusters firing, its speed does not run away."; + public override uint Seed => 101; + public override bool Ambient => false; + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); + + public override void Stage(ScenarioStage stage) => + stage.Player(stage.Generated("Djinni"), float2.zero); +} diff --git a/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs.meta b/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs.meta new file mode 100644 index 000000000..4f25ab345 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1afb559630c241ed813ddd3c444c618b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/Duel.cs b/Assets/Scripts/ServerShared/Scenarios/Duel.cs new file mode 100644 index 000000000..fa270a4bd --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Duel.cs @@ -0,0 +1,35 @@ +using static CultMath.math; +using static ItemRotation; + +public sealed class Duel : Scenario +{ + public override string Name => "Duel"; + public override string Brief => "One piloted, hostile LonginusX with slow GT 3K missiles and a Spectra in its hold. Verify: rolled " + + "hits and near-misses, subsystem aim, beam reads above bow, armour takes the missile, loot keeps its brand."; + public override uint Seed => 104; + public override bool Ambient => false; + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); + + public override void Stage(ScenarioStage stage) + { + var fighter = stage.Fit("LonginusX", + ("Cockpit 2x2", int2(2, 6), None), + ("Traction", int2(2, 3), None), + ("Core Power", int2(2, 1), None), + ("GT 3K", int2(0, 5), None), + ("GT 3K", int2(5, 5), None), + ("FastBlast+-", int2(1, 8), None), + ("FastBlast+-", int2(4, 8), None), + ("Iapyx", int2(1, 2), CounterClockwise), + ("Iapyx", int2(4, 2), Clockwise), + ("not if i see you first", int2(3, 10), None), + ("PotaT+-", int2(1, 5), None), + ("Fire Control Array", int2(2, 5), None), + ("Store-All Plus", int2(2, 8), None)); + + stage.Player(fighter, float2(0, 0)); + var rival = stage.Place(fighter, float2(0, 900), facing: float2(0, -1), stance: ScenarioStance.Hostile, piloted: true); + stage.Cargo(rival, "Spectra"); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/Duel.cs.meta b/Assets/Scripts/ServerShared/Scenarios/Duel.cs.meta new file mode 100644 index 000000000..4acea5ed7 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Duel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6ba813d4a9447c89e1bbb6997b7ac35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs new file mode 100644 index 000000000..a734be6e1 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs @@ -0,0 +1,33 @@ +using static CultMath.math; +using static ItemRotation; + +public sealed class LauncherAngles : Scenario +{ + public override string Name => "Launcher Angles"; + public override string Brief => "A GT 3K, a pswarm and two FastBlasts; a hostile LonginusX broadside on, a bare Longinus beside it. " + + "Verify: hits pulse only the facing edge; a pswarm round whose target dies fades unexploded."; + public override uint Seed => 105; + public override bool Ambient => false; + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); + + public override void Stage(ScenarioStage stage) + { + var launcher = stage.Fit("LonginusX", + ("Cockpit 2x2", int2(2, 6), None), + ("Traction", int2(2, 3), None), + ("Core Power", int2(2, 1), None), + ("GT 3K", int2(0, 5), None), + ("pswarm", int2(5, 5), None), + ("FastBlast+-", int2(1, 8), None), + ("FastBlast+-", int2(4, 8), None), + ("Iapyx", int2(1, 2), CounterClockwise), + ("Iapyx", int2(4, 2), Clockwise), + ("not if i see you first", int2(3, 10), None), + ("Fire Control Array", int2(2, 5), None)); + + stage.Player(launcher, float2(0, 0)); + stage.Place(stage.Bare("LonginusX"), float2(0, 900), facing: float2(1, 0), stance: ScenarioStance.Hostile); + stage.Place(stage.Bare("Longinus"), float2(300, 700)); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs.meta b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs.meta new file mode 100644 index 000000000..82a27fcac --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 58cfaf062ddf458c99c12f3652243b45 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs b/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs new file mode 100644 index 000000000..bb5381b10 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs @@ -0,0 +1,14 @@ +using float2 = CultMath.float2; + +public sealed class LongHaul : Scenario +{ + public override string Name => "Long Haul"; + public override string Brief => "A standard ship in a populated galaxy. Verify: memory stays flat across zones with kills; " + + "warp, quit and Continue keep items, tier and brand; death disables Continue."; + public override uint Seed => 106; + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); + + public override void Stage(ScenarioStage stage) => + stage.Player(stage.Generated(stage.StartingHull), float2.zero); +} diff --git a/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs.meta b/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs.meta new file mode 100644 index 000000000..984d274d6 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a7911611f9845709e9ae5ba34d91dfb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs b/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs new file mode 100644 index 000000000..f32e205d3 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs @@ -0,0 +1,13 @@ +using float2 = CultMath.float2; + +// The full galaxy: factions drawn from the catalog, bosses on the road to the exit, entered in the starting hull. +public sealed class MainGalaxy : Scenario +{ + public override string Name => "Main Galaxy"; + public override string Brief => "The full galaxy: factions drawn at random, bosses guarding the road to the exit."; + + public override Galaxy Generate(GalaxyStage stage) => stage.Main(); + + public override void Stage(ScenarioStage stage) => + stage.Player(stage.Generated(stage.StartingHull), float2.zero); +} diff --git a/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs.meta b/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs.meta new file mode 100644 index 000000000..4e862188d --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c7f6bdb3ed7b4b1aa137569c7e79bccd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs new file mode 100644 index 000000000..31890d789 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs @@ -0,0 +1,11 @@ +// Every way to set up a new run, in the order New Game lists them. Modes are the game; Tests are arenas built to check +// one system each, listed only in editor and development builds. +public static class Scenarios +{ + public static readonly Scenario[] Modes = { new TutorialGalaxy(), new MainGalaxy() }; + + public static readonly Scenario[] Tests = + { + new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new LongHaul() + }; +} diff --git a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs.meta b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs.meta new file mode 100644 index 000000000..4c856d4d9 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12f60a9ad25c4aa0a9b8b098d385ea0e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs new file mode 100644 index 000000000..ccbf9baa8 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs @@ -0,0 +1,29 @@ +using static CultMath.math; +using static ItemRotation; + +public sealed class StarvedReactor : Scenario +{ + public override string Name => "Starved Reactor"; + public override string Brief => "Two Spectras on a MoveOnPro, which can fall to a quarter of Core Power's charge, and a bare hull " + + "to shoot. Verify: starvation reads as starvation, brownout degrades, refills stutter."; + public override uint Seed => 102; + public override bool Ambient => false; + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); + + public override void Stage(ScenarioStage stage) + { + var starved = stage.Fit("LonginusX", + ("Cockpit 2x2", int2(2, 6), None), + ("Traction", int2(2, 3), None), + ("MoveOnPro", int2(2, 1), None), + ("Spectra", int2(1, 8), None), + ("Spectra", int2(4, 8), None), + ("Iapyx", int2(1, 2), CounterClockwise), + ("Iapyx", int2(4, 2), Clockwise), + ("not if i see you first", int2(3, 10), None), + ("Fire Control Array", int2(2, 5), None)); + stage.Player(starved, float2(0, 0)); + stage.Place(stage.Bare("LonginusX"), float2(0, 400)); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs.meta b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs.meta new file mode 100644 index 000000000..e13077120 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 91a81ac4784c4066969cac11ac1937b0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs b/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs new file mode 100644 index 000000000..161a52348 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs @@ -0,0 +1,16 @@ +using float2 = CultMath.float2; + +// New Game as it has always been: a prelude galaxy, entered in the starting hull of the protagonist faction. +public sealed class TutorialGalaxy : Scenario +{ + public override string Name => "Tutorial Galaxy"; + public override string Brief => "A small galaxy of fixed factions, entered in a ship of the protagonist faction."; + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); + + public override void Stage(ScenarioStage stage) + { + var protagonist = stage.Galaxy.ResolveFaction(stage.TutorialGenerationSettings.ProtagonistFaction); + stage.Player(stage.Generated(stage.StartingHull, protagonist), float2.zero); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs.meta b/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs.meta new file mode 100644 index 000000000..3f7c8777a --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 94d688b37c5044dd9b67865c706d8918 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 9fcff8f14..b5bdab2ee 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -5,15 +5,12 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using MessagePack; using TMPro; using UniRx; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.UI; using static CultMath.math; -using float2 = CultMath.float2; -using Random = UnityEngine.Random; public class MainMenu : MonoBehaviour { @@ -118,45 +115,8 @@ public class MainMenu : MonoBehaviour _nextMenu.panel.AddButton("New Game", () => { - RunSave.Clear(ActionGameManager.CultCache); - var generatorState = "Loading Database Contents"; - Action setState = s => generatorState = s; - + ShowScenarios(); Fade(true); - _nextMenu.panel.gameObject.SetActive(false); - Dialog.Clear(); - Dialog.Title.text = "Generating Galaxy"; - Dialog.AddProperty(() => generatorState); - Dialog.Show(); - - { - var backgroundSettings = MessagePackSerializer.Deserialize( - MessagePackSerializer.Serialize(Settings.TutorialBackgroundSettings)); - int iteration = 1; - do - { - backgroundSettings.NoisePosition = Random.value * 1000; - setState($"Finding Galaxy Position: iteration {iteration++}"); - } while (backgroundSettings.CloudDensity(float2(0.5f)) < .5f); - - Task.Run(() => - { - var sector = new Galaxy( - Settings.TutorialGenerationSettings, - backgroundSettings, - Settings.NameGeneratorSettings, - ActionGameManager.CultCache, - ActionGameManager.PlayerSettings, - ActionGameManager.GameDataDirectory.CreateSubdirectory("Narrative"), - Debug.Log, - setState); - Observable.NextFrame().Subscribe(_ => - { - ActionGameManager.CurrentGalaxy = sector; - EnterGame(); - }); - }).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted); - } }); _nextMenu.panel.AddButton("Settings", () => @@ -167,6 +127,72 @@ public class MainMenu : MonoBehaviour _nextMenu.panel.AddButton("Quit", Application.Quit); } + // New Game: every way to set up a run, each with its brief. The test arenas are listed only in editor and + // development builds. + private void ShowScenarios() + { + _nextMenu.panel.Clear(); + _nextMenu.panel.Title.text = TitleSubtitle("new game", "scenarios"); + AddScenarios(Scenarios.Modes); + if (Debug.isDebugBuild) + { + _nextMenu.panel.AddSection("test arenas"); + AddScenarios(Scenarios.Tests); + } + _nextMenu.panel.AddButton("Back", + () => + { + ShowMain(); + Fade(false); + }); + } + + private void AddScenarios(IEnumerable scenarios) + { + foreach (var scenario in scenarios) + { + _nextMenu.panel.AddButton(scenario.Name, () => Launch(scenario)); + _nextMenu.panel.AddProperty(() => scenario.Brief); + } + } + + // The only way into a new run: clears the saved run, generates the scenario's galaxy off the main thread, and + // enters the game, where StartGame has RunStart stage it. + private void Launch(Scenario scenario) + { + RunSave.Clear(ActionGameManager.CultCache); + var generatorState = "Loading Database Contents"; + + Fade(true); + _nextMenu.panel.gameObject.SetActive(false); + Dialog.Clear(); + Dialog.Title.text = "Generating Galaxy"; + Dialog.AddProperty(() => generatorState); + Dialog.Show(); + + var stage = new GalaxyStage( + Settings.SectorGenerationSettings, + Settings.SectorBackgroundSettings, + Settings.TutorialGenerationSettings, + Settings.TutorialBackgroundSettings, + Settings.NameGeneratorSettings, + ActionGameManager.CultCache, + ActionGameManager.PlayerSettings, + ActionGameManager.GameDataDirectory.CreateSubdirectory("Narrative"), + Debug.Log, + state => generatorState = state); + Task.Run(() => + { + var galaxy = RunStart.Generate(scenario, stage); + Observable.NextFrame().Subscribe(_ => + { + ActionGameManager.CurrentGalaxy = galaxy; + ActionGameManager.PendingScenario = scenario; + EnterGame(); + }); + }).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted); + } + // The one way into the game scene. Mod ship prototypes import asynchronously at boot, so the scene waits for them, // and a preload that could not run at all stops here with its reason rather than in the first zone. private void EnterGame() => StartCoroutine(EnterGameWhenModShipsAreReady()); diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 383425c60..12daaee32 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -229,7 +229,7 @@ public sealed partial class RunStartTests var low = Heater("P30 Low Heater", 260, 10); var high = Heater("P30 High Heater", 320, 7); var hull = Hull("Djinni"); - var zone = Arena(new Scenario { Ambient = false }); + var zone = Arena(false); List Bills(params GearData[] order) { diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index c3ac81ff5..bfb71e59d 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -16,10 +16,9 @@ using static CultMath.math; using float2 = CultMath.float2; using float3 = CultMath.float3; -// Scenarios Cut 1 (docs/scenarios-cut.md, 2.2): RunStart owns what a new run starts with, and Zone.Admit is the one -// admission into a zone. Every test stages into a real arena: the entrance zone of a prelude galaxy at a fixed seed, -// built from the shipped catalog and the authored settings (Assets/Resources/Settings.asset). The presets and -// scenarios live in a scratch copy of the catalog, because the shipped one holds none. +// RunStart owns what a new run starts with, and Zone.Admit is the one admission into a zone (docs/scenarios-cut.md, +// 2.2 and R). Every test stages into a real arena: the entrance zone of a prelude galaxy at a fixed seed, built from a +// scratch copy of the shipped catalog and the authored settings (Assets/Resources/Settings.asset). public sealed partial class RunStartTests : IDisposable { private const uint GalaxySeed = 1; @@ -32,6 +31,7 @@ public sealed partial class RunStartTests : IDisposable private readonly PlanetSettings _planetSettings; private readonly string _startingHull; private readonly Faction _protagonist; + private readonly TutorialGenerationSettings _tutorialSettings; public RunStartTests() { @@ -49,7 +49,7 @@ public sealed partial class RunStartTests : IDisposable var authored = AuthoredSettings.Load(repo); _zoneSettings = authored.Read("ZoneSettings"); _planetSettings = authored.Read("PlanetSettings"); - var tutorial = authored.Read("TutorialGenerationSettings"); + var tutorial = _tutorialSettings = authored.Read("TutorialGenerationSettings"); _galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), authored.Read("NameGeneratorSettings"), _cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, GalaxySeed); @@ -80,8 +80,9 @@ public sealed partial class RunStartTests : IDisposable throw new DirectoryNotFoundException("Run from inside the Aetheria repository."); } - private Zone Arena(Scenario scenario) => - new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, _galaxy, scenario), _galaxy.Entrance, _galaxy); + // The fixture galaxy's arena, with or without its generated ships and turrets. + private Zone Arena(bool ambient) => + new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(ambient, _ => { })), _galaxy.Entrance, _galaxy); private HullData Hull(string name) => _cache.GetAll().Single(hull => hull.Name == name); @@ -92,314 +93,12 @@ public sealed partial class RunStartTests : IDisposable .OrderBy(hull => hull.Name, StringComparer.Ordinal) .First(); - private CultRecordRef Preset(Loadout loadout) - { - var existing = _cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name); - if (existing != null) return _cache.RefOf(existing); - _cache.Upsert(loadout); - return _cache.RefOf(loadout); - } - - private CultRecordRef Bare(HullData hull) => - Preset(new Loadout { Name = "bare " + hull.Name, Hull = _cache.RefOf(hull), WeaponGroups = new int[0][] }); - - // A fully fitted preset: a generated ship of this hull, captured. - private Loadout Fitted(string hullName) - { - var generator = new LoadoutGenerator(ref _items.Random, _items, null, null, null, 2); - var ship = (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull.Name == hullName)); - Assert.NotEmpty(ship.CargoBays); - return Loadouts.Capture(_items, ship, "fitted " + hullName); - } - - private static ScenarioShip At(CultRecordRef preset, float x, float z) => - new ScenarioShip { Loadout = preset, Position = float2(x, z) }; - - private static ScenarioEntity Placed(CultRecordRef preset, float x, float z, ScenarioStance stance, bool piloted) => - new ScenarioEntity { Loadout = preset, Position = float2(x, z), Stance = stance, Piloted = piloted }; - - private Scenario Quiet(ScenarioShip player, params ScenarioEntity[] entities) => - new Scenario { Name = "test", Seed = GalaxySeed, Ambient = false, Player = player, Entities = entities.ToList() }; - - private RunStart.Staged Stage(Zone zone, Scenario scenario) - { - var failures = new List(); - var staged = RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures); - Assert.Empty(failures); - Assert.NotNull(staged); - return staged; - } + // A loadout with only its hull, in memory. + private Loadout BareLoadout(HullData hull) => + new Loadout { Name = "bare " + hull.Name, Hull = _cache.RefOf(hull), WeaponGroups = new int[0][] }; private static int AgentsOf(Zone zone, Entity entity) => zone.Agents.Count(agent => agent.Ship == entity); - // New Game is the absent-scenario case: the authored starting hull, flagged as the player's, admitted at the - // origin, for the protagonist faction, with no agent. The hull name is an input: staging honours another one too. - [Fact] - public void WithNoScenarioThePlayerIsTheStartingHullAtTheOrigin() - { - Assert.Equal("LonginusX", _startingHull); - foreach (var hullName in new[] { _startingHull, "Djinni" }) - { - var zone = Arena(null); - var failures = new List(); - var staged = RunStart.Stage(_items, zone, null, hullName, _protagonist, failures); - Assert.Empty(failures); - var player = staged.Player; - Assert.Equal(hullName, player.HullData.Name); - Assert.True(player.IsPlayerShip); - SafeAssert.In(zone, player, "the player is admitted"); - Assert.True(zone == player.Zone, "the player's zone is the arena"); - Assert.Equal(float3.zero, player.Position); - Assert.Same(_protagonist, player.Faction); - Assert.Equal(0, AgentsOf(zone, player)); - Assert.Empty(staged.Entities); - } - } - - // A scenario's player is its preset, fitted slot for slot, at the authored position and heading, carrying its cargo. - [Fact] - public void AScenarioPlayerIsItsPresetAtItsPlaceWithItsCargo() - { - var fitted = Fitted("Djinni"); - var preset = Preset(fitted); - // Two one-cell designs a manufacturer makes, by name, so the cargo is ordinary branded stock - var cargo = _cache.GetAll() - .Where(design => design.Shape.Coordinates.Length == 1) - .Where(design => _cache.GetAll().Any(p => p.Design.Key.Equals(_cache.RefOf(design).Key))) - .OrderBy(design => design.Name, StringComparer.Ordinal) - .Take(2) - .Select(design => new CultRecordRef(_cache.RefOf(design).Key)) - .ToList(); - Assert.Equal(2, cargo.Count); - var zone = Arena(null); - var scenario = Quiet(new ScenarioShip { Loadout = preset, Position = float2(120, -40), Direction = float2(3, 4), Cargo = cargo }); - - var player = Stage(zone, scenario).Player; - - Assert.Equal("Djinni", player.HullData.Name); - Assert.True(player.IsPlayerShip); - SafeAssert.In(zone, player, "the player is admitted"); - Assert.Equal(float3(120, 0, -40), player.Position); - Assert.True(length(player.Direction - float2(.6f, .8f)) < 1e-5f, $"heading {player.Direction}"); - Assert.Equal( - fitted.Slots.Select(slot => (slot.Position, slot.Design.Key)).OrderBy(s => s.ToString()), - player.Equipment.Concat(player.CargoBays).Concat(player.DockingBays) - .Where(item => item != player.EquippedHull) - .Select(item => (item.Position, _cache.RefOf(_items.GetData(item.EquippableItem)).Key)) - .OrderBy(s => s.ToString())); - Assert.Equal(cargo.Select(design => design.Key.Value).OrderBy(k => k), - player.CargoBays.First().Cargo.Keys.Select(item => item.Data.Key.Value).OrderBy(k => k)); - } - - // A piloted entity gets exactly one agent; an unpiloted one gets none, and nothing steers it. (It is not frozen: - // the zone's gravity still moves it, as it moves any ship, so the rule is observed on its steering inputs.) - [Fact] - public void APilotedEntityHasOneAgentAndAnUnpilotedOneNoneAndStaysPut() - { - var zone = Arena(null); - var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0), - Placed(Preset(Fitted("LonginusX")), 300, 0, ScenarioStance.Neutral, piloted: true), - Placed(Bare(Hull("LonginusX")), -300, 0, ScenarioStance.Neutral, piloted: false))); - var piloted = staged.Entities[0]; - var unpiloted = staged.Entities[1]; - - Assert.Equal(1, AgentsOf(zone, piloted)); - Assert.Equal(0, AgentsOf(zone, unpiloted)); - Assert.Equal(0, AgentsOf(zone, staged.Player)); - SafeAssert.In(zone, piloted, "the piloted entity is admitted"); - SafeAssert.In(zone, unpiloted, "the unpiloted entity is admitted"); - - var look = unpiloted.LookDirection; - for (var tick = 0; tick < 50; tick++) - { - zone.Update(.1f); - Assert.Equal(float2.zero, ((Ship) unpiloted).MovementDirection); - Assert.Equal(look, unpiloted.LookDirection); - } - Assert.Equal(0, AgentsOf(zone, unpiloted)); - } - - // A stance holds both ways between the entity and the player. - [Fact] - public void AStanceIsSetBothWays() - { - var longinus = Bare(Hull("LonginusX")); - var staged = Stage(Arena(null), Quiet(At(Bare(Hull("Djinni")), 0, 0), - Placed(longinus, 300, 0, ScenarioStance.Hostile, piloted: false), - Placed(longinus, -300, 0, ScenarioStance.Neutral, piloted: false))); - var player = staged.Player; - var hostile = staged.Entities[0]; - var neutral = staged.Entities[1]; - - Assert.True(player.IsHostileTo(hostile)); - Assert.True(hostile.IsHostileTo(player)); - Assert.False(player.IsHostileTo(neutral)); - Assert.False(neutral.IsHostileTo(player)); - - // Admission activates: the live hostility each side tracks (what targeting and grudges read) agrees. - Assert.True(player.EntityHostility[hostile]); - Assert.True(hostile.EntityHostility[player]); - Assert.False(player.EntityHostility[neutral]); - Assert.False(neutral.EntityHostility[player]); - } - - // A stance lasts the sitting: the player docking and undocking leaves it standing both ways. It goes with an entity - // gone for good: once an entity is destroyed, the player holds no stance toward it. - [Fact] - public void AStanceSurvivesDockingButNotDestruction() - { - var zone = Arena(null); - var longinus = Bare(Hull("LonginusX")); - var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0), - Placed(longinus, 300, 0, ScenarioStance.Hostile, piloted: false), - Placed(longinus, -300, 0, ScenarioStance.Hostile, piloted: false))); - var player = staged.Player; - var hostile = staged.Entities[0]; - var doomed = staged.Entities[1]; - var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); - - Assert.NotNull(station.TryDock(player)); - SafeAssert.NotIn(zone, player, "a docked ship is out of the zone"); - Assert.True(station.TryUndock(player)); - Assert.True(player.IsHostileTo(hostile), "the player's stance was lost over docking"); - Assert.True(hostile.IsHostileTo(player), "the entity's stance was lost over docking"); - Assert.True(player.EntityHostility[hostile], "the player's live hostility was lost over docking"); - Assert.True(hostile.EntityHostility[player], "the entity's live hostility was lost over docking"); - - // Presets carry no faction, so without its stance the derived rule is not hostile - Assert.True(player.Faction == null && doomed.Faction == null, "the fixture's ships are factionless"); - Assert.True(player.IsHostileTo(doomed)); - doomed.Hull.Durability = 0; - doomed.HullDamage.OnNext(1); - SafeAssert.NotIn(zone, doomed, "a destroyed entity leaves the zone"); - Assert.False(player.IsHostileTo(doomed), "the stance toward a destroyed entity outlived it"); - } - - // Undocking admits the ship unpiloted: back in the zone, active (its live hostility is tracked), with no agent. - [Fact] - public void UndockingAdmitsWithNoAgent() - { - var zone = Arena(null); - var player = Stage(zone, null).Player; - var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); - var agents = zone.Agents.Count; - - Assert.NotNull(station.TryDock(player)); - SafeAssert.NotIn(zone, player, "a docked ship is out of the zone"); - Assert.True(station.TryUndock(player)); - SafeAssert.In(zone, player, "an undocked ship is back in the zone"); - Assert.Equal(agents, zone.Agents.Count); - Assert.Equal(0, AgentsOf(zone, player)); - Assert.True(player.EntityHostility.ContainsKey(station), "an undocked ship is active"); - } - - // Ambient: false keeps the stations and drops every generated ship and turret; ambient, and a plain New Game, keep - // them. The arena is the galaxy's entrance pack. The entrance holds a story station, placed as the narrative would - // place one, so a story station's own turrets are dropped too. - [Fact] - public void AQuietArenaHasNoGeneratedShipOrTurretButKeepsItsStations() - { - (int ships, int turrets, int stations) Census(ZonePack pack) => ( - pack.Entities.OfType().Count(), - pack.Entities.OfType().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Turret), - pack.Entities.OfType().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Station)); - - _galaxy.Entrance.Locations.Add(new LocationStory - { - Zone = _galaxy.Entrance, Name = "story", Type = LocationType.Station, Faction = _protagonist, Turrets = 2 - }); - var plain = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, null)); - var ambient = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scenario { Ambient = true })); - var quietPack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scenario { Ambient = false }); - var quiet = Census(quietPack); - - Assert.Same(quietPack, _galaxy.Entrance.PackedContents); - Assert.True(plain.ships > 0 && plain.turrets > 0, $"a plain arena generated {plain}"); - Assert.True(ambient.ships > 0 && ambient.turrets > 0, $"an ambient arena generated {ambient}"); - Assert.Equal(0, quiet.ships); - Assert.Equal(0, quiet.turrets); - Assert.True(quiet.stations > 0); - Assert.Equal(ambient.stations, quiet.stations); - Assert.Contains(quietPack.Entities.OfType(), station => station.Story == 0); - } - - // A turret preset stages as an orbital entity with no orbit, which stays where it is put. - [Fact] - public void ATurretPresetStagesAsAStationaryOrbitalEntity() - { - var zone = Arena(null); - var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0), - Placed(Bare(TurretHull()), 200, 50, ScenarioStance.Hostile, piloted: false))); - var turret = Assert.IsType(staged.Entities[0]); - Assert.False(turret.OrbitData.IsSet()); - SafeAssert.In(zone, turret, "the turret is admitted"); - for (var tick = 0; tick < 20; tick++) zone.Update(.1f); - Assert.Equal(float2(200, 50), turret.Position.xz); - } - - // A station preset is refused, and so is a turret as the player or a piloted turret: each is a staging failure - // naming the hull, with nothing admitted. - [Fact] - public void AStationPresetOrAMisplacedTurretIsAStagingFailure() - { - var station = _cache.GetAll().First(hull => hull.HullType == HullType.Station); - var turret = TurretHull(); - var djinni = Bare(Hull("Djinni")); - foreach (var (scenario, expected) in new[] - { - (Quiet(At(djinni, 0, 0), Placed(Bare(station), 200, 0, ScenarioStance.Neutral, false)), $"{station.Name} is a station hull"), - (Quiet(At(Bare(turret), 0, 0)), $"player: {turret.Name} is not a ship hull"), - (Quiet(At(djinni, 0, 0), Placed(Bare(turret), 200, 0, ScenarioStance.Neutral, true)), "cannot be piloted"), - }) - { - var zone = Arena(null); - var entities = zone.Entities.ToList(); - var agents = zone.Agents.ToList(); - var failures = new List(); - Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures)); - Assert.Contains(failures, failure => failure.Contains(expected)); - SafeAssert.Unchanged(zone, entities, agents); - Assert.Contains(RunStart.Check(_items, scenario), failure => failure.Contains(expected)); - } - } - - // A scenario naming a missing design or preset fails with each named; the check entry finds the same failures with - // no zone and writes no document, and staging admits nothing, not even the parts that built: a player and a piloted - // entity that build cleanly stay out when a later entity fails. - [Fact] - public void AMissingDesignFailsNamedAndChangesNothing() - { - var djinni = Bare(Hull("Djinni")); - var scenario = Quiet( - new ScenarioShip { Loadout = djinni, Cargo = { new CultRecordRef(new CultRecordKey("absent-design")) } }, - Placed(Bare(Hull("LonginusX")), 300, 0, ScenarioStance.Hostile, piloted: true), - Placed(new CultRecordRef(new CultRecordKey("absent-preset")), -300, 0, ScenarioStance.Neutral, false)); - var zone = Arena(null); - var entities = zone.Entities.ToList(); - var agents = zone.Agents.ToList(); - var documents = _cache.AllStoredDocuments.Count(); - - var checkFailures = RunStart.Check(_items, scenario); - Assert.Equal(2, checkFailures.Count); - Assert.Contains("absent-design", checkFailures[0]); - Assert.Contains("absent-preset", checkFailures[1]); - Assert.Equal(documents, _cache.AllStoredDocuments.Count()); - - var failures = new List(); - Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures)); - Assert.Equal(checkFailures, failures); - SafeAssert.Unchanged(zone, entities, agents); - - var lateFailure = Quiet(At(djinni, 0, 0), - Placed(Bare(Hull("LonginusX")), 300, 0, ScenarioStance.Hostile, piloted: true), - Placed(new CultRecordRef(new CultRecordKey("absent-preset")), -300, 0, ScenarioStance.Neutral, false)); - failures.Clear(); - Assert.Null(RunStart.Stage(_items, zone, lateFailure, _startingHull, _protagonist, failures)); - Assert.Contains("absent-preset", Assert.Single(failures)); - SafeAssert.Unchanged(zone, entities, agents); - Assert.Empty(RunStart.Check(_items, null)); - } - // A heater is gear whose thermostat runs its heat only below a target: a low-pass thermotoggle, then heat. private static bool IsHeater(EquippedItem item) => item.Behaviors.OfType().Any(t => !t.ThermotoggleData.HighPass) && item.Behaviors.OfType().Any(); @@ -408,7 +107,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void EveryGeneratedStationCarriesOneHeaterAndAReactor() { - var zone = Arena(null); + var zone = Arena(true); var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); for (var i = 0; i < 8; i++) { @@ -422,7 +121,7 @@ public sealed partial class RunStartTests : IDisposable // A main-sector galaxy (not the prelude, which offers every product): each station's gear must come from // manufacturers its faction can reach, the galaxy's factions its allegiance names. - private Galaxy MainGalaxy() + private Galaxy MainSectorGalaxy() { var authored = AuthoredSettings.Load(FindRepoRoot()); return new Galaxy(authored.Read("SectorGenerationSettings"), @@ -443,9 +142,9 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void EveryFactionsStationsCarryOnlyGearItCanReachIncludingAHeaterAndAReactor() { - var galaxy = MainGalaxy(); + var galaxy = MainSectorGalaxy(); Assert.False(galaxy.IsPrelude); - var zone = Arena(null); + var zone = Arena(true); foreach (var faction in galaxy.Factions) { var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, faction, .5f); @@ -466,7 +165,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void AFactionReachesItsOwnGearAndItsAllegianceNamesOnlyOthers() { - var galaxy = MainGalaxy(); + var galaxy = MainSectorGalaxy(); var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); foreach (var faction in galaxy.Factions) { @@ -483,7 +182,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void AStationWithoutZhestokostAccessIsPoweredAndStaysAboveFreezing() { - var galaxy = MainGalaxy(); + var galaxy = MainSectorGalaxy(); Faction Named(string shortName) => _cache.GetAll().Single(f => f.ShortName == shortName); var coop = new Faction { Name = "Test Cooperative", ShortName = "Coop" }; _cache.Upsert(coop); @@ -497,7 +196,7 @@ public sealed partial class RunStartTests : IDisposable var own = new FactionProductData { Name = "Cooperative Capacitor", Design = products.First().Design, Manufacturer = _cache.RefOf(coop) }; _cache.Upsert(own); Assert.True(generator.IsAvailable(own), "a faction reaches its own manufacturer's gear"); - var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var zone = Arena(false); // no ships: nothing but the zone runs Entity station = null; for (var i = 0; i < 8; i++) { @@ -525,7 +224,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void AnIdleStationStaysAboveFreezing() { - var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var zone = Arena(false); // no ships: nothing but the zone runs var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); var heater = station.Equipment.Single(IsHeater); var coldest = float.MaxValue; @@ -542,7 +241,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void AStationHeaterHeatsBelowItsTargetAndNotAbove() { - var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var zone = Arena(false); // no ships: nothing but the zone runs var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); var heater = station.Equipment.Single(IsHeater); var target = heater.Behaviors.OfType().Single().TargetTemperature; @@ -565,7 +264,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void AHeaterAboveItsTargetDrawsNoPower() { - var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var zone = Arena(false); // no ships: nothing but the zone runs var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); var heater = station.Equipment.Single(IsHeater); var target = heater.Behaviors.OfType().Single().TargetTemperature; @@ -616,7 +315,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void NoOrbitStandsStillAgainstItsParent() { - var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var zone = Arena(false); // no ships: nothing but the zone runs Assert.True(zone.AsteroidBelts.Count > 0, "the arena has an asteroid belt to race against"); float2 Relative(Orbit orbit, bool previous) => (previous ? orbit.PreviousPosition : orbit.Position) - @@ -638,7 +337,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void EveryAsteroidInABeltIsPlacedForTheSameTime() { - var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var zone = Arena(false); // no ships: nothing but the zone runs var (key, belt) = zone.AsteroidBelts.First(); var asteroids = ((AsteroidBeltData) zone.Planets[key]).Asteroids; const float dt = .02f; @@ -668,7 +367,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void MiningWhileTheBeltUpdatesNeverRacesIt() { - var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var zone = Arena(false); // no ships: nothing but the zone runs var (key, _) = zone.AsteroidBelts.First(); var data = (AsteroidBeltData) zone.Planets[key]; var count = data.Asteroids.Length; @@ -963,7 +662,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void TheBusBillsAHeaterExactlyWhenItsThermostatIsOpen() { - var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var zone = Arena(false); // no ships: nothing but the zone runs var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); var heater = station.Equipment.Single(IsHeater); var thermostat = heater.Behaviors.OfType().Single(); @@ -1007,7 +706,7 @@ public sealed partial class RunStartTests : IDisposable { Name = "Two Group Gear", Design = new CultRecordRef(_cache.RefOf(gear).Key), Manufacturer = _cache.RefOf(_protagonist) }); - var zone = Arena(new Scenario { Ambient = false }); + var zone = Arena(false); var ship = BareHull(Hull("Djinni")); ship.Zone = zone; Assert.True(ship.TryEquip(Instance(gear))); @@ -1018,59 +717,6 @@ public sealed partial class RunStartTests : IDisposable Assert.True(abs(ship.PowerBus.TotalDemand - 7) < .01f, $"billed {ship.PowerBus.TotalDemand} for a closed 10 and an ungated 7"); } - // Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a - // scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of - // it, or cargo it carries. These are the unsold designs no scenario places, by name. - private static List UnsoldAndUnplaced(CultCache cache) - { - var sold = cache.GetAll().Select(product => product.Design.Key).ToHashSet(); - var placed = new HashSet(); - foreach (var scenario in cache.GetAll()) - foreach (var ship in scenario.Entities.Prepend(scenario.Player).Where(ship => ship != null)) - { - foreach (var cargo in ship.Cargo) placed.Add(cargo.Key); - var preset = cache.Get(ship.Loadout); - if (preset == null) continue; - placed.Add(preset.Hull.Key); - foreach (var slot in preset.Slots) placed.Add(slot.Design.Key); - } - return cache.GetAll() - .Where(design => !sold.Contains(cache.RefOf(design).Key) && !placed.Contains(cache.RefOf(design).Key)) - .Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToList(); - } - - // The shipped catalog holds no unsold design outside a scenario (today, none unsold at all). A new unsold design is - // flagged until a scenario places it, in a preset's slot or as an entity's cargo. - [Fact] - public void EveryUnsoldDesignIsAScenarioTestDesign() - { - Assert.Empty(UnsoldAndUnplaced(_cache)); - - GearData Probe(string name) - { - var design = new GearData { Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1 }; - _cache.Upsert(design); - return design; - } - var fitted = Probe("Census Fitted"); - var carried = Probe("Census Carried"); - Assert.Equal(new[] { "Census Carried", "Census Fitted" }, UnsoldAndUnplaced(_cache)); - - var preset = Preset(new Loadout - { - Name = "census preset", Hull = _cache.RefOf(Hull("Djinni")), WeaponGroups = new int[0][], - Slots = { new LoadoutSlot { Design = new CultRecordRef(_cache.RefOf(fitted).Key) } } - }); - var scenario = Quiet(At(Bare(Hull("Djinni")), 0, 0), Placed(preset, 100, 0, ScenarioStance.Neutral, false)); - scenario.Name = "census"; - _cache.Upsert(scenario); - Assert.Equal(new[] { "Census Carried" }, UnsoldAndUnplaced(_cache)); - - scenario.Entities[0].Cargo.Add(new CultRecordRef(_cache.RefOf(carried).Key)); - _cache.Upsert(scenario); - Assert.Empty(UnsoldAndUnplaced(_cache)); - } - // The Q3 rule must not reach a real hull: the hulls scenarios rely on each have a product, and materialize bare, // branded, under the prelude's availability. [Fact] @@ -1080,7 +726,7 @@ public sealed partial class RunStartTests : IDisposable foreach (var hull in new[] { Hull("Djinni"), Hull("LonginusX"), TurretHull() }) { var failures = new List(); - var entity = Loadouts.Materialize(_items, null, _cache.Get(Bare(hull)), available.IsAvailable, failures); + var entity = Loadouts.Materialize(_items, null, BareLoadout(hull), available.IsAvailable, failures); Assert.Empty(failures); Assert.NotNull(_items.Brand(entity.Hull).Maker); } @@ -1091,7 +737,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void AdmitIsTheOnlyAgentCreator() { - var pack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, null); + var pack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(true, _ => { })); var packedPlayer = pack.Entities.OfType().First(); packedPlayer.IsPlayerShip = true; var zone = new Zone(_items, _planetSettings, pack, _galaxy.Entrance, _galaxy); @@ -1099,44 +745,10 @@ public sealed partial class RunStartTests : IDisposable Assert.True(ships.Count > 1); foreach (var ship in ships) Assert.Equal(ship.IsPlayerShip ? 0 : 1, AgentsOf(zone, ship)); - var arriving = (Ship) Loadouts.Materialize(_items, zone, _cache.Get(Bare(Hull("Djinni"))), _ => true, new List()); + var arriving = (Ship) Loadouts.Materialize(_items, zone, BareLoadout(Hull("Djinni")), _ => true, new List()); var agents = zone.Agents.Count; zone.Admit(arriving, piloted: false); SafeAssert.In(zone, arriving, "the arrival is admitted"); Assert.Equal(agents, zone.Agents.Count); } - - // The scenario type is catalog data: it round-trips through the catalog file. - [Fact] - public void AScenarioRoundTripsThroughTheCatalog() - { - var djinni = Bare(Hull("Djinni")); - var cargo = new CultRecordRef(_cache.RefOf(Hull("LonginusX")).Key); - var scenario = new Scenario - { - Name = "Round trip", Brief = "one of everything", Seed = 42, Ambient = true, - Player = new ScenarioShip { Loadout = djinni, Position = float2(1, 2), Direction = float2(0, -1), Cargo = { cargo } }, - Entities = { Placed(djinni, 3, 4, ScenarioStance.Hostile, piloted: true) } - }; - // Written through its own cache over a second copy of the catalog, disposed, then read back fresh - var copy = Path.Combine(_root, "roundtrip.cc"); - File.Copy(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), copy); - using (var writer = new CultCache(Registry())) - { - writer.AddBackingStore(new SingleFileMessagePackBackingStore(copy), AetheriaStores.CatalogTypes); - writer.Commit(batch => batch.Upsert(typeof(Loadout), _cache.Get(djinni), djinni.Key)); - writer.Upsert(scenario); - writer.FlushAsync().Wait(); - } - - using var reopened = new CultCache(Registry()); - reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy, true), AetheriaStores.CatalogTypes); - var loaded = Assert.Single(reopened.GetAll()); - Assert.Equal(("Round trip", "one of everything", 42u, true), (loaded.Name, loaded.Brief, loaded.Seed, loaded.Ambient)); - Assert.Equal(djinni.Key, loaded.Player.Loadout.Key); - Assert.Equal((float2(1, 2), float2(0, -1)), (loaded.Player.Position, loaded.Player.Direction)); - Assert.Equal(cargo.Key, Assert.Single(loaded.Player.Cargo).Key); - var entity = Assert.Single(loaded.Entities); - Assert.Equal((djinni.Key, float2(3, 4), ScenarioStance.Hostile, true), (entity.Loadout.Key, entity.Position, entity.Stance, entity.Piloted)); - } } diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index f35cded44..c2a4e36b3 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -2,29 +2,443 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +using System; using System.Buffers; using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; -// Scenarios S2 (docs/scenarios-cut.md, R): every new run is a scenario. The fixture is RunStartTests': the shipped -// catalog, the authored settings and a prelude galaxy at a fixed seed. +// Scenarios S2 (docs/scenarios-cut.md, R): every new run is a scenario. The fixture is RunStartTests': a scratch copy +// of the shipped catalog, the authored settings and a prelude galaxy at a fixed seed. public sealed partial class RunStartTests { + // A test's own scenario: a script given inline, staged into the fixture's arena or launched whole. + private sealed class Scripted : Scenario + { + private readonly bool _ambient; + private readonly Action _stage; + private readonly uint _seed; + private readonly Func _generate; + + public Scripted(bool ambient, Action stage, uint seed = GalaxySeed, Func generate = null) + { + _ambient = ambient; + _stage = stage; + _seed = seed; + _generate = generate ?? (galaxy => galaxy.Prelude()); + } + + public override string Name => "Scripted"; + public override string Brief => "A test's own scenario."; + public override uint Seed => _seed; + public override bool Ambient => _ambient; + public override Galaxy Generate(GalaxyStage stage) => _generate(stage); + public override void Stage(ScenarioStage stage) => _stage(stage); + } + + private static IEnumerable EveryScenario => Scenarios.Modes.Concat(Scenarios.Tests); + + // The galaxy inputs the menu hands to RunStart, read from the authored settings. + private GalaxyStage Inputs(Func clock = null) + { + var authored = AuthoredSettings.Load(FindRepoRoot()); + return new GalaxyStage( + authored.Read("SectorGenerationSettings"), + authored.Read("SectorBackgroundSettings"), + authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), + authored.Read("NameGeneratorSettings"), + _cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, clock); + } + + // What MainMenu.Launch and StartGame do with a scenario: its galaxy, its arena, then staging. + private (Galaxy galaxy, Zone arena, RunStart.Staged staged, List failures) Launch(Scenario scenario, GalaxyStage inputs = null) + { + var galaxy = RunStart.Generate(scenario, inputs ?? Inputs()); + var arena = new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, galaxy, scenario), galaxy.Entrance, galaxy); + var failures = new List(); + var staged = RunStart.Stage(_items, arena, scenario, _startingHull, _tutorialSettings, failures); + return (galaxy, arena, staged, failures); + } + + private RunStart.Staged Stage(Zone zone, Scenario scenario) + { + var failures = new List(); + var staged = RunStart.Stage(_items, zone, scenario, _startingHull, _tutorialSettings, failures); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + Assert.NotNull(staged); + return staged; + } + + // Staging that must fail: it returns null and admits nothing. Returns the failures. + private List Refused(Zone zone, Scenario scenario) + { + var entities = zone.Entities.ToList(); + var agents = zone.Agents.ToList(); + var failures = new List(); + Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _tutorialSettings, failures)); + SafeAssert.Unchanged(zone, entities, agents); + return failures; + } + + // The rot test: every scenario New Game lists generates its galaxy and stages against the live catalog and the + // authored settings with no failure. A script naming a design the catalog lost fails here, by name. + [Fact] + public void EveryScenarioStages() + { + Assert.Equal(EveryScenario.Count(), EveryScenario.Select(scenario => scenario.Name).Distinct().Count()); + foreach (var scenario in EveryScenario) + { + var (_, arena, staged, failures) = Launch(scenario, Inputs(() => GalaxySeed)); + Assert.True(failures.Count == 0, $"{scenario.Name}: {string.Join("; ", failures)}"); + Assert.True(staged.Player.IsPlayerShip, $"{scenario.Name}: the player is not flagged"); + SafeAssert.In(arena, staged.Player, $"{scenario.Name}: the player is admitted"); + foreach (var entity in staged.Entities) SafeAssert.In(arena, entity, $"{scenario.Name}: {entity.Name} is admitted"); + Assert.False(string.IsNullOrWhiteSpace(scenario.Brief), $"{scenario.Name} has no brief"); + } + } + + // The two game modes are what New Game did: the tutorial a prelude galaxy and a starting-hull ship of the + // protagonist faction at the entrance's origin; the main galaxy a main galaxy and a starting-hull ship there. Both + // arenas keep their generated ships. + [Fact] + public void ModesMatchOldNewGame() + { + Assert.Equal("LonginusX", _startingHull); + + var tutorial = Launch(new TutorialGalaxy(), Inputs(() => GalaxySeed)); + Assert.Empty(tutorial.failures); + Assert.True(tutorial.galaxy.IsPrelude, "the tutorial is a prelude galaxy"); + Assert.Equal(_startingHull, tutorial.staged.Player.HullData.Name); + Assert.Same(tutorial.galaxy.ResolveFaction(_tutorialSettings.ProtagonistFaction), tutorial.staged.Player.Faction); + Assert.Equal(float3.zero, tutorial.staged.Player.Position); + Assert.Empty(tutorial.staged.Entities); + Assert.Contains(tutorial.arena.Pack.Entities, entity => entity is ShipPack { IsPlayerShip: false }); + + var main = Launch(new MainGalaxy(), Inputs(() => GalaxySeed)); + Assert.Empty(main.failures); + Assert.False(main.galaxy.IsPrelude, "the main galaxy is not a prelude"); + Assert.Equal(_startingHull, main.staged.Player.HullData.Name); + Assert.Equal(float3.zero, main.staged.Player.Position); + Assert.Empty(main.staged.Entities); + Assert.Contains(main.arena.Pack.Entities, entity => entity is ShipPack { IsPlayerShip: false }); + } + + private static string Layout(Galaxy galaxy) => + string.Join(";", galaxy.Zones.Select(zone => $"{zone.Name}@{zone.Position}:{zone.Owner?.Name}")) + + $" entrance {Array.IndexOf(galaxy.Zones, galaxy.Entrance)} noise {galaxy.Background.NoisePosition}"; + + private static string Layout(ZonePack arena) => + $"{arena.Radius} {arena.Mass} {arena.Planets.Count()} {arena.Orbits.Count()} {arena.Entities.Count()}"; + + // A fixed seed fixes the galaxy, its background and its arena, launch after launch in one session. Seed zero takes + // the clock, so two launches at two clock readings are two galaxies. + [Fact] + public void SeedRules() + { + var arena = new DjinniShakedown(); + Assert.NotEqual(0u, arena.Seed); + var first = Launch(arena); + var second = Launch(arena); + Assert.Equal(Layout(first.galaxy), Layout(second.galaxy)); + Assert.Equal(Layout(first.arena.Pack), Layout(second.arena.Pack)); + + var clock = new Queue(new[] { 1000u, 2000u }); + var inputs = Inputs(() => clock.Dequeue()); + var everyLaunchNew = new Scripted(true, _ => { }, seed: 0, generate: galaxy => galaxy.Main()); + var a = RunStart.Generate(everyLaunchNew, inputs); + Assert.Equal(1000u, inputs.Seed); + var b = RunStart.Generate(everyLaunchNew, inputs); + Assert.Equal(2000u, inputs.Seed); + Assert.NotEqual(Layout(a), Layout(b)); + } + + // A scenario naming one unknown design admits nothing, not even the parts that built, and names the design. + [Fact] + public void StagingIsAllOrNothing() + { + var failures = Refused(Arena(true), new Scripted(true, stage => + { + stage.Player(stage.Bare("Djinni"), float2(0, 0)); + stage.Place(stage.Generated("LonginusX"), float2(300, 0), stance: ScenarioStance.Hostile, piloted: true); + stage.Place(stage.Fit("LonginusX", ("No Such Gun", int2(1, 8), ItemRotation.None)), float2(-300, 0)); + })); + Assert.Contains("No Such Gun", Assert.Single(failures)); + } + + // A stance holds both ways between an entity and the player. A piloted entity has exactly one agent; an unpiloted + // one has none, and nothing steers it. (It is not frozen: the zone's gravity still moves it, as it moves any ship, + // so the rule is observed on its steering inputs.) + [Fact] + public void StanceAndPilot() + { + var zone = Arena(true); + var staged = Stage(zone, new Scripted(true, stage => + { + stage.Player(stage.Bare("Djinni"), float2(0, 0)); + stage.Place(stage.Generated("LonginusX"), float2(300, 0), stance: ScenarioStance.Hostile, piloted: true); + stage.Place(stage.Bare("LonginusX"), float2(-300, 0), stance: ScenarioStance.Neutral, piloted: false); + })); + var player = staged.Player; + var hostile = staged.Entities[0]; + var neutral = staged.Entities[1]; + + Assert.True(player.IsHostileTo(hostile) && hostile.IsHostileTo(player), "hostile both ways"); + Assert.False(player.IsHostileTo(neutral) || neutral.IsHostileTo(player), "neutral both ways"); + // Admission activates: the live hostility each side tracks (what targeting and grudges read) agrees. + Assert.True(player.EntityHostility[hostile] && hostile.EntityHostility[player]); + Assert.False(player.EntityHostility[neutral] || neutral.EntityHostility[player]); + + Assert.Equal(1, AgentsOf(zone, hostile)); + Assert.Equal(0, AgentsOf(zone, neutral)); + Assert.Equal(0, AgentsOf(zone, player)); + var look = neutral.LookDirection; + for (var tick = 0; tick < 50; tick++) + { + zone.Update(.1f); + Assert.Equal(float2.zero, ((Ship) neutral).MovementDirection); + Assert.Equal(look, neutral.LookDirection); + } + Assert.Equal(0, AgentsOf(zone, neutral)); + } + + // A stance lasts the sitting: the player docking and undocking leaves it standing both ways. It goes with an entity + // gone for good: once an entity is destroyed, the player holds no stance toward it. + [Fact] + public void AStanceSurvivesDockingButNotDestruction() + { + var zone = Arena(true); + var staged = Stage(zone, new Scripted(true, stage => + { + stage.Player(stage.Bare("Djinni"), float2(0, 0)); + stage.Place(stage.Bare("LonginusX"), float2(300, 0), stance: ScenarioStance.Hostile); + stage.Place(stage.Bare("LonginusX"), float2(-300, 0), stance: ScenarioStance.Hostile); + })); + var player = staged.Player; + var hostile = staged.Entities[0]; + var doomed = staged.Entities[1]; + var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); + + Assert.NotNull(station.TryDock(player)); + SafeAssert.NotIn(zone, player, "a docked ship is out of the zone"); + Assert.True(station.TryUndock(player)); + Assert.True(player.IsHostileTo(hostile) && hostile.IsHostileTo(player), "the stance was lost over docking"); + Assert.True(player.EntityHostility[hostile] && hostile.EntityHostility[player], "the live hostility was lost over docking"); + + // Bare fits carry no faction, so without its stance the derived rule is not hostile + Assert.True(player.Faction == null && doomed.Faction == null, "the fixture's ships are factionless"); + Assert.True(player.IsHostileTo(doomed)); + doomed.Hull.Durability = 0; + doomed.HullDamage.OnNext(1); + SafeAssert.NotIn(zone, doomed, "a destroyed entity leaves the zone"); + Assert.False(player.IsHostileTo(doomed), "the stance toward a destroyed entity outlived it"); + } + + // Undocking admits the ship unpiloted: back in the zone, active (its live hostility is tracked), with no agent. + [Fact] + public void UndockingAdmitsWithNoAgent() + { + var zone = Arena(true); + var player = Stage(zone, new TutorialGalaxy()).Player; + var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); + var agents = zone.Agents.Count; + + Assert.NotNull(station.TryDock(player)); + SafeAssert.NotIn(zone, player, "a docked ship is out of the zone"); + Assert.True(station.TryUndock(player)); + SafeAssert.In(zone, player, "an undocked ship is back in the zone"); + Assert.Equal(agents, zone.Agents.Count); + Assert.Equal(0, AgentsOf(zone, player)); + Assert.True(player.EntityHostility.ContainsKey(station), "an undocked ship is active"); + } + + // Ambient false keeps the stations and drops every generated ship and turret; ambient keeps them. The arena is the + // galaxy's entrance pack. The entrance holds a story station, placed as the narrative would place one, so a story + // station's own turrets are dropped too. + [Fact] + public void AQuietArenaHasNoGeneratedShipOrTurretButKeepsItsStations() + { + (int ships, int turrets, int stations) Census(ZonePack pack) => ( + pack.Entities.OfType().Count(), + pack.Entities.OfType().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Turret), + pack.Entities.OfType().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Station)); + + _galaxy.Entrance.Locations.Add(new LocationStory + { + Zone = _galaxy.Entrance, Name = "story", Type = LocationType.Station, Faction = _protagonist, Turrets = 2 + }); + var ambient = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(true, _ => { }))); + var quietPack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(false, _ => { })); + var quiet = Census(quietPack); + + Assert.Same(quietPack, _galaxy.Entrance.PackedContents); + Assert.True(ambient.ships > 0 && ambient.turrets > 0, $"an ambient arena generated {ambient}"); + Assert.Equal(0, quiet.ships); + Assert.Equal(0, quiet.turrets); + Assert.True(quiet.stations > 0); + Assert.Equal(ambient.stations, quiet.stations); + Assert.Contains(quietPack.Entities.OfType(), station => station.Story == 0); + } + + // A scenario's player is its fit (here a catalog preset), at the scripted place and heading, carrying its cargo. + [Fact] + public void AScenarioPlayerIsItsFitAtItsPlaceWithItsCargo() + { + var generator = new LoadoutGenerator(ref _items.Random, _items, null, null, null, 2); + var preset = Loadouts.Capture(_items, (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull.Name == "Djinni")), "test djinni"); + _cache.Upsert(preset); + // Two one-cell designs a manufacturer makes, by name, so the cargo is ordinary branded stock + var cargo = _cache.GetAll() + .Where(design => design.Shape.Coordinates.Length == 1) + .Where(design => _cache.GetAll().Any(p => p.Design.Key.Equals(_cache.RefOf(design).Key))) + .OrderBy(design => design.Name, StringComparer.Ordinal) + .Take(2) + .ToList(); + Assert.Equal(2, cargo.Count); + + var zone = Arena(true); + var player = Stage(zone, new Scripted(true, stage => + stage.Cargo(stage.Player(stage.Preset("test djinni"), float2(120, -40), float2(3, 4)), cargo.Select(design => design.Name).ToArray()))).Player; + + Assert.Equal("Djinni", player.HullData.Name); + SafeAssert.In(zone, player, "the player is admitted"); + Assert.Equal(float3(120, 0, -40), player.Position); + Assert.True(length(player.Direction - float2(.6f, .8f)) < 1e-5f, $"heading {player.Direction}"); + Assert.Equal( + preset.Slots.Select(slot => (slot.Position, slot.Design.Key)).OrderBy(s => s.ToString()), + player.Equipment.Concat(player.CargoBays).Concat(player.DockingBays) + .Where(item => item != player.EquippedHull) + .Select(item => (item.Position, _cache.RefOf(_items.GetData(item.EquippableItem)).Key)) + .OrderBy(s => s.ToString())); + Assert.Equal(cargo.Select(design => _cache.RefOf(design).Key.Value).OrderBy(k => k), + player.CargoBays.First().Cargo.Keys.Select(item => item.Data.Key.Value).OrderBy(k => k)); + } + + // A turret hull stages as an orbital entity with no orbit, which stays where it is put. + [Fact] + public void ATurretStagesAsAStationaryOrbitalEntity() + { + var zone = Arena(true); + var staged = Stage(zone, new Scripted(true, stage => + { + stage.Player(stage.Bare("Djinni"), float2(0, 0)); + stage.Place(stage.Bare(TurretHull().Name), float2(200, 50), stance: ScenarioStance.Hostile); + })); + var turret = Assert.IsType(staged.Entities[0]); + Assert.False(turret.OrbitData.IsSet()); + SafeAssert.In(zone, turret, "the turret is admitted"); + for (var tick = 0; tick < 20; tick++) zone.Update(.1f); + Assert.Equal(float2(200, 50), turret.Position.xz); + } + + // A run has exactly one player, and it is a ship; a station is never staged, and a turret is never piloted. Each + // is a staging failure, named, with nothing admitted. + [Fact] + public void AMisplacedHullOrPlayerIsAStagingFailure() + { + var station = _cache.GetAll().First(hull => hull.HullType == HullType.Station).Name; + var turret = TurretHull().Name; + foreach (var (script, expected) in new (Action, string)[] + { + (stage => { stage.Player(stage.Bare("Djinni"), float2(0, 0)); stage.Place(stage.Bare(station), float2(200, 0)); }, $"{station} is a station hull"), + (stage => stage.Player(stage.Bare(turret), float2(0, 0)), $"player: {turret} is not a ship hull"), + (stage => { stage.Player(stage.Bare("Djinni"), float2(0, 0)); stage.Place(stage.Bare(turret), float2(200, 0), piloted: true); }, "cannot be piloted"), + (stage => stage.Place(stage.Bare("Djinni"), float2(200, 0)), "places no player"), + (stage => { stage.Player(stage.Bare("Djinni"), float2(0, 0)); stage.Player(stage.Bare("Djinni"), float2(9, 0)); }, "a second player"), + }) + { + var failures = Refused(Arena(true), new Scripted(true, script)); + Assert.Contains(failures, failure => failure.Contains(expected)); + } + } + + // Arcs' geometry: the bow hull sits inside every one of the player's mount arcs, the stern hull outside all of them, + // and the turret is hostile. + [Fact] + public void ArcsGeometry() + { + var staged = Stage(Arena(false), new Arcs()); + var player = staged.Player; + var bow = staged.Entities[0]; + var stern = staged.Entities[1]; + var turret = Assert.IsType(staged.Entities[2]); + Assert.Equal((Arcs.Bow, Arcs.Stern, Arcs.Turret), (bow.Position.xz, stern.Position.xz, turret.Position.xz)); + + var mounts = player.Equipment.Where(item => item.Behaviors.OfType().Any()).ToList(); + Assert.NotEmpty(mounts); + foreach (var mount in mounts) + { + Assert.True(FireControl.InArc(mount, bow.Position - player.Position), $"{mount.Data.Name} cannot bear on the bow hull"); + Assert.False(FireControl.InArc(mount, stern.Position - player.Position), $"{mount.Data.Name} bears on the stern hull"); + } + Assert.True(player.IsHostileTo(turret) && turret.IsHostileTo(player), "the turret is hostile"); + Assert.False(player.IsHostileTo(bow) || player.IsHostileTo(stern), "the bare hulls are neutral"); + } + + // Q3 (operator, 2026-09-30): a design no product sells is a scenario test design and nothing else. A test design + // is one some scenario's staged entities carry, fitted or in cargo. These are the unsold designs none carries. + private List UnsoldAndUnplaced(IEnumerable runs) + { + var sold = _cache.GetAll().Select(product => product.Design.Key).ToHashSet(); + var placed = new HashSet(); + foreach (var entity in runs.SelectMany(run => run.Entities.Prepend(run.Player))) + { + foreach (var item in entity.Equipment.Concat(entity.CargoBays).Concat(entity.DockingBays)) + placed.Add(_cache.RefOf(_items.GetData(item.EquippableItem)).Key); + foreach (var item in entity.CargoBays.SelectMany(bay => bay.Cargo.Keys)) placed.Add(item.Data.Key); + } + return _cache.GetAll() + .Where(design => !sold.Contains(_cache.RefOf(design).Key) && !placed.Contains(_cache.RefOf(design).Key)) + .Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToList(); + } + + // The shipped catalog holds no unsold design that no scenario carries (today, none unsold at all). A new unsold + // design is flagged until a scenario fits it or carries it as cargo. + [Fact] + public void EveryUnsoldDesignIsAScenarioTestDesign() + { + var runs = EveryScenario.Select(scenario => Launch(scenario, Inputs(() => GalaxySeed)).staged).ToList(); + Assert.Empty(UnsoldAndUnplaced(runs)); + + void Probe(string name) + { + var shape = new Shape(1, 1); + shape[int2(0, 0)] = true; + _cache.Upsert(new GearData { Name = name, Hardpoint = HardpointType.Tool, Shape = shape, Durability = 1 }); + } + Probe("Census Fitted"); + Probe("Census Carried"); + Assert.Equal(new[] { "Census Carried", "Census Fitted" }, UnsoldAndUnplaced(runs)); + + runs.Add(Stage(Arena(false), new Scripted(false, stage => + { + var ship = stage.Player(stage.Fit("Djinni", ("Census Fitted", int2(1, 6), ItemRotation.None), ("Store-All Plus", int2(3, 6), ItemRotation.None)), float2(0, 0)); + stage.Cargo(ship, "Census Carried"); + }))); + Assert.Empty(UnsoldAndUnplaced(runs)); + } + // Whether a run is the prelude is the galaxy's fact (Galaxy.IsPrelude); a save records it with no second owner. [Fact] public void SaveReadsPrelude() { - var arena = Arena(null); + var arena = Arena(true); var (prelude, _) = RunSave.Capture(_cache, _galaxy, arena, null, new SavedActionBarBinding[0]); Assert.True(prelude.IsTutorial); - var (main, _) = RunSave.Capture(_cache, MainGalaxy(), arena, null, new SavedActionBarBinding[0]); + var (main, _) = RunSave.Capture(_cache, MainSectorGalaxy(), arena, null, new SavedActionBarBinding[0]); Assert.False(main.IsTutorial); } - // PlayerSettings Key 2 (TutorialPassed) is retired: a player.cc written with it still loads, every other field intact. + // PlayerSettings Key 2, the never-written tutorial flag, is retired: a player.cc written with it still loads, every other field intact. [Fact] public void OldPlayerSettingsLoad() { @@ -34,7 +448,7 @@ public sealed partial class RunStartTests writer.WriteArrayHeader(7); writer.Write("Pilot"); writer.WriteNil(); // Key 1, retired before this cut - writer.Write(true); // Key 2, TutorialPassed + writer.Write(true); // Key 2, the retired tutorial flag MessagePackSerializer.Serialize(ref writer, new Dictionary { ["story"] = "hash" }, options); MessagePackSerializer.Serialize(ref writer, new PlayerGameplaySettings { TemperatureUnit = TemperatureUnit.Kelvin, SignificantDigits = 5 }, options); MessagePackSerializer.Serialize(ref writer, new PlayerInputSettings { ActionBarInputs = { "/1" } }, options); From c0be2843ea335b18dfc94332d3f38fe5ca2c8bf8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 20:45:26 +0200 Subject: [PATCH 105/113] A galaxy's zone names are unique within the galaxy, not across the process MarkovNameGenerator kept every name it ever gave in a static list, so each later galaxy in a session drew different zone names at the same seed, and ZoneGenerator seeds a zone from its name: a fixed-seed scenario relaunched in one session got a different arena. Galaxy now passes one used-name set to its factions' generators. The unused Reset goes. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Galaxy.cs | 3 ++- Assets/Scripts/ServerShared/MarkovNameGenerator.cs | 14 ++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 89fa80794..7291a0c38 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -333,12 +333,13 @@ public class Galaxy ref Random random, Action progressCallback = null) { + var used = new HashSet(); for (var i = 0; i < Factions.Length; i++) { progressCallback?.Invoke($"Feeding Markov Chains: {i + 1} / {Factions.Length}"); //if(progressCallback!=null) Thread.Sleep(250); // Inserting Delay to make it seem like it's doing more work lmao var faction = Factions[i]; - _nameGenerators[faction] = new MarkovNameGenerator(ref random, cache.Get(faction.GeonameFile).Names, nameGeneratorSettings); + _nameGenerators[faction] = new MarkovNameGenerator(ref random, cache.Get(faction.GeonameFile).Names, nameGeneratorSettings, used); } // Generate zone name using the owner's name generator, otherwise assign catalogue ID diff --git a/Assets/Scripts/ServerShared/MarkovNameGenerator.cs b/Assets/Scripts/ServerShared/MarkovNameGenerator.cs index cf58a7b91..528684f40 100644 --- a/Assets/Scripts/ServerShared/MarkovNameGenerator.cs +++ b/Assets/Scripts/ServerShared/MarkovNameGenerator.cs @@ -14,14 +14,17 @@ public class MarkovNameGenerator { private Dictionary> _chains = new Dictionary>(); private List _samples = new List(); - private static List _used = new List(); + // The names already given, shared by every generator that must not repeat another's: a galaxy passes one set to + // all its factions' generators. Never process-wide, so one galaxy's names cannot change the next galaxy's. + private readonly HashSet _used; private int _order; private int _minLength; private int _maxLength; private Random _random; - public MarkovNameGenerator(ref Random random, IEnumerable sampleNames, NameGeneratorSettings settings) + public MarkovNameGenerator(ref Random random, IEnumerable sampleNames, NameGeneratorSettings settings, HashSet used) { + _used = used; var names = new HashSet(); foreach (var line in sampleNames) { @@ -41,6 +44,7 @@ public class MarkovNameGenerator public MarkovNameGenerator(ref Random random, IEnumerable sampleNames, int order, int minLength, int maxLength) { + _used = new HashSet(); _random = random; _order = max(1, order); @@ -113,12 +117,6 @@ public class MarkovNameGenerator } } - //Reset the used names - public void Reset() - { - _used.Clear(); - } - //Get a random letter from the chain private char GetLetter(string token) { From 0854d91d77b7c646b8030535bf92b0b1fb1b845a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 20:45:31 +0200 Subject: [PATCH 106/113] Arcs runs at seed 107: seed 103's prelude galaxy fails generation (a pre-existing KeyNotFound in Galaxy's home-zone placement) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Scenarios/Arcs.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index d1ceb61e0..cd7eec885 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -6,7 +6,7 @@ public sealed class Arcs : Scenario public override string Name => "Arcs"; public override string Brief => "Forward mounts, a bare hull off the bow, one off the stern, a hostile turret. Verify: the bow " + "hull can be hit, nothing fires at the stern one through the ship, the turret tracks you all the way round."; - public override uint Seed => 103; + public override uint Seed => 107; public override bool Ambient => false; // Where the targets sit: the player is at the origin facing +z, and every mount fires forward. From 494d7955507aba8642c303988954832483d0d78c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 20:55:02 +0200 Subject: [PATCH 107/113] Pin that generating a galaxy leaves the catalog's faction influence as authored Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ScenarioTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index c2a4e36b3..cebf564d6 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -161,6 +161,16 @@ public sealed partial class RunStartTests Assert.NotEqual(Layout(a), Layout(b)); } + // Generating a galaxy writes nothing a later one reads: a prelude leaves the catalog's faction influence as authored. + [Fact] + public void AGalaxyLeavesTheCatalogsFactionsAsAuthored() + { + var authored = _cache.GetAll().ToDictionary(faction => faction, faction => faction.InfluenceDistance); + RunStart.Generate(new TutorialGalaxy(), Inputs()); + RunStart.Generate(new MainGalaxy(), Inputs()); + Assert.All(authored, pair => Assert.Equal(pair.Value, pair.Key.InfluenceDistance)); + } + // A scenario naming one unknown design admits nothing, not even the parts that built, and names the design. [Fact] public void StagingIsAllOrNothing() From e56af28892cd81b695c75eb3b005bed665126543 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 21:59:09 +0200 Subject: [PATCH 108/113] Galaxy links zones along a double-precision Delaunay triangulation: the vendored float MIConvexHull dropped zones, so ~2% of galaxies threw The vendored MIConvexHull (NIH, ported from double to float) returned triangulations that left whole sets of interior zones with no cell at all (prelude seed 103: 22 of 64 zones; main seed 4247651650: 182 of 196). Those zones had no link, Distance lacked them, and the constructor threw KeyNotFoundException placing the antagonist home (prelude) or the entrance (main). Galaxy was its only consumer; Delaunay.Edges (Bowyer-Watson, double) replaces it, and the 3,896 vendored lines go. The three RestoredHullsTests loops that skipped seeds whose galaxy threw now require every seed to build, and the stale comment that Galaxy mutates Faction.InfluenceDistance goes with them. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Delaunay.cs | 97 +++ Assets/Scripts/ServerShared/Delaunay.cs.meta | 2 + Assets/Scripts/ServerShared/Galaxy.cs | 21 +- .../ServerShared/NIH/MIConvexHull.meta | 8 - .../ServerShared/NIH/MIConvexHull/Cell2.cs | 114 --- .../NIH/MIConvexHull/Cell2.cs.meta | 11 - .../NIH/MIConvexHull/Cell2Object.cs | 114 --- .../NIH/MIConvexHull/Cell2Object.cs.meta | 3 - .../NIH/MIConvexHull/Constants.cs | 46 -- .../NIH/MIConvexHull/Constants.cs.meta | 11 - .../NIH/MIConvexHull/ConvexHull.cs | 135 ---- .../NIH/MIConvexHull/ConvexHull.cs.meta | 11 - .../NIH/MIConvexHull/ConvexHull.meta | 8 - .../MIConvexHull/ConvexHull/Collections.cs | 304 -------- .../ConvexHull/Collections.cs.meta | 11 - .../NIH/MIConvexHull/ConvexHull/ConvexFace.cs | 69 -- .../ConvexHull/ConvexFace.cs.meta | 11 - .../ConvexHullAlgorithm.Initialize.cs | 721 ------------------ .../ConvexHullAlgorithm.Initialize.cs.meta | 11 - .../ConvexHullAlgorithm.MainLoop.cs | 635 --------------- .../ConvexHullAlgorithm.MainLoop.cs.meta | 11 - .../NIH/MIConvexHull/ConvexHull/DataModel.cs | 237 ------ .../MIConvexHull/ConvexHull/DataModel.cs.meta | 11 - .../NIH/MIConvexHull/ConvexHull/MathHelper.cs | 480 ------------ .../ConvexHull/MathHelper.cs.meta | 11 - .../MIConvexHull/ConvexHull/ObjectManager.cs | 218 ------ .../ConvexHull/ObjectManager.cs.meta | 11 - .../ServerShared/NIH/MIConvexHull/Face3.cs | 8 - .../NIH/MIConvexHull/Face3.cs.meta | 11 - .../ServerShared/NIH/MIConvexHull/IVertex.cs | 53 -- .../NIH/MIConvexHull/IVertex.cs.meta | 11 - .../NIH/MIConvexHull/Triangulation.cs | 181 ----- .../NIH/MIConvexHull/Triangulation.cs.meta | 11 - .../NIH/MIConvexHull/Triangulation.meta | 8 - .../DelaunayTrianglationInternal.cs | 96 --- .../DelaunayTrianglationInternal.cs.meta | 11 - .../Triangulation/DelaunayTriangulation.cs | 75 -- .../DelaunayTriangulation.cs.meta | 11 - .../Triangulation/TriangulationCell.cs | 51 -- .../Triangulation/TriangulationCell.cs.meta | 11 - .../MIConvexHull/Triangulation/VoronoiEdge.cs | 93 --- .../Triangulation/VoronoiEdge.cs.meta | 11 - .../MIConvexHull/Triangulation/VoronoiMesh.cs | 203 ----- .../Triangulation/VoronoiMesh.cs.meta | 11 - .../ServerShared/NIH/MIConvexHull/Vertex2.cs | 34 - .../NIH/MIConvexHull/Vertex2.cs.meta | 11 - .../NIH/MIConvexHull/Vertex2Object.cs | 16 - .../NIH/MIConvexHull/Vertex2Object.cs.meta | 3 - .../ServerShared/NIH/MIConvexHull/Vertex3.cs | 30 - .../NIH/MIConvexHull/Vertex3.cs.meta | 11 - .../RestoredHullsTests.cs | 28 +- tests/Aetheria.Shared.Tests/ScenarioTests.cs | 59 ++ 52 files changed, 165 insertions(+), 4205 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Delaunay.cs create mode 100644 Assets/Scripts/ServerShared/Delaunay.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2Object.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2Object.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Constants.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Constants.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/Collections.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/Collections.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexFace.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexFace.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.Initialize.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.Initialize.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.MainLoop.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.MainLoop.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/DataModel.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/DataModel.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/MathHelper.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/MathHelper.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ObjectManager.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ObjectManager.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Face3.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Face3.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/IVertex.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/IVertex.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTrianglationInternal.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTrianglationInternal.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTriangulation.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTriangulation.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/TriangulationCell.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/TriangulationCell.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiEdge.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiEdge.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiMesh.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiMesh.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2Object.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2Object.cs.meta delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex3.cs delete mode 100644 Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex3.cs.meta diff --git a/Assets/Scripts/ServerShared/Delaunay.cs b/Assets/Scripts/ServerShared/Delaunay.cs new file mode 100644 index 000000000..b6a25bba6 --- /dev/null +++ b/Assets/Scripts/ServerShared/Delaunay.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using CultMath; + +// The Delaunay triangulation of a galaxy's zone positions, as the edges Galaxy links zones along. Bowyer-Watson +// (Bowyer 1981, Watson 1981) in double precision: each point is inserted by removing every triangle whose +// circumcircle holds it and fanning the cavity's boundary to it. Every point lands in the triangulation, so for two or +// more distinct points the edge graph is connected. +public static class Delaunay +{ + // Each edge once, as (lower index, higher index), in ascending order. + public static List<(int a, int b)> Edges(IReadOnlyList points) + { + var n = points.Count; + var x = new double[n + 3]; + var y = new double[n + 3]; + double minX = double.MaxValue, minY = double.MaxValue, maxX = double.MinValue, maxY = double.MinValue; + for (var i = 0; i < n; i++) + { + x[i] = points[i].x; + y[i] = points[i].y; + minX = Math.Min(minX, x[i]); + minY = Math.Min(minY, y[i]); + maxX = Math.Max(maxX, x[i]); + maxY = Math.Max(maxY, y[i]); + } + + // A super-triangle far outside the points, so its vertices bend no edge between them; its triangles are + // dropped at the end. + var size = Math.Max(Math.Max(maxX - minX, maxY - minY), 1e-6) * 1000; + double midX = (minX + maxX) / 2, midY = (minY + maxY) / 2; + x[n] = midX - size; y[n] = midY - size; + x[n + 1] = midX + size; y[n + 1] = midY - size; + x[n + 2] = midX; y[n + 2] = midY + size; + + var triangles = new List { new Triangle(n, n + 1, n + 2, x, y) }; + var cavity = new Dictionary<(int, int), int>(); + for (var p = 0; p < n; p++) + { + cavity.Clear(); + for (var t = triangles.Count - 1; t >= 0; t--) + { + var triangle = triangles[t]; + var dx = x[p] - triangle.CenterX; + var dy = y[p] - triangle.CenterY; + if (dx * dx + dy * dy >= triangle.RadiusSquared) continue; + Count(cavity, triangle.A, triangle.B); + Count(cavity, triangle.B, triangle.C); + Count(cavity, triangle.C, triangle.A); + triangles[t] = triangles[triangles.Count - 1]; + triangles.RemoveAt(triangles.Count - 1); + } + + // The cavity's boundary is every edge only one removed triangle had. + foreach (var edge in cavity) + if (edge.Value == 1) + triangles.Add(new Triangle(edge.Key.Item1, edge.Key.Item2, p, x, y)); + } + + var edges = new SortedSet<(int a, int b)>(); + foreach (var triangle in triangles) + { + if (triangle.A >= n || triangle.B >= n || triangle.C >= n) continue; + edges.Add(Ordered(triangle.A, triangle.B)); + edges.Add(Ordered(triangle.B, triangle.C)); + edges.Add(Ordered(triangle.C, triangle.A)); + } + return new List<(int a, int b)>(edges); + } + + private static void Count(Dictionary<(int, int), int> cavity, int a, int b) + { + var key = Ordered(a, b); + cavity[key] = cavity.TryGetValue(key, out var seen) ? seen + 1 : 1; + } + + private static (int a, int b) Ordered(int a, int b) => a < b ? (a, b) : (b, a); + + private readonly struct Triangle + { + public readonly int A, B, C; + public readonly double CenterX, CenterY, RadiusSquared; + + public Triangle(int a, int b, int c, double[] x, double[] y) + { + A = a; + B = b; + C = c; + double ax = x[a], ay = y[a], bx = x[b], by = y[b], cx = x[c], cy = y[c]; + var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); + double a2 = ax * ax + ay * ay, b2 = bx * bx + by * by, c2 = cx * cx + cy * cy; + CenterX = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d; + CenterY = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d; + RadiusSquared = (ax - CenterX) * (ax - CenterX) + (ay - CenterY) * (ay - CenterY); + } + } +} diff --git a/Assets/Scripts/ServerShared/Delaunay.cs.meta b/Assets/Scripts/ServerShared/Delaunay.cs.meta new file mode 100644 index 000000000..917f6e6b4 --- /dev/null +++ b/Assets/Scripts/ServerShared/Delaunay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a05b77dab9114e19b222b79a8403fc21 diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 7291a0c38..bcdf7a2f8 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -7,7 +7,6 @@ using System.Threading; using System.Threading.Tasks; using DataStructures.ViliWonka.Heap; using Ink.Runtime; -using MIConvexHull; using JM.LinqFaster; using UniRx; using CultMath; @@ -361,22 +360,10 @@ public class Galaxy progressCallback?.Invoke("Triangulating Zone Positions"); if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao - // Create a delaunay triangulation to connect adjacent sectors - var triangulation = DelaunayTriangulation, Cell2> - .Create(Zones.Select(z => new Vertex2(z.Position, z)).ToList(), 1e-7f); - var links = new HashSet<(GalaxyZone, GalaxyZone)>(); - foreach (var cell in triangulation.Cells) - { - if (!links.Contains((cell.Vertices[0].StoredObject, cell.Vertices[1].StoredObject)) && - !links.Contains((cell.Vertices[1].StoredObject, cell.Vertices[0].StoredObject))) - links.Add((cell.Vertices[0].StoredObject, cell.Vertices[1].StoredObject)); - if (!links.Contains((cell.Vertices[1].StoredObject, cell.Vertices[2].StoredObject)) && - !links.Contains((cell.Vertices[2].StoredObject, cell.Vertices[1].StoredObject))) - links.Add((cell.Vertices[1].StoredObject, cell.Vertices[2].StoredObject)); - if (!links.Contains((cell.Vertices[0].StoredObject, cell.Vertices[2].StoredObject)) && - !links.Contains((cell.Vertices[2].StoredObject, cell.Vertices[0].StoredObject))) - links.Add((cell.Vertices[0].StoredObject, cell.Vertices[2].StoredObject)); - } + // The Delaunay triangulation connects adjacent sectors, every zone among them + var links = Delaunay.Edges(Zones.Select(z => z.Position).ToArray()) + .Select(edge => (Zones[edge.a], Zones[edge.b])) + .ToList(); progressCallback?.Invoke("Eliminating Zone Links"); if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull.meta deleted file mode 100644 index 5f927b59d..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 4a109a0b0bb6e8f40935ca2ec55e5bd7 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2.cs deleted file mode 100644 index 6e8a8d2f9..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Collections; -using System.Linq; -using MIConvexHull; -using CultMath; -using static CultMath.math; - -/// -/// A vertex is a simple class that stores the postion of a point, node or vertex. -/// -public class Cell2 : TriangulationCell -{ - float2? circumCenter, centroid; - - public float2 Circumcenter { - get { - circumCenter = circumCenter ?? GetCircumcenter(); - return circumCenter.Value; - } - } - - public float2 Centroid { - get { - centroid = centroid ?? GetCentroid(); - return centroid.Value; - } - } - - public Cell2 () - { - - } - - double LengthSquared (float[] v) - { - float norm = 0; - for (int i = 0; i < v.Length; i++) { - var t = v [i]; - norm += t * t; - } - return norm; - } - - float Determinant(float[,] m) - { - float fCofactor00 = m[1,1] * m[2,2] - m[1,2] * m[2,1]; - float fCofactor10 = m[1,2] * m[2,0] - m[1,0] * m[2,2]; - float fCofactor20 = m[1,0] * m[2,1] - m[1,1] * m[2,0]; - - float fDet = m[0,0] * fCofactor00 + m[0,1] * fCofactor10 + m[0,2] * fCofactor20; - - return fDet; - } - - float2 GetCircumcenter () - { - // From MathWorld: http://mathworld.wolfram.com/Circumcircle.html - - var points = Vertices; - - float[,] m = new float[3, 3]; - - // x, y, 1 - for (int i = 0; i < 3; i++) { - m[i, 0] = points[i].x; - m[i, 1] = points[i].y; - m[i, 2] = 1; - } - var a = Determinant(m); - - // size, y, 1 - for (int i = 0; i < 3; i++) { - m[i, 0] = lengthsq(points[i].StoredPosition); - } - var dx = -Determinant(m); - - // size, x, 1 - for (int i = 0; i < 3; i++) { - m[i, 1] = points[i].x; - } - var dy = Determinant(m); - - // size, x, y - //for (int i = 0; i < 3; i++) { - // m[i, 2] = points[i].y; - //} - //var c = -Det(m); - - var s = -1.0f / (2.0f * a); - //var r = System.Math.Abs(s) * System.Math.Sqrt(dx * dx + dy * dy - 4 * a * c); - - return new float2(s * dx, s * dy); - } - - float2 GetCentroid () - { - return new float2(Vertices.Select(v => v.Position[0]).Average(), Vertices.Select(v => v.Position[1]).Average()); - } - -} - - - - - - - - - - - - - - - diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2.cs.meta deleted file mode 100644 index 52993549b..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: adf902e96e0c51049b65bc002d6ef4ca -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2Object.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2Object.cs deleted file mode 100644 index d23fbac51..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2Object.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Collections; -using System.Linq; -using MIConvexHull; -using CultMath; -using static CultMath.math; - -/// -/// A vertex is a simple class that stores the postion of a point, node or vertex. -/// -public class Cell2 : TriangulationCell, Cell2> -{ - float2? circumCenter, centroid; - - public float2 Circumcenter { - get { - circumCenter = circumCenter ?? GetCircumcenter(); - return circumCenter.Value; - } - } - - public float2 Centroid { - get { - centroid = centroid ?? GetCentroid(); - return centroid.Value; - } - } - - public Cell2 () - { - - } - - double LengthSquared (float[] v) - { - float norm = 0; - for (int i = 0; i < v.Length; i++) { - var t = v [i]; - norm += t * t; - } - return norm; - } - - float Determinant(float[,] m) - { - float fCofactor00 = m[1,1] * m[2,2] - m[1,2] * m[2,1]; - float fCofactor10 = m[1,2] * m[2,0] - m[1,0] * m[2,2]; - float fCofactor20 = m[1,0] * m[2,1] - m[1,1] * m[2,0]; - - float fDet = m[0,0] * fCofactor00 + m[0,1] * fCofactor10 + m[0,2] * fCofactor20; - - return fDet; - } - - float2 GetCircumcenter () - { - // From MathWorld: http://mathworld.wolfram.com/Circumcircle.html - - var points = Vertices; - - float[,] m = new float[3, 3]; - - // x, y, 1 - for (int i = 0; i < 3; i++) { - m[i, 0] = points[i].x; - m[i, 1] = points[i].y; - m[i, 2] = 1; - } - var a = Determinant(m); - - // size, y, 1 - for (int i = 0; i < 3; i++) { - m[i, 0] = lengthsq(points[i].StoredPosition); - } - var dx = -Determinant(m); - - // size, x, 1 - for (int i = 0; i < 3; i++) { - m[i, 1] = points[i].x; - } - var dy = Determinant(m); - - // size, x, y - //for (int i = 0; i < 3; i++) { - // m[i, 2] = points[i].y; - //} - //var c = -Det(m); - - var s = -1.0f / (2.0f * a); - //var r = System.Math.Abs(s) * System.Math.Sqrt(dx * dx + dy * dy - 4 * a * c); - - return new float2(s * dx, s * dy); - } - - float2 GetCentroid () - { - return new float2(Vertices.Select(v => v.Position[0]).Average(), Vertices.Select(v => v.Position[1]).Average()); - } - -} - - - - - - - - - - - - - - - diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2Object.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2Object.cs.meta deleted file mode 100644 index d47180ee1..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2Object.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: ea854ab9e5204f92828e92ffb1244964 -timeCreated: 1615420193 \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Constants.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Constants.cs deleted file mode 100644 index 20948bc1a..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Constants.cs +++ /dev/null @@ -1,46 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -using System; - -namespace MIConvexHull -{ - internal static class Constants - { - /// - /// The default plane distance tolerance - /// - internal const float DefaultPlaneDistanceTolerance = 1e-10f; - /// - /// The starting delta dot product in simplex - /// - internal const float StartingDeltaDotProductInSimplex = 0.5f; - /// - /// The connector table size - /// - internal const int ConnectorTableSize = 2017; - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Constants.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Constants.cs.meta deleted file mode 100644 index 3c5287e11..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Constants.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: ee065f025d2f5374db2ceaa973b822d4 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.cs deleted file mode 100644 index 1ef83b8a9..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.cs +++ /dev/null @@ -1,135 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace MIConvexHull -{ - /// - /// Factory class for computing convex hulls. - /// - public static class ConvexHull - { - /// - /// Creates a convex hull of the input data. - /// - /// The type of the t vertex. - /// The type of the t face. - /// The data. - /// The plane distance tolerance (default is 1e-10). If too high, points - /// will be missed. If too low, the algorithm may break. Only adjust if you notice problems. - /// - /// ConvexHull<TVertex, TFace>. - /// - public static ConvexHull Create(IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - where TVertex : IVertex - where TFace : ConvexFace, new() - { - return ConvexHull.Create(data, PlaneDistanceTolerance); - } - - /// - /// Creates a convex hull of the input data. - /// - /// The type of the t vertex. - /// The data. - /// The plane distance tolerance (default is 1e-10). If too high, points - /// will be missed. If too low, the algorithm may break. Only adjust if you notice problems. - /// - /// ConvexHull<TVertex, DefaultConvexFace<TVertex>>. - /// - public static ConvexHull> Create(IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - where TVertex : IVertex - { - return ConvexHull>.Create(data, PlaneDistanceTolerance); - } - - /// - /// Creates a convex hull of the input data. - /// - /// The data. - /// The plane distance tolerance (default is 1e-10). If too high, points - /// will be missed. If too low, the algorithm may break. Only adjust if you notice problems. - /// - /// ConvexHull<DefaultVertex, DefaultConvexFace<DefaultVertex>>. - /// - public static ConvexHull> Create(IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - { - var points = data.Select(p => new DefaultVertex { Position = p }).ToList(); - return ConvexHull>.Create(points, PlaneDistanceTolerance); - } - } - - /// - /// Representation of a convex hull. - /// - /// The type of the t vertex. - /// The type of the t face. - public class ConvexHull - where TVertex : IVertex - where TFace : ConvexFace, new() - { - /// - /// Can only be created using a factory method. - /// - internal ConvexHull() - { - } - - /// - /// Points of the convex hull. - /// - /// The points. - public IEnumerable Points { get; internal set; } - - /// - /// Faces of the convex hull. - /// - /// The faces. - public IEnumerable Faces { get; internal set; } - - /// - /// Creates the convex hull. - /// - /// The data. - /// The plane distance tolerance. - /// - /// ConvexHull<TVertex, TFace>. - /// - /// The supplied data is null. - /// data - public static ConvexHull Create(IList data, float PlaneDistanceTolerance) - { - if (data == null) throw new ArgumentNullException("The supplied data is null."); - return ConvexHullAlgorithm.GetConvexHull(data, PlaneDistanceTolerance); - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.cs.meta deleted file mode 100644 index 0be10d4af..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 447289e858e19c14abbe91dfbd0e3089 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.meta deleted file mode 100644 index 6a291ff20..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: a6e88469e3b81a541a0bc8a66e60d0c7 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/Collections.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/Collections.cs deleted file mode 100644 index ace305648..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/Collections.cs +++ /dev/null @@ -1,304 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -using System; - -namespace MIConvexHull -{ - /// - /// A more lightweight alternative to List of T. - /// On clear, only resets the count and does not clear the references - /// => this works because of the ObjectManager. - /// Includes a stack functionality. - /// - /// - internal class SimpleList - { - /// - /// The capacity - /// - private int capacity; - - /// - /// The count - /// - public int Count; - /// - /// The items - /// - private T[] items; - - /// - /// Get the i-th element. - /// - /// The i. - /// T. - public T this[int i] - { - get { return items[i]; } - set { items[i] = value; } - } - - /// - /// Size matters. - /// - private void EnsureCapacity() - { - if (capacity == 0) - { - capacity = 32; - items = new T[32]; - } - else - { - var newItems = new T[capacity*2]; - Array.Copy(items, newItems, capacity); - capacity = 2*capacity; - items = newItems; - } - } - - /// - /// Adds a vertex to the buffer. - /// - /// The item. - public void Add(T item) - { - if (Count + 1 > capacity) EnsureCapacity(); - items[Count++] = item; - } - - /// - /// Pushes the value to the back of the list. - /// - /// The item. - public void Push(T item) - { - if (Count + 1 > capacity) EnsureCapacity(); - items[Count++] = item; - } - - /// - /// Pops the last value from the list. - /// - /// T. - public T Pop() - { - return items[--Count]; - } - - /// - /// Sets the Count to 0, otherwise does nothing. - /// - public void Clear() - { - Count = 0; - } - } - - - /// - /// Class IndexBuffer. - /// A fancy name for a list of integers. - /// - internal class IndexBuffer : SimpleList - { - } - - /// - /// A priority based linked list. - /// - internal sealed class FaceList - { - /// - /// The last - /// - private ConvexFaceInternal last; - - /// - /// Get the first element. - /// - /// The first. - public ConvexFaceInternal First { get; private set; } - - /// - /// Adds the element to the beginning. - /// - /// The face. - private void AddFirst(ConvexFaceInternal face) - { - face.InList = true; - First.Previous = face; - face.Next = First; - First = face; - } - - /// - /// Adds a face to the list. - /// - /// The face. - public void Add(ConvexFaceInternal face) - { - if (face.InList) - { - if (First.VerticesBeyond.Count < face.VerticesBeyond.Count) - { - Remove(face); - AddFirst(face); - } - return; - } - - face.InList = true; - - if (First != null && First.VerticesBeyond.Count < face.VerticesBeyond.Count) - { - First.Previous = face; - face.Next = First; - First = face; - } - else - { - if (last != null) - { - last.Next = face; - } - face.Previous = last; - last = face; - if (First == null) - { - First = face; - } - } - } - - /// - /// Removes the element from the list. - /// - /// The face. - public void Remove(ConvexFaceInternal face) - { - if (!face.InList) return; - - face.InList = false; - - if (face.Previous != null) - { - face.Previous.Next = face.Next; - } - else if ( /*first == face*/ face.Previous == null) - { - First = face.Next; - } - - if (face.Next != null) - { - face.Next.Previous = face.Previous; - } - else if ( /*last == face*/ face.Next == null) - { - last = face.Previous; - } - - face.Next = null; - face.Previous = null; - } - } - - /// - /// Connector list. - /// - internal sealed class ConnectorList - { - /// - /// The last - /// - private FaceConnector last; - - /// - /// Get the first element. - /// - /// The first. - public FaceConnector First { get; private set; } - - /// - /// Adds the element to the beginning. - /// - /// The connector. - private void AddFirst(FaceConnector connector) - { - First.Previous = connector; - connector.Next = First; - First = connector; - } - - /// - /// Adds a face to the list. - /// - /// The element. - public void Add(FaceConnector element) - { - if (last != null) - { - last.Next = element; - } - element.Previous = last; - last = element; - if (First == null) - { - First = element; - } - } - - /// - /// Removes the element from the list. - /// - /// The connector. - public void Remove(FaceConnector connector) - { - if (connector.Previous != null) - { - connector.Previous.Next = connector.Next; - } - else if ( /*first == face*/ connector.Previous == null) - { - First = connector.Next; - } - - if (connector.Next != null) - { - connector.Next.Previous = connector.Previous; - } - else if ( /*last == face*/ connector.Next == null) - { - last = connector.Previous; - } - - connector.Next = null; - connector.Previous = null; - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/Collections.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/Collections.cs.meta deleted file mode 100644 index 323e260ef..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/Collections.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 3969283304824fe4a8eca1eb522fd3a7 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexFace.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexFace.cs deleted file mode 100644 index 34fab61b2..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexFace.cs +++ /dev/null @@ -1,69 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -namespace MIConvexHull -{ - /// - /// A convex face representation containing adjacency information. - /// - /// The type of the t vertex. - /// The type of the t face. - public abstract class ConvexFace - where TVertex : IVertex - where TFace : ConvexFace - { - /// - /// Adjacency. Array of length "dimension". - /// If F = Adjacency[i] then the vertices shared with F are Vertices[j] where j != i. - /// In the context of triangulation, can be null (indicates the cell is at boundary). - /// - /// The adjacency. - public TFace[] Adjacency { get; set; } - - /// - /// The vertices stored in clockwise order for dimensions 2 - 4, in higher dimensions the order is arbitrary. - /// Unless I accidentally switch some index somewhere in which case the order is CCW. Either way, it is consistent. - /// 3D Normal = (V[1] - V[0]) x (V[2] - V[1]). - /// - /// The vertices. - public TVertex[] Vertices { get; set; } - - /// - /// The normal vector of the face. Null if used in triangulation. - /// - /// The normal. - public float[] Normal { get; set; } - } - - /// - /// A default convex face representation. - /// - /// The type of the t vertex. - public class DefaultConvexFace : ConvexFace> - where TVertex : IVertex - { - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexFace.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexFace.cs.meta deleted file mode 100644 index 40c26cd01..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexFace.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 5db1a96860ec6d949930695ae9bedcc0 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.Initialize.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.Initialize.cs deleted file mode 100644 index e4b8477ae..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.Initialize.cs +++ /dev/null @@ -1,721 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace MIConvexHull -{ - /* - * This part of the implementation handles initialization of the convex hull algorithm: - * - Constructor & Process initiation - * - Determine the dimension by looking at length of Position vector of 10 random data points from the input. - * - Identify bounding box points in each direction. - * - Pick (Dimension + 1) points from the extremes and construct the initial simplex. - */ - - /// - /// Class ConvexHullAlgorithm. - /// - internal partial class ConvexHullAlgorithm - { - #region Starting functions and constructor - /// - /// The main function for the Convex Hull algorithm. It is static, but it creates - /// an instantiation of this class in order to allow for parallel execution. - /// Following this simple function, the constructor and the main function "FindConvexHull" is listed. - /// - /// The type of the vertices in the data. - /// The desired type of the faces. - /// The data is the vertices as a collection of IVertices. - /// The plane distance tolerance. - /// - /// MIConvexHull.ConvexHull<TVertex, TFace>. - /// - internal static ConvexHull GetConvexHull(IList data, - float PlaneDistanceTolerance) - where TFace : ConvexFace, new() - where TVertex : IVertex - { - var ch = new ConvexHullAlgorithm(data.Cast().ToArray(), false, PlaneDistanceTolerance); - ch.GetConvexHull(); - - if (ch.NumOfDimensions == 2) return ch.Return2DResultInOrder(data); - return new ConvexHull - { - Points = ch.GetHullVertices(data), - Faces = ch.GetConvexFaces() - }; - } - - /// - /// Initializes a new instance of the class. - /// - /// The vertices. - /// if set to true [lift]. - /// The plane distance tolerance. - /// Dimension of the input must be 2 or greater. - /// There are too few vertices (m) for the n-dimensional space. (m must be greater + - /// than the n, but m is + NumberOfVertices + and n is + NumOfDimensions - /// PointTranslationGenerator cannot be null if PointTranslationType is enabled. - /// or - /// Dimension of the input must be 2 or greater. - /// There are too few vertices (m) for the n-dimensional space. (m must be greater " + - /// "than the n, but m is " + NumberOfVertices + " and n is " + Dimension - private ConvexHullAlgorithm(IVertex[] vertices, bool lift, float PlaneDistanceTolerance) - { - IsLifted = lift; - Vertices = vertices; - NumberOfVertices = vertices.Length; - - NumOfDimensions = DetermineDimension(); - if (IsLifted) NumOfDimensions++; - if (NumOfDimensions < 2) throw new InvalidOperationException("Dimension of the input must be 2 or greater."); - if (NumberOfVertices <= NumOfDimensions) - throw new ArgumentException( - "There are too few vertices (m) for the n-dimensional space. (m must be greater " + - "than the n, but m is " + NumberOfVertices + " and n is " + NumOfDimensions); - this.PlaneDistanceTolerance = PlaneDistanceTolerance; - UnprocessedFaces = new FaceList(); - ConvexFaces = new IndexBuffer(); - - FacePool = new ConvexFaceInternal[(NumOfDimensions + 1) * 10]; // must be initialized before object manager - AffectedFaceFlags = new bool[(NumOfDimensions + 1) * 10]; - ObjectManager = new ObjectManager(this); - - Center = new float[NumOfDimensions]; - TraverseStack = new IndexBuffer(); - UpdateBuffer = new int[NumOfDimensions]; - UpdateIndices = new int[NumOfDimensions]; - EmptyBuffer = new IndexBuffer(); - AffectedFaceBuffer = new IndexBuffer(); - ConeFaceBuffer = new SimpleList(); - SingularVertices = new HashSet(); - BeyondBuffer = new IndexBuffer(); - - ConnectorTable = new ConnectorList[Constants.ConnectorTableSize]; - for (var i = 0; i < Constants.ConnectorTableSize; i++) ConnectorTable[i] = new ConnectorList(); - - VertexVisited = new bool[NumberOfVertices]; - Positions = new float[NumberOfVertices * NumOfDimensions]; - boundingBoxPoints = new List[NumOfDimensions]; - minima = new float[NumOfDimensions]; - maxima = new float[NumOfDimensions]; - mathHelper = new MathHelper(NumOfDimensions, Positions); - } - /// - /// Check the dimensionality of the input data. - /// - /// System.Int32. - /// Invalid input data (non-uniform dimension). - private int DetermineDimension() - { - var r = new Random(); - var dimensions = new List(); - for (var i = 0; i < 10; i++) - dimensions.Add(Vertices[r.Next(NumberOfVertices)].Position.Length); - var dimension = dimensions.Min(); - if (dimension != dimensions.Max()) - throw new ArgumentException("Invalid input data (non-uniform dimension)."); - return dimension; - } - - - /// - /// Gets/calculates the convex hull. This is - /// - private void GetConvexHull() - { - // accessing a 1D array is quicker than a jagged array, so the first step is to make this array - SerializeVerticesToPositions(); - // next the bounding box extremes are found. This is used to shift, scale and find the starting simplex. - FindBoundingBoxPoints(); - // the positions are shifted to avoid divide by zero problems - // and if Delaunay or Voronoi, then the parabola terms are scaled back to match the size of the other coords - ShiftAndScalePositions(); - // Find the (dimension+1) initial points and create the simplexes. - CreateInitialSimplex(); - - // Now, the main loop. These initial faces of a simplex are replaced and expanded - // outwards to make the convex hull and faces. - while (UnprocessedFaces.First != null) - { - var currentFace = UnprocessedFaces.First; - CurrentVertex = currentFace.FurthestVertex; - - UpdateCenter(); - - // The affected faces get tagged - TagAffectedFaces(currentFace); - - // Create the cone from the currentVertex and the affected faces horizon. - if (!SingularVertices.Contains(CurrentVertex) && CreateCone()) CommitCone(); - else HandleSingular(); - - // Need to reset the tags - var count = AffectedFaceBuffer.Count; - for (var i = 0; i < count; i++) AffectedFaceFlags[AffectedFaceBuffer[i]] = false; - } - } - - #endregion - - /// - /// Serializes the vertices into the 1D array, Positions. The 1D array has much quicker access in C#. - /// - private void SerializeVerticesToPositions() - { - var index = 0; - if (IsLifted) // "Lifted" means that the last dimension is the sum of the squares of the others. - { - foreach (var v in Vertices) - { - var parabolaTerm = 0.0f; // the lifted term is a sum of squares. - var origNumDim = NumOfDimensions - 1; - for (var i = 0; i < origNumDim; i++) - { - var coordinate = v.Position[i]; - Positions[index++] = coordinate; - parabolaTerm += coordinate * coordinate; - } - Positions[index++] = parabolaTerm; - } - } - else - foreach (var v in Vertices) - { - for (var i = 0; i < NumOfDimensions; i++) - Positions[index++] = v.Position[i]; - } - } - - - - /// - /// Finds the bounding box points. - /// - private void FindBoundingBoxPoints() - { - indexOfDimensionWithLeastExtremes = -1; - var minNumExtremes = int.MaxValue; - for (var i = 0; i < NumOfDimensions; i++) - { - var minIndices = new List(); - var maxIndices = new List(); - float min = float.PositiveInfinity, max = float.NegativeInfinity; - for (var j = 0; j < NumberOfVertices; j++) - { - var v = GetCoordinate(j, i); - var difference = min - v; - if (difference >= PlaneDistanceTolerance) - { - // you found a better solution than before, clear out the list and store new value - min = v; - minIndices.Clear(); - minIndices.Add(j); - } - else if (difference > 0) - { - // you found a solution slightly better than before, clear out those that are no longer on the list and store new value - min = v; - minIndices.RemoveAll(index => min - GetCoordinate(index, i) > PlaneDistanceTolerance); - minIndices.Add(j); - } - else if (difference > -PlaneDistanceTolerance) - { - //same or almost as good as current limit, so store it - minIndices.Add(j); - } - difference = v - max; - if (difference >= PlaneDistanceTolerance) - { - // you found a better solution than before, clear out the list and store new value - max = v; - maxIndices.Clear(); - maxIndices.Add(j); - } - else if (difference > 0) - { - // you found a solution slightly better than before, clear out those that are no longer on the list and store new value - max = v; - maxIndices.RemoveAll(index => min - GetCoordinate(index, i) > PlaneDistanceTolerance); - maxIndices.Add(j); - } - else if (difference > -PlaneDistanceTolerance) - { - //same or almost as good as current limit, so store it - maxIndices.Add(j); - } - } - minima[i] = min; - maxima[i] = max; - minIndices.AddRange(maxIndices); - if (minIndices.Count < minNumExtremes) - { - minNumExtremes = minIndices.Count; - indexOfDimensionWithLeastExtremes = i; - } - boundingBoxPoints[i] = minIndices; - } - } - - /// - /// Shifts and scales the Positions to avoid future errors. This does not alter the original data. - /// - private void ShiftAndScalePositions() - { - var positionsLength = Positions.Length; - if (IsLifted) - { - var origNumDim = NumOfDimensions - 1; - var parabolaScale = 2 / (minima.Sum(x => Math.Abs(x)) + maxima.Sum(x => Math.Abs(x)) - - Math.Abs(maxima[origNumDim]) - Math.Abs(minima[origNumDim])); - // the parabola scale is 1 / average of the sum of the other dimensions. - // multiplying this by the parabola will scale it back to be on near similar size to the - // other dimensions. Without this, the term is much larger than the others, which causes - // problems for roundoff error and finding the normal of faces. - minima[origNumDim] *= parabolaScale; // change the extreme values as well - maxima[origNumDim] *= parabolaScale; - // it is done here because - for (int i = origNumDim; i < positionsLength; i += NumOfDimensions) - Positions[i] *= parabolaScale; - } - var shiftAmount = new float[NumOfDimensions]; - for (int i = 0; i < NumOfDimensions; i++) - // now the entire model is shifted to all positive numbers...plus some more. - // why? - // 1) to avoid dealing with a point at the origin {0,0,...,0} which causes problems - // for future normal finding - // 2) note that weird shift that is used (max - min - min). This is to avoid scaling - // issues. this shift means that the minima in a dimension will always be a positive - // number (no points at zero), and the minima [in a given dimension] will always be - // half of the maxima. 'Half' is much preferred to 'thousands of times' - // Think of the first term as the range (max - min), then the second term avoids cases - // where there are both positive and negative numbers. - if (maxima[i] == minima[i]) shiftAmount[i] = 0.0f; - else shiftAmount[i] = (maxima[i] - minima[i]) - minima[i]; - for (int i = 0; i < positionsLength; i++) - Positions[i] += shiftAmount[i % NumOfDimensions]; - } - - /// - /// Find the (dimension+1) initial points and create the simplexes. - /// Creates the initial simplex of n+1 vertices by using points from the bounding box. - /// Special care is taken to ensure that the vertices chosen do not result in a degenerate shape - /// where vertices are collinear (co-planar, etc). This would technically be resolved when additional - /// vertices are checked in the main loop, but: 1) a degenerate simplex would not eliminate any other - /// vertices (thus no savings there), 2) the creation of the face normal is prone to error. - /// - private void CreateInitialSimplex() - { - var initialPoints = FindInitialPoints(); - #region Create the first faces from (dimension + 1) vertices. - - var faces = new int[NumOfDimensions + 1]; - - for (var i = 0; i < NumOfDimensions + 1; i++) - { - var vertices = new int[NumOfDimensions]; - for (int j = 0, k = 0; j <= NumOfDimensions; j++) - { - if (i != j) vertices[k++] = initialPoints[j]; - } - var newFace = FacePool[ObjectManager.GetFace()]; - newFace.Vertices = vertices; - Array.Sort(vertices); - mathHelper.CalculateFacePlane(newFace, Center); - faces[i] = newFace.Index; - } - // update the adjacency (check all pairs of faces) - for (var i = 0; i < NumOfDimensions; i++) - for (var j = i + 1; j < NumOfDimensions + 1; j++) UpdateAdjacency(FacePool[faces[i]], FacePool[faces[j]]); - - #endregion - - #region Init the vertex beyond buffers. - - foreach (var faceIndex in faces) - { - var face = FacePool[faceIndex]; - FindBeyondVertices(face); - if (face.VerticesBeyond.Count == 0) ConvexFaces.Add(face.Index); // The face is on the hull - else UnprocessedFaces.Add(face); - } - - #endregion - - // Set all vertices to false (unvisited). - foreach (var vertex in initialPoints) VertexVisited[vertex] = false; - } - - /// - /// Finds (dimension + 1) initial points. - /// - /// List<System.Int32>. - /// The input data is degenerate. It appears to exist in " + NumOfDimensions + - /// " dimensions, but it is a " + (NumOfDimensions - 1) + " dimensional set (i.e. the point of collinear," - /// + " coplanar, or co-hyperplanar.) - private List FindInitialPoints() - { - var bigNumber = maxima.Sum() * NumOfDimensions * NumberOfVertices; - // the first two points are taken from the dimension that had the fewest extremes - // well, in most cases there will only be 2 in all dimensions: one min and one max - // but a lot of engineering part shapes are nice and square and can have hundreds of - // parallel vertices at the extremes - var vertex1 = boundingBoxPoints[indexOfDimensionWithLeastExtremes].First(); // these are min and max vertices along - var vertex2 = boundingBoxPoints[indexOfDimensionWithLeastExtremes].Last(); // the dimension that had the fewest points - boundingBoxPoints[indexOfDimensionWithLeastExtremes].RemoveAt(0); - boundingBoxPoints[indexOfDimensionWithLeastExtremes].RemoveAt(boundingBoxPoints[indexOfDimensionWithLeastExtremes].Count - 1); - var initialPoints = new List { vertex1, vertex2 }; - VertexVisited[vertex1] = VertexVisited[vertex2] = true; - CurrentVertex = vertex1; UpdateCenter(); - CurrentVertex = vertex2; UpdateCenter(); - var edgeVectors = new float[NumOfDimensions][]; - edgeVectors[0] = mathHelper.VectorBetweenVertices(vertex2, vertex1); - // now the remaining vertices are just combined in one big list - var extremes = boundingBoxPoints.SelectMany(x => x).ToList(); - // otherwise find the remaining points by maximizing the initial simplex volume - var index = 1; - while (index < NumOfDimensions && extremes.Any()) - { - var bestVertex = -1; - var bestEdgeVector = new float[] { }; - var maxVolume = Constants.DefaultPlaneDistanceTolerance; - for (var i = extremes.Count - 1; i >= 0; i--) - { - // count backwards in order to remove potential duplicates - var vIndex = extremes[i]; - if (initialPoints.Contains(vIndex)) extremes.RemoveAt(i); - else - { - edgeVectors[index] = mathHelper.VectorBetweenVertices(vIndex, vertex1); - var volume = mathHelper.GetSimplexVolume(edgeVectors, index, bigNumber); - if (maxVolume < volume) - { - maxVolume = volume; - bestVertex = vIndex; - bestEdgeVector = edgeVectors[index]; - } - } - } - extremes.Remove(bestVertex); - if (bestVertex == -1) break; - initialPoints.Add(bestVertex); - edgeVectors[index++] = bestEdgeVector; - CurrentVertex = bestVertex; UpdateCenter(); - } - // hmm, there are not enough points on the bounding box to make a simplex. It is rare but entirely possibly. - // As an extreme, the bounding box can be made in n dimensions from only 2 unique points. When we can't find - // enough unique points, we start again with ALL the vertices. The following is a near replica of the code - // above, but instead of extremes, we consider "allVertices". - if (initialPoints.Count <= NumOfDimensions && !IsLifted) - { - var allVertices = Enumerable.Range(0, NumberOfVertices).ToList(); - while (index < NumOfDimensions && allVertices.Any()) - { - var bestVertex = -1; - var bestEdgeVector = new float[] { }; - var maxVolume = 0.0; - for (var i = allVertices.Count - 1; i >= 0; i--) - { - // count backwards in order to remove potential duplicates - var vIndex = allVertices[i]; - if (initialPoints.Contains(vIndex)) allVertices.RemoveAt(i); - else - { - edgeVectors[index] = mathHelper.VectorBetweenVertices(vIndex, vertex1); - var volume = mathHelper.GetSimplexVolume(edgeVectors, index, bigNumber); - if (maxVolume < volume) - { - maxVolume = volume; - bestVertex = vIndex; - bestEdgeVector = edgeVectors[index]; - } - } - } - allVertices.Remove(bestVertex); - if (bestVertex == -1) break; - initialPoints.Add(bestVertex); - edgeVectors[index++] = bestEdgeVector; - CurrentVertex = bestVertex; UpdateCenter(); - } - } - if (initialPoints.Count <= NumOfDimensions && IsLifted) - { - var allVertices = Enumerable.Range(0, NumberOfVertices).ToList(); - while (index < NumOfDimensions && allVertices.Any()) - { - var bestVertex = -1; - var bestEdgeVector = new float[] { }; - var maxVolume = 0.0; - for (var i = allVertices.Count - 1; i >= 0; i--) - { - // count backwards in order to remove potential duplicates - var vIndex = allVertices[i]; - if (initialPoints.Contains(vIndex)) allVertices.RemoveAt(i); - else - { - mathHelper.RandomOffsetToLift(vIndex, maxima.Last()-minima.Last()); - edgeVectors[index] = mathHelper.VectorBetweenVertices(vIndex, vertex1); - var volume = mathHelper.GetSimplexVolume(edgeVectors, index, bigNumber); - if (maxVolume < volume) - { - maxVolume = volume; - bestVertex = vIndex; - bestEdgeVector = edgeVectors[index]; - } - } - } - allVertices.Remove(bestVertex); - if (bestVertex == -1) break; - initialPoints.Add(bestVertex); - edgeVectors[index++] = bestEdgeVector; - CurrentVertex = bestVertex; UpdateCenter(); - } - } - if (initialPoints.Count <= NumOfDimensions && IsLifted) - throw new ArgumentException("The input data is degenerate. It appears to exist in " + NumOfDimensions + - " dimensions, but it is a " + (NumOfDimensions - 1) + " dimensional set (i.e. the point of collinear," - + " coplanar, or co-hyperplanar.)"); - return initialPoints; - } - - /// - /// Check if 2 faces are adjacent and if so, update their AdjacentFaces array. - /// - /// The l. - /// The r. - private void UpdateAdjacency(ConvexFaceInternal l, ConvexFaceInternal r) - { - var lv = l.Vertices; - var rv = r.Vertices; - int i; - - // reset marks on the 1st face - for (i = 0; i < lv.Length; i++) VertexVisited[lv[i]] = false; - - // mark all vertices on the 2nd face - for (i = 0; i < rv.Length; i++) VertexVisited[rv[i]] = true; - - // find the 1st false index - for (i = 0; i < lv.Length; i++) if (!VertexVisited[lv[i]]) break; - - // no vertex was marked - if (i == NumOfDimensions) return; - - // check if only 1 vertex wasn't marked - for (var j = i + 1; j < lv.Length; j++) if (!VertexVisited[lv[j]]) return; - - // if we are here, the two faces share an edge - l.AdjacentFaces[i] = r.Index; - - // update the adj. face on the other face - find the vertex that remains marked - for (i = 0; i < lv.Length; i++) VertexVisited[lv[i]] = false; - for (i = 0; i < rv.Length; i++) - { - if (VertexVisited[rv[i]]) break; - } - r.AdjacentFaces[i] = l.Index; - } - - /// - /// Used in the "initialization" code. - /// - /// The face. - private void FindBeyondVertices(ConvexFaceInternal face) - { - var beyondVertices = face.VerticesBeyond; - MaxDistance = float.NegativeInfinity; - FurthestVertex = 0; - for (var i = 0; i < NumberOfVertices; i++) - { - if (VertexVisited[i]) continue; - IsBeyond(face, beyondVertices, i); - } - - face.FurthestVertex = FurthestVertex; - } - #region Fields - - /// - /// Corresponds to the dimension of the data. - /// When the "lifted" hull is computed, Dimension is automatically incremented by one. - /// - internal readonly int NumOfDimensions; - - /// - /// Are we on a paraboloid? - /// - private readonly bool IsLifted; - - /// - /// Explained in ConvexHullComputationConfig. - /// - private readonly float PlaneDistanceTolerance; - - /* - * Representation of the input vertices. - * - * - In the algorithm, a vertex is represented by its index in the Vertices array. - * This makes the algorithm a lot faster (up to 30%) than using object reference everywhere. - * - Positions are stored as a single array of values. Coordinates for vertex with index i - * are stored at indices - /// The vertices - /// - private readonly IVertex[] Vertices; - /// - /// The positions - /// - private float[] Positions; - /// - /// The vertex marks - /// - private readonly bool[] VertexVisited; - - private readonly int NumberOfVertices; - - /* - * The triangulation faces are represented in a single pool for objects that are being reused. - * This allows for represent the faces as integers and significantly speeds up many computations. - * - AffectedFaceFlags are used to mark affected faces/ - */ - /// - /// The face pool - /// - internal ConvexFaceInternal[] FacePool; - /// - /// The affected face flags - /// - internal bool[] AffectedFaceFlags; - - /// - /// Used to track the size of the current hull in the Update/RollbackCenter functions. - /// - private int ConvexHullSize; - - /// - /// A list of faces that that are not a part of the final convex hull and still need to be processed. - /// - private readonly FaceList UnprocessedFaces; - - /// - /// A list of faces that form the convex hull. - /// - private readonly IndexBuffer ConvexFaces; - - /// - /// The vertex that is currently being processed. - /// - private int CurrentVertex; - - /// - /// A helper variable to determine the furthest vertex for a particular convex face. - /// - private double MaxDistance; - - /// - /// A helper variable to help determine the index of the vertex that is furthest from the face that is currently being - /// processed. - /// - private int FurthestVertex; - - /// - /// The centroid of the currently computed hull. - /// - private readonly float[] Center; - - /* - * Helper arrays to store faces for adjacency update. - * This is just to prevent unnecessary allocations. - */ - /// - /// The update buffer - /// - private readonly int[] UpdateBuffer; - /// - /// The update indices - /// - private readonly int[] UpdateIndices; - - /// - /// Used to determine which faces need to be updated at each step of the algorithm. - /// - private readonly IndexBuffer TraverseStack; - - /// - /// Used for VerticesBeyond for faces that are on the convex hull. - /// - private readonly IndexBuffer EmptyBuffer; - - /// - /// Used to determine which vertices are "above" (or "beyond") a face - /// - private IndexBuffer BeyondBuffer; - - /// - /// Stores faces that are visible from the current vertex. - /// - private readonly IndexBuffer AffectedFaceBuffer; - - /// - /// Stores faces that form a "cone" created by adding new vertex. - /// - private readonly SimpleList ConeFaceBuffer; - - /// - /// Stores a list of "singular" (or "generate", "planar", etc.) vertices that cannot be part of the hull. - /// - private readonly HashSet SingularVertices; - - /// - /// The connector table helps to determine the adjacency of convex faces. - /// Hashing is used instead of pairwise comparison. This significantly speeds up the computations, - /// especially for higher dimensions. - /// - private readonly ConnectorList[] ConnectorTable; - - /// - /// Manages the memory allocations and storage of unused objects. - /// Saves the garbage collector a lot of work. - /// - private readonly ObjectManager ObjectManager; - - /// - /// Helper class for handling math related stuff. - /// - private readonly MathHelper mathHelper; - private readonly List[] boundingBoxPoints; - private int indexOfDimensionWithLeastExtremes; - private readonly float[] minima; - private readonly float[] maxima; - #endregion - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.Initialize.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.Initialize.cs.meta deleted file mode 100644 index 35319f3c2..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.Initialize.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 48bd00cb6ec8d244f90e1c1608f39074 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.MainLoop.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.MainLoop.cs deleted file mode 100644 index 058b82640..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.MainLoop.cs +++ /dev/null @@ -1,635 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -using System.Collections.Generic; - -namespace MIConvexHull -{ - /* - * Main part of the algorithm - * Basic idea: - * - Create the initial hull (done in Initialize.cs) - * - * For each face there are "vertices beyond" which are "visible" from it. - * If there are no such vertices, the face is on the hull. - * - * - While there is at least one face with at least one "vertex beyond": - * * Pick the furthest beyond vertex - * * For this vertex: - * > find all faces that are visible from it (TagAffectedFaces) - * > remove them and replace them with a "cone" created by the vertex and the boundary - * of the affected faces, and for each new face, compute "beyond vertices" - * (CreateCone + CommitCone) - * - * + Implement it in way that is fast, but hard to understand and maintain. - */ - - /// - /// Class ConvexHullAlgorithm. - /// - internal partial class ConvexHullAlgorithm - { - /// - /// Tags all faces seen from the current vertex with 1. - /// - /// The current face. - private void TagAffectedFaces(ConvexFaceInternal currentFace) - { - AffectedFaceBuffer.Clear(); - AffectedFaceBuffer.Add(currentFace.Index); - TraverseAffectedFaces(currentFace.Index); - } - - /// - /// Recursively traverse all the relevant faces. - /// - /// The current face. - private void TraverseAffectedFaces(int currentFace) - { - TraverseStack.Clear(); - TraverseStack.Push(currentFace); - AffectedFaceFlags[currentFace] = true; - - while (TraverseStack.Count > 0) - { - var top = FacePool[TraverseStack.Pop()]; - for (var i = 0; i < NumOfDimensions; i++) - { - var adjFace = top.AdjacentFaces[i]; - - if (!AffectedFaceFlags[adjFace] && - mathHelper.GetVertexDistance(CurrentVertex, FacePool[adjFace]) >= PlaneDistanceTolerance) - { - AffectedFaceBuffer.Add(adjFace); - AffectedFaceFlags[adjFace] = true; - TraverseStack.Push(adjFace); - } - } - } - } - - /// - /// Creates a new deferred face. - /// - /// The face. - /// Index of the face. - /// The pivot. - /// Index of the pivot. - /// The old face. - /// DeferredFace. - private DeferredFace MakeDeferredFace(ConvexFaceInternal face, int faceIndex, ConvexFaceInternal pivot, - int pivotIndex, ConvexFaceInternal oldFace) - { - var ret = ObjectManager.GetDeferredFace(); - - ret.Face = face; - ret.FaceIndex = faceIndex; - ret.Pivot = pivot; - ret.PivotIndex = pivotIndex; - ret.OldFace = oldFace; - - return ret; - } - - /// - /// Connect faces using a connector. - /// - /// The connector. - private void ConnectFace(FaceConnector connector) - { - var index = connector.HashCode % Constants.ConnectorTableSize; - var list = ConnectorTable[index]; - - for (var current = list.First; current != null; current = current.Next) - { - if (FaceConnector.AreConnectable(connector, current, NumOfDimensions)) - { - list.Remove(current); - FaceConnector.Connect(current, connector); - current.Face = null; - connector.Face = null; - ObjectManager.DepositConnector(current); - ObjectManager.DepositConnector(connector); - return; - } - } - - list.Add(connector); - } - - /// - /// Removes the faces "covered" by the current vertex and adds the newly created ones. - /// - /// true if XXXX, false otherwise. - private bool CreateCone() - { - var currentVertexIndex = CurrentVertex; - ConeFaceBuffer.Clear(); - - for (var fIndex = 0; fIndex < AffectedFaceBuffer.Count; fIndex++) - { - var oldFaceIndex = AffectedFaceBuffer[fIndex]; - var oldFace = FacePool[oldFaceIndex]; - - // Find the faces that need to be updated - var updateCount = 0; - for (var i = 0; i < NumOfDimensions; i++) - { - var af = oldFace.AdjacentFaces[i]; - if (!AffectedFaceFlags[af]) // Tag == false when oldFaces does not contain af - { - UpdateBuffer[updateCount] = af; - UpdateIndices[updateCount] = i; - ++updateCount; - } - } - - for (var i = 0; i < updateCount; i++) - { - var adjacentFace = FacePool[UpdateBuffer[i]]; - - var oldFaceAdjacentIndex = 0; - var adjFaceAdjacency = adjacentFace.AdjacentFaces; - for (var j = 0; j < adjFaceAdjacency.Length; j++) - { - if (oldFaceIndex == adjFaceAdjacency[j]) - { - oldFaceAdjacentIndex = j; - break; - } - } - - var forbidden = UpdateIndices[i]; // Index of the face that corresponds to this adjacent face - - int oldVertexIndex; - int[] vertices; - - var newFaceIndex = ObjectManager.GetFace(); - var newFace = FacePool[newFaceIndex]; - vertices = newFace.Vertices; - for (var j = 0; j < NumOfDimensions; j++) vertices[j] = oldFace.Vertices[j]; - oldVertexIndex = vertices[forbidden]; - - int orderedPivotIndex; - - // correct the ordering - if (currentVertexIndex < oldVertexIndex) - { - orderedPivotIndex = 0; - for (var j = forbidden - 1; j >= 0; j--) - { - if (vertices[j] > currentVertexIndex) vertices[j + 1] = vertices[j]; - else - { - orderedPivotIndex = j + 1; - break; - } - } - } - else - { - orderedPivotIndex = NumOfDimensions - 1; - for (var j = forbidden + 1; j < NumOfDimensions; j++) - { - if (vertices[j] < currentVertexIndex) vertices[j - 1] = vertices[j]; - else - { - orderedPivotIndex = j - 1; - break; - } - } - } - - vertices[orderedPivotIndex] = CurrentVertex; - - if (!mathHelper.CalculateFacePlane(newFace, Center)) - { - return false; - } - - ConeFaceBuffer.Add(MakeDeferredFace(newFace, orderedPivotIndex, adjacentFace, oldFaceAdjacentIndex, - oldFace)); - } - } - - return true; - } - - /// - /// Commits a cone and adds a vertex to the convex hull. - /// - private void CommitCone() - { - // Fill the adjacency. - for (var i = 0; i < ConeFaceBuffer.Count; i++) - { - var face = ConeFaceBuffer[i]; - - var newFace = face.Face; - var adjacentFace = face.Pivot; - var oldFace = face.OldFace; - var orderedPivotIndex = face.FaceIndex; - - newFace.AdjacentFaces[orderedPivotIndex] = adjacentFace.Index; - adjacentFace.AdjacentFaces[face.PivotIndex] = newFace.Index; - - // let there be a connection. - for (var j = 0; j < NumOfDimensions; j++) - { - if (j == orderedPivotIndex) continue; - var connector = ObjectManager.GetConnector(); - connector.Update(newFace, j, NumOfDimensions); - ConnectFace(connector); - } - - // the id adjacent face on the hull? If so, we can use simple method to find beyond vertices. - if (adjacentFace.VerticesBeyond.Count == 0) - FindBeyondVertices(newFace, oldFace.VerticesBeyond); - // it is slightly more effective if the face with the lower number of beyond vertices comes first. - else if (adjacentFace.VerticesBeyond.Count < oldFace.VerticesBeyond.Count) - FindBeyondVertices(newFace, adjacentFace.VerticesBeyond, oldFace.VerticesBeyond); - else - FindBeyondVertices(newFace, oldFace.VerticesBeyond, adjacentFace.VerticesBeyond); - - // This face will definitely lie on the hull - if (newFace.VerticesBeyond.Count == 0) - { - ConvexFaces.Add(newFace.Index); - UnprocessedFaces.Remove(newFace); - ObjectManager.DepositVertexBuffer(newFace.VerticesBeyond); - newFace.VerticesBeyond = EmptyBuffer; - } - else // Add the face to the list - { - UnprocessedFaces.Add(newFace); - } - - // recycle the object. - ObjectManager.DepositDeferredFace(face); - } - - // Recycle the affected faces. - for (var fIndex = 0; fIndex < AffectedFaceBuffer.Count; fIndex++) - { - var face = AffectedFaceBuffer[fIndex]; - UnprocessedFaces.Remove(FacePool[face]); - ObjectManager.DepositFace(face); - } - } - - /// - /// Check whether the vertex v is beyond the given face. If so, add it to beyondVertices. - /// - /// The face. - /// The beyond vertices. - /// The v. - private void IsBeyond(ConvexFaceInternal face, IndexBuffer beyondVertices, int v) - { - var distance = mathHelper.GetVertexDistance(v, face); - if (distance >= PlaneDistanceTolerance) - { - if (distance > MaxDistance) - { - // If it's within the tolerance distance, use the lex. larger point - if (distance - MaxDistance < PlaneDistanceTolerance) - { // todo: why is this LexCompare necessary. Would seem to favor x over y over z (etc.)? - if (LexCompare(v, FurthestVertex) > 0) - { - MaxDistance = distance; - FurthestVertex = v; - } - } - else - { - MaxDistance = distance; - FurthestVertex = v; - } - } - beyondVertices.Add(v); - } - } - - - /// - /// Compares the values of two vertices. The return value (-1, 0 or +1) are found - /// by first checking the first coordinate and then progressing through the rest. - /// In this way {2, 8} will be a "-1" (less than) {3, 1}. - /// - /// The base vertex index, u. - /// The compared vertex index, v. - /// System.Int32. - private int LexCompare(int u, int v) - { - int uOffset = u * NumOfDimensions, vOffset = v * NumOfDimensions; - for (var i = 0; i < NumOfDimensions; i++) - { - double x = Positions[uOffset + i], y = Positions[vOffset + i]; - var comp = x.CompareTo(y); - if (comp != 0) return comp; - } - return 0; - } - - - /// - /// Used by update faces. - /// - /// The face. - /// The beyond. - /// The beyond1. - private void FindBeyondVertices(ConvexFaceInternal face, IndexBuffer beyond, IndexBuffer beyond1) - { - var beyondVertices = BeyondBuffer; - - MaxDistance = double.NegativeInfinity; - FurthestVertex = 0; - int v; - - for (var i = 0; i < beyond1.Count; i++) VertexVisited[beyond1[i]] = true; - VertexVisited[CurrentVertex] = false; - for (var i = 0; i < beyond.Count; i++) - { - v = beyond[i]; - if (v == CurrentVertex) continue; - VertexVisited[v] = false; - IsBeyond(face, beyondVertices, v); - } - - for (var i = 0; i < beyond1.Count; i++) - { - v = beyond1[i]; - if (VertexVisited[v]) IsBeyond(face, beyondVertices, v); - } - - face.FurthestVertex = FurthestVertex; - - // Pull the old switch a roo (switch the face beyond buffers) - var temp = face.VerticesBeyond; - face.VerticesBeyond = beyondVertices; - if (temp.Count > 0) temp.Clear(); - BeyondBuffer = temp; - } - - /// - /// Finds the beyond vertices. - /// - /// The face. - /// The beyond. - private void FindBeyondVertices(ConvexFaceInternal face, IndexBuffer beyond) - { - var beyondVertices = BeyondBuffer; - - MaxDistance = double.NegativeInfinity; - FurthestVertex = 0; - int v; - - for (var i = 0; i < beyond.Count; i++) - { - v = beyond[i]; - if (v == CurrentVertex) continue; - IsBeyond(face, beyondVertices, v); - } - - face.FurthestVertex = FurthestVertex; - - // Pull the old switch a roo (switch the face beyond buffers) - var temp = face.VerticesBeyond; - face.VerticesBeyond = beyondVertices; - if (temp.Count > 0) temp.Clear(); - BeyondBuffer = temp; - } - - /// - /// Recalculates the centroid of the current hull. - /// - private void UpdateCenter() - { - for (var i = 0; i < NumOfDimensions; i++) Center[i] *= ConvexHullSize; - ConvexHullSize += 1; - var f = 1.0f / ConvexHullSize; - var co = CurrentVertex * NumOfDimensions; - for (var i = 0; i < NumOfDimensions; i++) Center[i] = f * (Center[i] + Positions[co + i]); - } - - /// - /// Removes the last vertex from the center. - /// - private void RollbackCenter() - { - for (var i = 0; i < NumOfDimensions; i++) Center[i] *= ConvexHullSize; - ConvexHullSize -= 1; - var f = ConvexHullSize > 0 ? 1.0f / ConvexHullSize : 0.0f; - var co = CurrentVertex * NumOfDimensions; - for (var i = 0; i < NumOfDimensions; i++) Center[i] = f * (Center[i] - Positions[co + i]); - } - - /// - /// Handles singular vertex. - /// - private void HandleSingular() - { - RollbackCenter(); - SingularVertices.Add(CurrentVertex); - - // This means that all the affected faces must be on the hull and that all their "vertices beyond" are singular. - for (var fIndex = 0; fIndex < AffectedFaceBuffer.Count; fIndex++) - { - var face = FacePool[AffectedFaceBuffer[fIndex]]; - var vb = face.VerticesBeyond; - for (var i = 0; i < vb.Count; i++) - { - SingularVertices.Add(vb[i]); - } - - ConvexFaces.Add(face.Index); - UnprocessedFaces.Remove(face); - ObjectManager.DepositVertexBuffer(face.VerticesBeyond); - face.VerticesBeyond = EmptyBuffer; - } - } - - /// - /// Get a vertex coordinate. In order to reduce speed, all vertex coordinates - /// have been placed in a single array. - /// - /// The vertex index. - /// The index of the dimension. - /// System.Double. - private float GetCoordinate(int vIndex, int dimension) - { - return Positions[vIndex * NumOfDimensions + dimension]; - } - - #region Returning the Results in the proper format - - /// - /// Gets the hull vertices. - /// - /// The type of the t vertex. - /// The data. - /// TVertex[]. - private TVertex[] GetHullVertices(IList data) - { - var cellCount = ConvexFaces.Count; - var hullVertexCount = 0; - - for (var i = 0; i < NumberOfVertices; i++) VertexVisited[i] = false; - - for (var i = 0; i < cellCount; i++) - { - var vs = FacePool[ConvexFaces[i]].Vertices; - for (var j = 0; j < vs.Length; j++) - { - var v = vs[j]; - if (!VertexVisited[v]) - { - VertexVisited[v] = true; - hullVertexCount++; - } - } - } - - var result = new TVertex[hullVertexCount]; - for (var i = 0; i < NumberOfVertices; i++) - { - if (VertexVisited[i]) result[--hullVertexCount] = data[i]; - } - - return result; - } - - /// - /// Finds the convex hull and creates the TFace objects. - /// - /// The type of the t face. - /// The type of the t vertex. - /// TFace[]. - private TFace[] GetConvexFaces() - where TFace : ConvexFace, new() - where TVertex : IVertex - { - var faces = ConvexFaces; - var cellCount = faces.Count; - var cells = new TFace[cellCount]; - - for (var i = 0; i < cellCount; i++) - { - var face = FacePool[faces[i]]; - var vertices = new TVertex[NumOfDimensions]; - for (var j = 0; j < NumOfDimensions; j++) - { - vertices[j] = (TVertex)Vertices[face.Vertices[j]]; - } - - cells[i] = new TFace - { - Vertices = vertices, - Adjacency = new TFace[NumOfDimensions], - Normal = IsLifted ? null : face.Normal - }; - face.Tag = i; - } - - for (var i = 0; i < cellCount; i++) - { - var face = FacePool[faces[i]]; - var cell = cells[i]; - for (var j = 0; j < NumOfDimensions; j++) - { - if (face.AdjacentFaces[j] < 0) continue; - cell.Adjacency[j] = cells[FacePool[face.AdjacentFaces[j]].Tag]; - } - - // Fix the vertex orientation. - if (face.IsNormalFlipped) - { - var tempVert = cell.Vertices[0]; - cell.Vertices[0] = cell.Vertices[NumOfDimensions - 1]; - cell.Vertices[NumOfDimensions - 1] = tempVert; - - var tempAdj = cell.Adjacency[0]; - cell.Adjacency[0] = cell.Adjacency[NumOfDimensions - 1]; - cell.Adjacency[NumOfDimensions - 1] = tempAdj; - } - } - - return cells; - } - - /// - /// For 2D only: Returns the result in counter-clockwise order starting with the element with the lowest X value. - /// If there are multiple vertices with the same minimum X, then the one with the lowest Y is chosen. - /// - /// The type of the vertex. - /// The type of the face. - /// The data. - /// - private ConvexHull Return2DResultInOrder(IList data) - where TVertex : IVertex - where TFace : ConvexFace, new() - { - TFace[] faces = GetConvexFaces(); - var numPoints = faces.Length; - var orderDictionary = new Dictionary(); - foreach (var face in faces) - orderDictionary.Add(face.Vertices[1], face); - var firstPoint = faces[0].Vertices[1]; - var nextPoint = faces[0].Vertices[0]; - var orderedPointList = new List(); - orderedPointList.Add(firstPoint); - var orderedFaceList = new List(); - orderedFaceList.Add(faces[1]); - var lowestXMinIndex = 0; - var k = 0; - while (!nextPoint.Equals(firstPoint)) - { - orderedPointList.Add(nextPoint); - var nextFace = orderDictionary[nextPoint]; - orderedFaceList.Add(nextFace); - if (nextPoint.Position[0] < orderedPointList[lowestXMinIndex].Position[0] - || (nextPoint.Position[0] == orderedPointList[lowestXMinIndex].Position[0] - && nextPoint.Position[1] <= orderedPointList[lowestXMinIndex].Position[1])) - lowestXMinIndex = k; - k++; - nextPoint = nextFace.Vertices[0]; - } - TVertex[] points = new TVertex[numPoints]; - for (int i = 0; i < numPoints; i++) - { - var j = (i + lowestXMinIndex) % numPoints; - points[i] = orderedPointList[j]; - faces[i] = orderedFaceList[j]; - } - return new ConvexHull - { - Points = points, - Faces = faces - }; - } - - #endregion - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.MainLoop.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.MainLoop.cs.meta deleted file mode 100644 index 8eb122b5b..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ConvexHullAlgorithm.MainLoop.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: f7f265217d00ea340b87645242a45181 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/DataModel.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/DataModel.cs deleted file mode 100644 index 761dd1ccf..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/DataModel.cs +++ /dev/null @@ -1,237 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -namespace MIConvexHull -{ - /// - /// For deferred face addition. - /// - internal sealed class DeferredFace - { - /// - /// The faces. - /// - public ConvexFaceInternal Face, Pivot, OldFace; - - /// - /// The indices. - /// - public int FaceIndex, PivotIndex; - } - - /// - /// A helper class used to connect faces. - /// - internal sealed class FaceConnector - { - /// - /// The edge to be connected. - /// - public int EdgeIndex; - - /// - /// The face. - /// - public ConvexFaceInternal Face; - - /// - /// The hash code computed from indices. - /// - public uint HashCode; - - /// - /// Next node in the list. - /// - public FaceConnector Next; - - /// - /// Prev node in the list. - /// - public FaceConnector Previous; - - /// - /// The vertex indices. - /// - public int[] Vertices; - - /// - /// Ctor. - /// - /// The dimension. - public FaceConnector(int dimension) - { - Vertices = new int[dimension - 1]; - } - - /// - /// Updates the connector. - /// - /// The face. - /// Index of the edge. - /// The dim. - public void Update(ConvexFaceInternal face, int edgeIndex, int dim) - { - Face = face; - EdgeIndex = edgeIndex; - - uint hashCode = 23; - - unchecked - { - var vs = face.Vertices; - int i, c = 0; - for (i = 0; i < edgeIndex; i++) - { - Vertices[c++] = vs[i]; - hashCode += 31*hashCode + (uint) vs[i]; - } - for (i = edgeIndex + 1; i < vs.Length; i++) - { - Vertices[c++] = vs[i]; - hashCode += 31*hashCode + (uint) vs[i]; - } - } - - HashCode = hashCode; - } - - /// - /// Can two faces be connected. - /// - /// a. - /// The b. - /// The dim. - /// true if XXXX, false otherwise. - public static bool AreConnectable(FaceConnector a, FaceConnector b, int dim) - { - if (a.HashCode != b.HashCode) return false; - - var av = a.Vertices; - var bv = b.Vertices; - for (var i = 0; i < av.Length; i++) - { - if (av[i] != bv[i]) return false; - } - - return true; - } - - /// - /// Connect two faces. - /// - /// a. - /// The b. - public static void Connect(FaceConnector a, FaceConnector b) - { - a.Face.AdjacentFaces[a.EdgeIndex] = b.Face.Index; - b.Face.AdjacentFaces[b.EdgeIndex] = a.Face.Index; - } - } - - /// - /// This internal class manages the faces of the convex hull. It is a - /// separate class from the desired user class. - /// - internal sealed class ConvexFaceInternal - { - /// - /// Gets or sets the adjacent face data. - /// - public int[] AdjacentFaces; - - /// - /// The furthest vertex. - /// - public int FurthestVertex; - - /// - /// Index of the face inside the pool. - /// - public int Index; - - /// - /// Is it present in the list. - /// - public bool InList; - - /// - /// Is the normal flipped? - /// - public bool IsNormalFlipped; - - /// - /// Next node in the list. - /// - public ConvexFaceInternal Next; - - /// - /// Gets or sets the normal vector. - /// - public float[] Normal; - - /// - /// Face plane constant element. - /// - public float Offset; - - //public int UnprocessedIndex; - - /// - /// Prev node in the list. - /// - public ConvexFaceInternal Previous; - - /// - /// Used to traverse affected faces and create the Delaunay representation. - /// - public int Tag; - - /// - /// Gets or sets the vertices. - /// - public int[] Vertices; - - /// - /// Gets or sets the vertices beyond. - /// - public IndexBuffer VerticesBeyond; - - /// - /// Initializes a new instance of the class. - /// - /// The dimension. - /// The index. - /// The beyond list. - public ConvexFaceInternal(int dimension, int index, IndexBuffer beyondList) - { - Index = index; - AdjacentFaces = new int[dimension]; - VerticesBeyond = beyondList; - Normal = new float[dimension]; - Vertices = new int[dimension]; - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/DataModel.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/DataModel.cs.meta deleted file mode 100644 index 411bcb0d5..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/DataModel.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 58c58e163f4c86e48a6df3ee6c5056c0 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/MathHelper.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/MathHelper.cs deleted file mode 100644 index 0f030c59d..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/MathHelper.cs +++ /dev/null @@ -1,480 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -using System; -using System.Collections.Generic; -using CultMath; -using static CultMath.math; -using Random = CultMath.Random; - -namespace MIConvexHull -{ - /// - /// A helper class mostly for normal computation. If convex hulls are computed - /// in higher dimensions, it might be a good idea to add a specific - /// FindNormalVectorND function. - /// - internal class MathHelper - { - /// - /// The dimension - /// - private readonly int Dimension; - /// - /// The matrix pivots - /// - private readonly int[] matrixPivots; - /// - /// The n d matrix - /// - private readonly float[] nDMatrix; - /// - /// The n d normal helper vector - /// - private readonly float[] nDNormalHelperVector; - - /// - /// The nt x - /// - private readonly float[] ntX; - /// - /// The nt y - /// - private readonly float[] ntY; - /// - /// The nt z - /// - private readonly float[] ntZ; - - /// - /// The position data - /// - private readonly float[] PositionData; - - private static Random _random = new Random((uint) (DateTime.Now.Ticks % uint.MaxValue)); - - /// - /// Initializes a new instance of the class. - /// - /// The dimension. - /// The positions. - internal MathHelper(int dimension, float[] positions) - { - PositionData = positions; - Dimension = dimension; - - ntX = new float[Dimension]; - ntY = new float[Dimension]; - ntZ = new float[Dimension]; - - nDNormalHelperVector = new float[Dimension]; - nDMatrix = new float[Dimension * Dimension]; - matrixPivots = new int[Dimension]; - } - - /// - /// Calculates the normal and offset of the hyper-plane given by the face's vertices. - /// - /// The face. - /// The center. - /// true if XXXX, false otherwise. - internal bool CalculateFacePlane(ConvexFaceInternal face, float[] center) - { - var vertices = face.Vertices; - var normal = face.Normal; - FindNormalVector(vertices, normal); - - if (float.IsNaN(normal[0])) - { - return false; - } - - var offset = 0.0f; - var centerDistance = 0.0f; - var fi = vertices[0] * Dimension; - for (var i = 0; i < Dimension; i++) - { - var n = normal[i]; - offset += n * PositionData[fi + i]; - centerDistance += n * center[i]; - } - face.Offset = -offset; - centerDistance -= offset; - - if (centerDistance > 0) - { - for (var i = 0; i < Dimension; i++) normal[i] = -normal[i]; - face.Offset = offset; - face.IsNormalFlipped = true; - } - else face.IsNormalFlipped = false; - - return true; - } - - /// - /// Check if the vertex is "visible" from the face. - /// The vertex is "over face" if the return value is > Constants.PlaneDistanceTolerance. - /// - /// The v. - /// The f. - /// The vertex is "over face" if the result is positive. - internal double GetVertexDistance(int v, ConvexFaceInternal f) - { - var normal = f.Normal; - var x = v * Dimension; - var distance = f.Offset; - for (var i = 0; i < normal.Length; i++) distance += normal[i] * PositionData[x + i]; - return distance; - } - - /// - /// Returns the vector the between vertices. - /// - /// To index. - /// From index. - /// System.Double[]. - internal float[] VectorBetweenVertices(int toIndex, int fromIndex) - { - var target = new float[Dimension]; - VectorBetweenVertices(toIndex, fromIndex, target); - return target; - } - /// - /// Returns the vector the between vertices. - /// - /// From index. - /// To index. - /// The target. - /// - private void VectorBetweenVertices(int toIndex, int fromIndex, float[] target) - { - int u = toIndex * Dimension, v = fromIndex * Dimension; - for (var i = 0; i < Dimension; i++) - { - target[i] = PositionData[u + i] - PositionData[v + i]; - } - } - - internal void RandomOffsetToLift(int index, float maxHeight) - { - var liftIndex = (index * Dimension) + Dimension - 1; - PositionData[liftIndex] += 0.0001f * maxHeight * (_random.NextFloat() - 0.5f); - } - #region Find the normal vector of the face - /// - /// Finds normal vector of a hyper-plane given by vertices. - /// Stores the results to normalData. - /// - /// The vertices. - /// The normal data. - private void FindNormalVector(int[] vertices, float[] normalData) - { - switch (Dimension) - { - case 2: - FindNormalVector2D(vertices, normalData); - break; - case 3: - FindNormalVector3D(vertices, normalData); - break; - case 4: - FindNormalVector4D(vertices, normalData); - break; - default: - FindNormalVectorND(vertices, normalData); - break; - } - } - /// - /// Finds 2D normal vector. - /// - /// The vertices. - /// The normal. - private void FindNormalVector2D(int[] vertices, float[] normal) - { - VectorBetweenVertices(vertices[1], vertices[0], ntX); - - var nx = -ntX[1]; - var ny = ntX[0]; - - var norm = sqrt(nx * nx + ny * ny); - - var f = 1.0f / norm; - normal[0] = f * nx; - normal[1] = f * ny; - } - /// - /// Finds 3D normal vector. - /// - /// The vertices. - /// The normal. - private void FindNormalVector3D(int[] vertices, float[] normal) - { - VectorBetweenVertices(vertices[1], vertices[0], ntX); - VectorBetweenVertices(vertices[2], vertices[1], ntY); - - var nx = ntX[1] * ntY[2] - ntX[2] * ntY[1]; - var ny = ntX[2] * ntY[0] - ntX[0] * ntY[2]; - var nz = ntX[0] * ntY[1] - ntX[1] * ntY[0]; - - var norm = sqrt(nx * nx + ny * ny + nz * nz); - - var f = 1.0f / norm; - normal[0] = f * nx; - normal[1] = f * ny; - normal[2] = f * nz; - } - /// - /// Finds 4D normal vector. - /// - /// The vertices. - /// The normal. - private void FindNormalVector4D(int[] vertices, float[] normal) - { - VectorBetweenVertices(vertices[1], vertices[0], ntX); - VectorBetweenVertices(vertices[2], vertices[1], ntY); - VectorBetweenVertices(vertices[3], vertices[2], ntZ); - - var x = ntX; - var y = ntY; - var z = ntZ; - - // This was generated using Mathematica - var nx = x[3] * (y[2] * z[1] - y[1] * z[2]) - + x[2] * (y[1] * z[3] - y[3] * z[1]) - + x[1] * (y[3] * z[2] - y[2] * z[3]); - var ny = x[3] * (y[0] * z[2] - y[2] * z[0]) - + x[2] * (y[3] * z[0] - y[0] * z[3]) - + x[0] * (y[2] * z[3] - y[3] * z[2]); - var nz = x[3] * (y[1] * z[0] - y[0] * z[1]) - + x[1] * (y[0] * z[3] - y[3] * z[0]) - + x[0] * (y[3] * z[1] - y[1] * z[3]); - var nw = x[2] * (y[0] * z[1] - y[1] * z[0]) - + x[1] * (y[2] * z[0] - y[0] * z[2]) - + x[0] * (y[1] * z[2] - y[2] * z[1]); - - var norm = sqrt(nx * nx + ny * ny + nz * nz + nw * nw); - - var f = 1.0f / norm; - normal[0] = f * nx; - normal[1] = f * ny; - normal[2] = f * nz; - normal[3] = f * nw; - } - - /// - /// Finds the normal vector nd. - /// - /// The vertices. - /// The normal. - private void FindNormalVectorND(int[] vertices, float[] normal) - { - /* We need to solve the matrix A n = B where - * - A contains coordinates of vertices as columns - * - B is vector with all 1's. Really, it should be the distance of - * the plane from the origin, but - since we're not worried about that - * here and we will normalize the normal anyway - all 1's suffices. - */ - var iPiv = matrixPivots; - var data = nDMatrix; - var norm = 0.0f; - - // Solve determinants by replacing x-th column by all 1. - for (var x = 0; x < Dimension; x++) - { - for (var i = 0; i < Dimension; i++) - { - var offset = vertices[i] * Dimension; - for (var j = 0; j < Dimension; j++) - { - // maybe I got the i/j mixed up here regarding the representation Math.net uses... - // ...but it does not matter since Det(A) = Det(Transpose(A)). - data[Dimension * i + j] = j == x ? 1.0f : PositionData[offset + j]; - } - } - LUFactor(data, Dimension, iPiv, nDNormalHelperVector); - var coord = 1.0f; - for (var i = 0; i < Dimension; i++) - { - if (iPiv[i] != i) coord *= -data[Dimension * i + i]; // the determinant sign changes on row swap. - else coord *= data[Dimension * i + i]; - } - normal[x] = coord; - norm += coord * coord; - } - - // Normalize the result - var f = 1.0f / sqrt(norm); - for (var i = 0; i < normal.Length; i++) normal[i] *= f; - } - #endregion - - #region Simplex Volume - /// - /// Gets the simplex volume. Prior to having enough edge vectors, the method pads the remaining with all - /// "other numbers". So, yes, this method is not really finding the volume. But a relative volume-like measure. It - /// uses the magnitude of the determinant as the volume stand-in following the Cayley-Menger theorem. - /// - /// The edge vectors. - /// The last index. - /// The big number. - /// System.Double. - internal float GetSimplexVolume(float[][] edgeVectors, int lastIndex, float bigNumber) - { - var A = new float[Dimension * Dimension]; - var index = 0; - for (int i = 0; i < Dimension; i++) - for (int j = 0; j < Dimension; j++) - if (i <= lastIndex) - A[index++] = edgeVectors[i][j]; - else A[index] = (pow(-1, index) * index++) / bigNumber; - // this last term is used for all the vertices in the comparison for the yet determined vertices - // the idea is to come up with sets of numbers that are orthogonal so that an non-zero value will result - // and to choose smallish numbers since the choice of vectors will affect what the end volume is. - // A better way (todo?) is to solve a smaller matrix. However, cases were found in which the obvious smaller vector - // (the upper left) had too many zeros. So, one would need to find the right subset. Indeed choosing a subset - // biases the first dimensions of the others. Perhaps a larger volume would be created from a different vertex - // if another subset of dimensions were used. - return abs(DeterminantDestructive(A)); - } - - /// - /// Determinants the destructive. - /// - /// a. - /// System.Double. - private float DeterminantDestructive(float[] A) - { - switch (Dimension) - { - case 0: - return 0.0f; - case 1: - return A[0]; - case 2: - return A[0] * A[3] - A[1] * A[2]; - case 3: - return A[0] * A[4] * A[8] + A[1] * A[5] * A[6] + A[2] * A[3] * A[7] - - A[0] * A[5] * A[7] - A[1] * A[3] * A[8] - A[2] * A[4] * A[6]; - default: - { - var iPiv = new int[Dimension]; - var helper = new float[Dimension]; - LUFactor(A, Dimension, iPiv, helper); - var det = 1.0f; - for (var i = 0; i < iPiv.Length; i++) - { - det *= A[Dimension * i + i]; - if (iPiv[i] != i) det *= -1; // the determinant sign changes on row swap. - } - return det; - } - } - } - #endregion - - - // Modified from Math.NET - // Copyright (c) 2009-2013 Math.NET - /// - /// Lus the factor. - /// - /// The data. - /// The order. - /// The ipiv. - /// The vec l ucolj. - private static void LUFactor(float[] data, int order, int[] ipiv, float[] vecLUcolj) - { - // Initialize the pivot matrix to the identity permutation. - for (var i = 0; i < order; i++) - { - ipiv[i] = i; - } - - // Outer loop. - for (var j = 0; j < order; j++) - { - var indexj = j * order; - var indexjj = indexj + j; - - // Make a copy of the j-th column to localize references. - for (var i = 0; i < order; i++) - { - vecLUcolj[i] = data[indexj + i]; - } - - // Apply previous transformations. - for (var i = 0; i < order; i++) - { - // Most of the time is spent in the following dot product. - var kmax = Math.Min(i, j); - var s = 0.0f; - for (var k = 0; k < kmax; k++) - { - s += data[k * order + i] * vecLUcolj[k]; - } - - data[indexj + i] = vecLUcolj[i] -= s; - } - - // Find pivot and exchange if necessary. - var p = j; - for (var i = j + 1; i < order; i++) - { - if (abs(vecLUcolj[i]) > abs(vecLUcolj[p])) - { - p = i; - } - } - - if (p != j) - { - for (var k = 0; k < order; k++) - { - var indexk = k * order; - var indexkp = indexk + p; - var indexkj = indexk + j; - var temp = data[indexkp]; - data[indexkp] = data[indexkj]; - data[indexkj] = temp; - } - - ipiv[j] = p; - } - - // Compute multipliers. - if (j < order & data[indexjj] != 0.0f) - { - for (var i = j + 1; i < order; i++) - { - data[indexj + i] /= data[indexjj]; - } - } - } - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/MathHelper.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/MathHelper.cs.meta deleted file mode 100644 index 920a8673d..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/MathHelper.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 8bb3327041efa18479319ec117493f8c -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ObjectManager.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ObjectManager.cs deleted file mode 100644 index 66333b74f..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ObjectManager.cs +++ /dev/null @@ -1,218 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -using System; - -namespace MIConvexHull -{ - /// - /// A helper class for object allocation/storage. - /// This helps the GC a lot as it prevents the creation of about 75% of - /// new face objects (in the case of ConvexFaceInternal). In the case of - /// FaceConnectors and DefferedFaces, the difference is even higher (in most - /// cases O(1) vs O(number of created faces)). - /// - internal class ObjectManager - { - /// - /// The dimension - /// - private readonly int Dimension; - /// - /// The connector stack - /// - private FaceConnector ConnectorStack; - /// - /// The deferred face stack - /// - private readonly SimpleList DeferredFaceStack; - /// - /// The empty buffer stack - /// - private readonly SimpleList EmptyBufferStack; - /// - /// The face pool - /// - private ConvexFaceInternal[] FacePool; - /// - /// The face pool size - /// - private int FacePoolSize; - /// - /// The face pool capacity - /// - private int FacePoolCapacity; - /// - /// The free face indices - /// - private readonly IndexBuffer FreeFaceIndices; - - /// - /// The hull - /// - private readonly ConvexHullAlgorithm Hull; - - /// - /// Create the manager. - /// - /// The hull. - public ObjectManager(ConvexHullAlgorithm hull) - { - Dimension = hull.NumOfDimensions; - Hull = hull; - FacePool = hull.FacePool; - FacePoolSize = 0; - FacePoolCapacity = hull.FacePool.Length; - FreeFaceIndices = new IndexBuffer(); - - EmptyBufferStack = new SimpleList(); - DeferredFaceStack = new SimpleList(); - } - - /// - /// Return the face to the pool for later use. - /// - /// Index of the face. - public void DepositFace(int faceIndex) - { - var face = FacePool[faceIndex]; - var af = face.AdjacentFaces; - for (var i = 0; i < af.Length; i++) - { - af[i] = -1; - } - FreeFaceIndices.Push(faceIndex); - } - - /// - /// Reallocate the face pool, including the AffectedFaceFlags - /// - private void ReallocateFacePool() - { - var newPool = new ConvexFaceInternal[2*FacePoolCapacity]; - var newTags = new bool[2*FacePoolCapacity]; - Array.Copy(FacePool, newPool, FacePoolCapacity); - Buffer.BlockCopy(Hull.AffectedFaceFlags, 0, newTags, 0, FacePoolCapacity*sizeof (bool)); - FacePoolCapacity = 2*FacePoolCapacity; - Hull.FacePool = newPool; - FacePool = newPool; - Hull.AffectedFaceFlags = newTags; - } - - /// - /// Create a new face and put it in the pool. - /// - /// System.Int32. - private int CreateFace() - { - var index = FacePoolSize; - var face = new ConvexFaceInternal(Dimension, index, GetVertexBuffer()); - FacePoolSize++; - if (FacePoolSize > FacePoolCapacity) ReallocateFacePool(); - FacePool[index] = face; - return index; - } - - /// - /// Return index of an unused face or creates a new one. - /// - /// System.Int32. - public int GetFace() - { - if (FreeFaceIndices.Count > 0) return FreeFaceIndices.Pop(); - return CreateFace(); - } - - /// - /// Store a face connector in the "embedded" linked list. - /// - /// The connector. - public void DepositConnector(FaceConnector connector) - { - if (ConnectorStack == null) - { - connector.Next = null; - ConnectorStack = connector; - } - else - { - connector.Next = ConnectorStack; - ConnectorStack = connector; - } - } - - /// - /// Get an unused face connector. If none is available, create it. - /// - /// FaceConnector. - public FaceConnector GetConnector() - { - if (ConnectorStack == null) return new FaceConnector(Dimension); - - var ret = ConnectorStack; - ConnectorStack = ConnectorStack.Next; - ret.Next = null; - return ret; - } - - /// - /// Deposit the index buffer. - /// - /// The buffer. - public void DepositVertexBuffer(IndexBuffer buffer) - { - buffer.Clear(); - EmptyBufferStack.Push(buffer); - } - - /// - /// Get a store index buffer or create a new instance. - /// - /// IndexBuffer. - public IndexBuffer GetVertexBuffer() - { - return EmptyBufferStack.Count != 0 ? EmptyBufferStack.Pop() : new IndexBuffer(); - } - - /// - /// Deposit the deferred face. - /// - /// The face. - public void DepositDeferredFace(DeferredFace face) - { - DeferredFaceStack.Push(face); - } - - /// - /// Get the deferred face. - /// - /// DeferredFace. - public DeferredFace GetDeferredFace() - { - return DeferredFaceStack.Count != 0 ? DeferredFaceStack.Pop() : new DeferredFace(); - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ObjectManager.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ObjectManager.cs.meta deleted file mode 100644 index cb1cf125b..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/ObjectManager.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: e8d582e97c6824740ba9e1a41641b372 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Face3.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Face3.cs deleted file mode 100644 index 980e442d0..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Face3.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Collections; -using MIConvexHull; - -public class Face3 : ConvexFace -{ - - -} diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Face3.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Face3.cs.meta deleted file mode 100644 index ab550e9ac..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Face3.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 30a2635ffe51545499499ce392b234c8 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/IVertex.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/IVertex.cs deleted file mode 100644 index b7217c074..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/IVertex.cs +++ /dev/null @@ -1,53 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -namespace MIConvexHull -{ - /// - /// An interface for a structure with nD position. - /// - public interface IVertex - { - /// - /// Position of the vertex. - /// - /// The position. - float[] Position { get; } - } - - /// - /// "Default" vertex. - /// - /// - public class DefaultVertex : IVertex - { - /// - /// Position of the vertex. - /// - /// The position. - public float[] Position { get; set; } - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/IVertex.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/IVertex.cs.meta deleted file mode 100644 index 06195b473..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/IVertex.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a55f713690988bb48aef7cbd4f01ebc7 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation.cs deleted file mode 100644 index 690071bec..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation.cs +++ /dev/null @@ -1,181 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -using System.Collections.Generic; -using System.Linq; - -namespace MIConvexHull -{ - /// - /// Simple interface to unify different types of triangulations in the future. - /// - /// The type of the t vertex. - /// The type of the t cell. - public interface ITriangulation - where TCell : TriangulationCell, new() - where TVertex : IVertex - { - /// - /// Triangulation simplexes. For 2D - triangles, 3D - tetrahedrons, etc ... - /// - /// The cells. - IEnumerable Cells { get; } - } - - /// - /// Factory class for creating triangulations. - /// - public static class Triangulation - { - /// - /// Creates the Delaunay triangulation of the input data. - /// - /// The type of the t vertex. - /// The data. - /// The plane distance tolerance (default is 1e-10). If too high, points - /// will be missed. If too low, the algorithm may break. Only adjust if you notice problems. - /// ITriangulation<TVertex, DefaultTriangulationCell<TVertex>>. - public static ITriangulation> CreateDelaunay( - IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - where TVertex : IVertex - { - return DelaunayTriangulation>.Create(data, PlaneDistanceTolerance); - } - - /// - /// Creates the Delaunay triangulation of the input data. - /// - /// The data. - /// The plane distance tolerance (default is 1e-10). If too high, points - /// will be missed. If too low, the algorithm may break. Only adjust if you notice problems. - /// ITriangulation<DefaultVertex, DefaultTriangulationCell<DefaultVertex>>. - public static ITriangulation> CreateDelaunay( - IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - { - var points = data.Select(p => new DefaultVertex { Position = p }).ToList(); - return DelaunayTriangulation>.Create(points, PlaneDistanceTolerance); - } - - /// - /// Creates the Delaunay triangulation of the input data. - /// - /// The type of the t vertex. - /// The type of the t face. - /// The data. - /// The plane distance tolerance (default is 1e-10). If too high, points - /// will be missed. If too low, the algorithm may break. Only adjust if you notice problems. - /// ITriangulation<TVertex, TFace>. - public static ITriangulation CreateDelaunay(IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - where TVertex : IVertex - where TFace : TriangulationCell, new() - { - return DelaunayTriangulation.Create(data, PlaneDistanceTolerance); - } - - - /// - /// Create the voronoi mesh. - /// - /// The type of the t vertex. - /// The type of the t cell. - /// The type of the t edge. - /// The data. - /// The plane distance tolerance (default is 1e-10). If too high, points - /// will be missed. If too low, the algorithm may break. Only adjust if you notice problems. - /// VoronoiMesh<TVertex, TCell, TEdge>. - public static VoronoiMesh CreateVoronoi(IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - where TCell : TriangulationCell, new() - where TVertex : IVertex - where TEdge : VoronoiEdge, new() - { - return VoronoiMesh.Create(data, PlaneDistanceTolerance); - } - - /// - /// Create the voronoi mesh. - /// - /// The type of the t vertex. - /// The data. - /// The plane distance tolerance (default is 1e-10). If too high, points - /// will be missed. If too low, the algorithm may break. Only adjust if you notice problems. - /// VoronoiMesh<TVertex, DefaultTriangulationCell<TVertex>, VoronoiEdge<TVertex, DefaultTriangulationCell<TVertex>>>. - public static - VoronoiMesh - , VoronoiEdge>> - CreateVoronoi(IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - where TVertex : IVertex - { - return - VoronoiMesh - , VoronoiEdge> - >.Create(data, PlaneDistanceTolerance); - } - - /// - /// Create the voronoi mesh. - /// - /// The data. - /// The plane distance tolerance (default is 1e-10). If too high, points - /// will be missed. If too low, the algorithm may break. Only adjust if you notice problems. - /// VoronoiMesh<DefaultVertex, DefaultTriangulationCell<DefaultVertex>, VoronoiEdge<DefaultVertex, DefaultTriangulationCell<DefaultVertex>>>. - public static - VoronoiMesh - , - VoronoiEdge>> - CreateVoronoi(IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - { - var points = data.Select(p => new DefaultVertex { Position = p.ToArray() }).ToList(); - return - VoronoiMesh - , - VoronoiEdge>>.Create(points, PlaneDistanceTolerance); - } - - /// - /// Create the voronoi mesh. - /// - /// The type of the t vertex. - /// The type of the t cell. - /// The data. - /// The plane distance tolerance (default is 1e-10). If too high, points - /// will be missed. If too low, the algorithm may break. Only adjust if you notice problems. - /// VoronoiMesh<TVertex, TCell, VoronoiEdge<TVertex, TCell>>. - public static VoronoiMesh> CreateVoronoi( - IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - where TVertex : IVertex - where TCell : TriangulationCell, new() - { - return VoronoiMesh>.Create(data, PlaneDistanceTolerance); - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation.cs.meta deleted file mode 100644 index 2f8f75a85..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 0762bfe861ed97749a222fb1c217788a -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation.meta deleted file mode 100644 index 346f5fafe..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 5bf99a94223c6e1458c243cb68091852 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTrianglationInternal.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTrianglationInternal.cs deleted file mode 100644 index 7209c0ccc..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTrianglationInternal.cs +++ /dev/null @@ -1,96 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -using System.Collections.Generic; -using System.Linq; - -namespace MIConvexHull -{ - /* - * Code here handles triangulation related stuff. - */ - - /// - /// Class ConvexHullAlgorithm. - /// - internal partial class ConvexHullAlgorithm - { - /// - /// Computes the Delaunay triangulation. - /// - /// The type of the t vertex. - /// The type of the t cell. - /// The data. - /// The plane distance tolerance. - /// TCell[]. - internal static TCell[] GetDelaunayTriangulation(IList data, - float PlaneDistanceTolerance ) - where TCell : TriangulationCell, new() - where TVertex : IVertex - { - var ch = new ConvexHullAlgorithm(data.Cast().ToArray(), true, PlaneDistanceTolerance); - ch.GetConvexHull(); - ch.RemoveUpperFaces(); - return ch.GetConvexFaces(); - } - - /// - /// Removes up facing Tetrahedrons from the triangulation. - /// - private void RemoveUpperFaces() - { - var delaunayFaces = ConvexFaces; - var dimension = NumOfDimensions - 1; - - // Remove the "upper" faces - for (var i = delaunayFaces.Count - 1; i >= 0; i--) - { - var candidateIndex = delaunayFaces[i]; - var candidate = FacePool[candidateIndex]; - if (candidate.Normal[dimension] >= 0.0) - { - for (var fi = 0; fi < candidate.AdjacentFaces.Length; fi++) - { - var af = candidate.AdjacentFaces[fi]; - if (af >= 0) - { - var face = FacePool[af]; - for (var j = 0; j < face.AdjacentFaces.Length; j++) - { - if (face.AdjacentFaces[j] == candidateIndex) - { - face.AdjacentFaces[j] = -1; - } - } - } - } - delaunayFaces[i] = delaunayFaces[delaunayFaces.Count - 1]; - delaunayFaces.Pop(); - } - } - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTrianglationInternal.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTrianglationInternal.cs.meta deleted file mode 100644 index d78b006e2..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTrianglationInternal.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: de8661d86f2667647b9dca59c1c97e9a -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTriangulation.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTriangulation.cs deleted file mode 100644 index a22962b23..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTriangulation.cs +++ /dev/null @@ -1,75 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -using System; -using System.Collections.Generic; - -namespace MIConvexHull -{ - /// - /// Calculation and representation of Delaunay triangulation. - /// - /// The type of the t vertex. - /// The type of the t cell. - /// - public class DelaunayTriangulation : ITriangulation - where TCell : TriangulationCell, new() - where TVertex : IVertex - { - /// - /// Can only be created using a factory method. - /// - private DelaunayTriangulation() - { - } - - /// - /// Cells of the triangulation. - /// - /// The cells. - public IEnumerable Cells { get; private set; } - - /// - /// Creates the Delaunay triangulation of the input data. - /// - /// The data. - /// The plane distance tolerance (default is 1e-10). If too high, points - /// will be missed. If too low, the algorithm may break. Only adjust if you notice problems. - /// DelaunayTriangulation<TVertex, TCell>. - /// data - /// data - public static DelaunayTriangulation Create(IList data, - float PlaneDistanceTolerance)// = Constants.DefaultPlaneDistanceTolerance) - { - if (data == null) throw new ArgumentNullException("data"); - if (data.Count == 0) return new DelaunayTriangulation { Cells = new TCell[0] }; - - var cells = ConvexHullAlgorithm.GetDelaunayTriangulation(data, PlaneDistanceTolerance); - - return new DelaunayTriangulation { Cells = cells }; - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTriangulation.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTriangulation.cs.meta deleted file mode 100644 index fc2ba273c..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/DelaunayTriangulation.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 4b0e872e78d2ab8428b446cfa5c06580 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/TriangulationCell.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/TriangulationCell.cs deleted file mode 100644 index 63e9d1f5a..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/TriangulationCell.cs +++ /dev/null @@ -1,51 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -namespace MIConvexHull -{ - /// - /// Representation of the triangulation cell. Pretty much the same as ConvexFace, - /// just wanted to distinguish the two. - /// To declare your own face type, use class Face : DelaunayFace(of Vertex, of Face) - /// - /// The type of the t vertex. - /// The type of the t cell. - /// - public abstract class TriangulationCell : ConvexFace - where TVertex : IVertex - where TCell : ConvexFace - { - } - - /// - /// Default triangulation cell. - /// - /// The type of the t vertex. - public class DefaultTriangulationCell : TriangulationCell> - where TVertex : IVertex - { - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/TriangulationCell.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/TriangulationCell.cs.meta deleted file mode 100644 index 29dcef792..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/TriangulationCell.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 808182249e0e66143b65945d9aeb37fe -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiEdge.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiEdge.cs deleted file mode 100644 index 1557352d6..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiEdge.cs +++ /dev/null @@ -1,93 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -namespace MIConvexHull -{ - /// - /// A class representing an (undirected) edge of the Voronoi graph. - /// - /// The type of the t vertex. - /// The type of the t cell. - public class VoronoiEdge - where TVertex : IVertex - where TCell : TriangulationCell - { - /// - /// Create an instance of the edge. - /// - public VoronoiEdge() - { - } - - /// - /// Create an instance of the edge. - /// - /// The source. - /// The target. - public VoronoiEdge(TCell source, TCell target) - { - Source = source; - Target = target; - } - - /// - /// Source of the edge. - /// - /// The source. - public TCell Source { get; internal set; } - - /// - /// Target of the edge. - /// - /// The target. - public TCell Target { get; internal set; } - - /// - /// ... - /// - /// The object to compare with the current object. - /// true if the specified is equal to this instance; otherwise, false. - public override bool Equals(object obj) - { - var other = obj as VoronoiEdge; - if (other == null) return false; - if (ReferenceEquals(this, other)) return true; - return (Source == other.Source && Target == other.Target) - || (Source == other.Target && Target == other.Source); - } - - /// - /// ... - /// - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. - public override int GetHashCode() - { - var hash = 23; - hash = hash*31 + Source.GetHashCode(); - return hash*31 + Target.GetHashCode(); - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiEdge.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiEdge.cs.meta deleted file mode 100644 index 71c57ddb7..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiEdge.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 2f5c388c78c121a47b14c2f4ed26af38 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiMesh.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiMesh.cs deleted file mode 100644 index 3142af854..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiMesh.cs +++ /dev/null @@ -1,203 +0,0 @@ -/****************************************************************************** - * - * The MIT License (MIT) - * - * MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - *****************************************************************************/ - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace MIConvexHull -{ - /// - /// A factory class for creating a Voronoi mesh. - /// - public static class VoronoiMesh - { - /// - /// Create the voronoi mesh. - /// - /// The type of the t vertex. - /// The type of the t cell. - /// The type of the t edge. - /// The data. - /// VoronoiMesh<TVertex, TCell, TEdge>. - public static VoronoiMesh Create(IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - where TCell : TriangulationCell, new() - where TVertex : IVertex - where TEdge : VoronoiEdge, new() - { - return VoronoiMesh.Create(data, PlaneDistanceTolerance); - } - - /// - /// Create the voronoi mesh. - /// - /// The type of the t vertex. - /// The data. - /// VoronoiMesh<TVertex, DefaultTriangulationCell<TVertex>, VoronoiEdge<TVertex, DefaultTriangulationCell<TVertex>>>. - public static - VoronoiMesh - , VoronoiEdge>> - Create(IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - where TVertex : IVertex - { - return - VoronoiMesh - , VoronoiEdge> - >.Create(data, PlaneDistanceTolerance); - } - - /// - /// Create the voronoi mesh. - /// - /// The data. - /// VoronoiMesh<DefaultVertex, DefaultTriangulationCell<DefaultVertex>, VoronoiEdge<DefaultVertex, DefaultTriangulationCell<DefaultVertex>>>. - public static - VoronoiMesh - , - VoronoiEdge>> - Create(IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - { - var points = data.Select(p => new DefaultVertex { Position = p.ToArray() }).ToList(); - return - VoronoiMesh - , - VoronoiEdge>>.Create(points, PlaneDistanceTolerance); - } - - /// - /// Create the voronoi mesh. - /// - /// The type of the t vertex. - /// The type of the t cell. - /// The data. - /// VoronoiMesh<TVertex, TCell, VoronoiEdge<TVertex, TCell>>. - public static VoronoiMesh> Create( - IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - where TVertex : IVertex - where TCell : TriangulationCell, new() - { - return VoronoiMesh>.Create(data, PlaneDistanceTolerance); - } - } - - /// - /// A representation of a voronoi mesh. - /// - /// The type of the t edge. - /// The type of the t vertex. - /// The type of the t cell. - public class VoronoiMesh - where TCell : TriangulationCell, new() - where TVertex : IVertex - where TEdge : VoronoiEdge, new() - { - /// - /// Can only be created using a factory method. - /// - private VoronoiMesh() - { - } - - /// - /// Vertices of the diagram. - /// - /// The vertices. - public IEnumerable Vertices { get; private set; } - - /// - /// Edges connecting the cells. - /// The same information can be retrieved Cells' Adjacency. - /// - /// The edges. - public IEnumerable Edges { get; private set; } - - /// - /// Create a Voronoi diagram of the input data. - /// - /// The data. - /// The plane distance tolerance (default is 1e-10). If too high, points - /// will be missed. If too low, the algorithm may break. Only adjust if you notice problems. - /// VoronoiMesh<TVertex, TCell, TEdge>. - /// data - /// data - public static VoronoiMesh Create(IList data, - float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance) - { - if (data == null) throw new ArgumentNullException("data"); - - var t = DelaunayTriangulation.Create(data, PlaneDistanceTolerance); - var vertices = t.Cells.ToList(); - var edges = new HashSet(new EdgeComparer()); - - foreach (var f in vertices) - { - for (var i = 0; i < f.Adjacency.Length; i++) - { - var af = f.Adjacency[i]; - if (af != null) edges.Add(new TEdge { Source = f, Target = af }); - } - } - - return new VoronoiMesh - { - Vertices = vertices, - Edges = edges.ToList() - }; - } - - /// - /// This is probably not needed, but might make things a tiny bit faster. - /// - /// - private class EdgeComparer : IEqualityComparer - { - /// - /// Equals the specified x. - /// - /// The x. - /// The y. - /// System.Boolean. - public bool Equals(TEdge x, TEdge y) - { - return (x.Source == y.Source && x.Target == y.Target) || (x.Source == y.Target && x.Target == y.Source); - } - - /// - /// Returns a hash code for this instance. - /// - /// The for which a hash code is to be returned. - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. - public int GetHashCode(TEdge obj) - { - return obj.Source.GetHashCode() ^ obj.Target.GetHashCode(); - } - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiMesh.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiMesh.cs.meta deleted file mode 100644 index c87b2415b..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Triangulation/VoronoiMesh.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: fbb5750f20479844eb4e504302755db9 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2.cs deleted file mode 100644 index e00c7deef..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Collections; -using MIConvexHull; -using CultMath; -using static CultMath.math; - -public class Vertex2 : IVertex -{ - public float[] Position => new[] {StoredPosition.x, StoredPosition.y}; - - public float2 StoredPosition; - - public float x { get { return StoredPosition.x; } } - public float y { get { return StoredPosition.y; } } - - public Vertex2(float x, float y) - { - StoredPosition = float2(x, y); - } - - public Vertex2(float2 pos) - { - StoredPosition = pos; - } - - public float2 ToFloat2() { - return StoredPosition; - } - - public float Distance(Vertex2 v) - { - return length(StoredPosition-v.StoredPosition); - } -} diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2.cs.meta deleted file mode 100644 index a314ce4ec..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 67d3ec22ea52a2642a14ff864e3017b0 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2Object.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2Object.cs deleted file mode 100644 index a266ddaec..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2Object.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CultMath; - -public class Vertex2 : Vertex2 -{ - public T StoredObject { get; } - - public Vertex2(float x, float y, T obj) : base(x, y) - { - StoredObject = obj; - } - - public Vertex2(float2 pos, T obj) : base(pos) - { - StoredObject = obj; - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2Object.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2Object.cs.meta deleted file mode 100644 index 24639c89b..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex2Object.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: e42612a273ec490380eaa67bed216a6d -timeCreated: 1615418804 \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex3.cs b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex3.cs deleted file mode 100644 index a14aea5b1..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex3.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Collections; -using MIConvexHull; -using CultMath; -using static CultMath.math; - -public class Vertex3 : IVertex -{ - public float[] Position => new[] {StoredPosition.x, StoredPosition.y}; - - public float3 StoredPosition; - - public Vertex3(float x, float y, float z) - { - StoredPosition = float3(x, y, z); - } - - public Vertex3(float3 pos) - { - StoredPosition = pos; - } - - public float3 ToFloat3() { - return StoredPosition; - } - - public float Distance(Vertex3 v) - { - return length(StoredPosition-v.StoredPosition); - } -} diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex3.cs.meta b/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex3.cs.meta deleted file mode 100644 index fc1115c36..000000000 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Vertex3.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 3553f231414f28347996a803a79425fa -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 6a70f9a43..ed7756a5a 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -282,25 +282,14 @@ public sealed class RestoredHullsTests var background = TutorialBackgroundSettings(); var names = TutorialNameSettings(); var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); - - // Galaxy zone-graph generation has a pre-existing, seed-dependent flakiness of its own (a disconnected - // zone graph throws inside the Galaxy constructor on roughly 1 seed in 15, confirmed unrelated to this - // cut: it reproduces identically against the pre-fix-batch catalog). That failure belongs to Galaxy.cs, - // not to ZoneGenerator's entrance-station override this test is pinning, so it is skipped rather than - // asserted on; every seed whose Galaxy actually builds must still get a docked entrance station. - var succeeded = 0; var sawGenuineLagrangeOrbit = false; for (uint seed = 1; seed <= 25; seed++) { var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-station-{Guid.NewGuid():N}.cc"); try { - // Galaxy mutates Faction.InfluenceDistance in place; a fresh catalog open per seed keeps runs independent. using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); - Galaxy galaxy; - try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); } - catch (Exception) { continue; } - succeeded++; + var galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); @@ -317,8 +306,6 @@ public sealed class RestoredHullsTests if (File.Exists(scratchRun)) File.Delete(scratchRun); } } - - Assert.True(succeeded >= 15, $"only {succeeded} of 25 seeds produced a Galaxy at all; too few runs to trust this result."); Assert.True(sawGenuineLagrangeOrbit, "none of the 25 seeds seated the entrance station on a genuine (non-rosette) Lagrange orbit."); } @@ -336,8 +323,6 @@ public sealed class RestoredHullsTests var background = TutorialBackgroundSettings(); var names = TutorialNameSettings(); var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); - - var succeeded = 0; var sawGenuineCandidate = false; for (uint seed = 1; seed <= 25; seed++) { @@ -345,10 +330,7 @@ public sealed class RestoredHullsTests try { using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); - Galaxy galaxy; - try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); } - catch (Exception) { continue; } - succeeded++; + var galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); @@ -369,8 +351,6 @@ public sealed class RestoredHullsTests if (File.Exists(scratchRun)) File.Delete(scratchRun); } } - - Assert.True(succeeded >= 15, $"only {succeeded} of 25 seeds produced a Galaxy at all; too few runs to trust this result."); Assert.True(sawGenuineCandidate, "none of the 25 seeds had a genuine Lagrange candidate at all; too few runs to trust this result."); } @@ -477,9 +457,7 @@ public sealed class RestoredHullsTests try { using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); - Galaxy galaxy; - try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); } - catch (Exception) { continue; } + var galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index cebf564d6..ac57abce9 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -171,6 +171,65 @@ public sealed partial class RunStartTests Assert.All(authored, pair => Assert.Equal(pair.Value, pair.Key.InfluenceDistance)); } + // Every zone is linked into one graph. These seeds left zones with no link at all (the vendored float-precision + // convex-hull triangulation dropped them), so the constructor threw KeyNotFoundException placing homes (prelude) + // or the entrance (main). Each galaxy's links are a subset of its zone positions' Delaunay edges, and the + // triangulation agrees with a brute-force empty-circumcircle oracle. + [Theory] + [InlineData(103u, true)] + [InlineData(134u, true)] + [InlineData(137u, true)] + [InlineData(173u, true)] + [InlineData(227u, true)] + [InlineData(285u, true)] + [InlineData(34u, false)] + public void EveryZoneIsLinked(uint seed, bool prelude) + { + var galaxy = RunStart.Generate(new Scripted(true, _ => { }, seed, stage => prelude ? stage.Prelude() : stage.Main()), Inputs()); + Assert.All(galaxy.Zones, zone => Assert.Equal(galaxy.Zones.Length, zone.Distance.Count)); + + var positions = galaxy.Zones.Select(zone => zone.Position).ToArray(); + var edges = Delaunay.Edges(positions); + Assert.Equal(BruteForceDelaunay(positions), edges); + var delaunay = new HashSet<(int, int)>(edges); + for (var i = 0; i < galaxy.Zones.Length; i++) + foreach (var adjacent in galaxy.Zones[i].AdjacentZones) + { + var j = Array.IndexOf(galaxy.Zones, adjacent); + Assert.Contains((Math.Min(i, j), Math.Max(i, j)), delaunay); + } + } + + // Every triangle whose circumcircle holds no other point, as sorted edges. + private static List<(int a, int b)> BruteForceDelaunay(float2[] points) + { + var edges = new SortedSet<(int a, int b)>(); + for (var i = 0; i < points.Length; i++) + for (var j = i + 1; j < points.Length; j++) + for (var k = j + 1; k < points.Length; k++) + { + double ax = points[i].x, ay = points[i].y, bx = points[j].x, by = points[j].y, cx = points[k].x, cy = points[k].y; + var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); + if (d == 0) continue; + double a2 = ax * ax + ay * ay, b2 = bx * bx + by * by, c2 = cx * cx + cy * cy; + var ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d; + var uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d; + var r2 = (ax - ux) * (ax - ux) + (ay - uy) * (ay - uy); + var empty = true; + for (var m = 0; m < points.Length && empty; m++) + if (m != i && m != j && m != k) + { + double dx = points[m].x - ux, dy = points[m].y - uy; + empty = dx * dx + dy * dy >= r2; + } + if (!empty) continue; + edges.Add((i, j)); + edges.Add((j, k)); + edges.Add((i, k)); + } + return edges.ToList(); + } + // A scenario naming one unknown design admits nothing, not even the parts that built, and names the design. [Fact] public void StagingIsAllOrNothing() From 4d1334e044a20a6c6bee7fc9b0e5294d5c3e8e41 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 21:59:17 +0200 Subject: [PATCH 109/113] Arcs runs at seed 103 again, as the spec sets it: its prelude galaxy generates now that every zone is linked Reverts 0854d91d. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Scenarios/Arcs.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index cd7eec885..d1ceb61e0 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -6,7 +6,7 @@ public sealed class Arcs : Scenario public override string Name => "Arcs"; public override string Brief => "Forward mounts, a bare hull off the bow, one off the stern, a hostile turret. Verify: the bow " + "hull can be hit, nothing fires at the stern one through the ship, the turret tracks you all the way round."; - public override uint Seed => 107; + public override uint Seed => 103; public override bool Ambient => false; // Where the targets sit: the player is at the origin facing +z, and every mount fires forward. From 02e7fd4b7e308d1ac05fc436618d074886a04f28 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 22:01:17 +0200 Subject: [PATCH 110/113] A fixed seed fixes every fit: RunStart seeds the run's item draws from the galaxy's seed ScenarioStage.Generated picked from ItemManager.Random, which is clock-seeded per process and advances in play, so a relaunch of Djinni Shakedown or Long Haul in one session gave the player a different fit; every branded lot's workmanship and role fills came from the same clock stream, so arena gear and explicit fits varied in quality too. Galaxy now keeps the seed it was generated at, and RunStart.GenerateArena and Stage each reseed the item manager's random from it, one stream per step, so what runs between them cannot shift either. SeedRules relaunches every fixed-seed scenario around a clock-free Main Galaxy launch and a scrambled item random, and compares the galaxy (links, factions, homes, exit too), the arena down to each item's maker and workmanship, and the staged fits. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Galaxy.cs | 10 +++- Assets/Scripts/ServerShared/RunStart.cs | 15 +++++- Assets/Scripts/ServerShared/Scenario.cs | 3 +- Assets/Scripts/ServerShared/ScenarioStage.cs | 4 +- tests/Aetheria.Shared.Tests/ScenarioTests.cs | 55 +++++++++++++++----- 5 files changed, 68 insertions(+), 19 deletions(-) diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index bcdf7a2f8..a5660f52e 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -28,6 +28,10 @@ public class Galaxy public Dictionary FactionRelationships { get; } = new Dictionary(); private Action Log { get; } public bool IsPrelude { get; } + + // The seed this galaxy was generated at, which also seeds its run's item draws (RunStart); zero for a galaxy + // restored from a save. + public uint Seed { get; } private HashSet _containedFactions; private GalaxyZone[] _exitPath; @@ -104,7 +108,8 @@ public class Galaxy Background = background; Log = log; var factions = cache.GetAll(); - var random = new Random(seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed); + Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed; + var random = new Random(Seed); Factions = factions.OrderBy(x => random.NextFloat()).Take(settings.MegaCount).ToArray(); foreach (var f in Factions) FactionRelationships[f] = FactionRelationship.Neutral; @@ -159,7 +164,8 @@ public class Galaxy Background = background; Log = log; - var random = new Random(seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed); + Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed; + var random = new Random(Seed); var factions = new List(); diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index 1eab125bb..4397f4575 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Random = CultMath.Random; // The owner of a new run (docs/scenarios-cut.md, R.4). Every new run is a scenario: RunStart generates its galaxy, // generates its arena (the galaxy's entrance zone) under the scenario's Ambient rule, and admits what the scenario @@ -21,9 +22,12 @@ public static class RunStart return scenario.Generate(stage) ?? throw new InvalidOperationException($"Scenario {scenario.Name} generated no galaxy."); } - public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, Galaxy galaxy, Scenario scenario) => - galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone( + public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, Galaxy galaxy, Scenario scenario) + { + SeedItems(itemManager, galaxy, 1); + return galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone( itemManager, zoneSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario.Ambient); + } // Runs the scenario's Stage, then admits its player and everything it placed: each entity's stance set both ways // with the player, a pilot for each piloted one. Returns null, with every failure listed and the arena unchanged, @@ -31,6 +35,7 @@ public static class RunStart public static Staged Stage(ItemManager itemManager, Zone arena, Scenario scenario, string startingHull, TutorialGenerationSettings tutorialGenerationSettings, List failures) { + SeedItems(itemManager, arena.Galaxy, 2); var stage = new ScenarioStage(itemManager, arena, startingHull, tutorialGenerationSettings); scenario.Stage(stage); var reported = failures.Count; @@ -51,4 +56,10 @@ public static class RunStart } return new Staged { Player = player, Entities = stage.Placed.Select(placed => placed.Entity).ToList() }; } + + // A new run's item draws (each lot's quality, each generated fit) come from its galaxy's seed, not the process clock, + // so a fixed seed fixes the arena's gear and the player's fit. Each step draws its own stream, so whatever runs + // between them (populating the level) cannot shift it. + private static void SeedItems(ItemManager itemManager, Galaxy galaxy, uint step) => + itemManager.Random = new Random(galaxy.Seed * 0x9E3779B1u + step); } diff --git a/Assets/Scripts/ServerShared/Scenario.cs b/Assets/Scripts/ServerShared/Scenario.cs index ba7165a19..acd2f10b2 100644 --- a/Assets/Scripts/ServerShared/Scenario.cs +++ b/Assets/Scripts/ServerShared/Scenario.cs @@ -10,7 +10,8 @@ public abstract class Scenario public abstract string Brief { get; } // Zero draws the seed from the clock, so each launch is a new galaxy. Anything else fixes the galaxy, its - // background and its arena, so every launch has the same layout. + // background, its arena and every fit, each item's maker and workmanship included, so every launch has the same + // layout. public virtual uint Seed => 0; // False: the arena keeps its planets, orbits and stations but generates no ship or turret, so nothing wanders into diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index 241684a35..feac93e66 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -64,8 +64,8 @@ public sealed class ScenarioStage // A hull with nothing fitted. public ScenarioFit Bare(string hull) => Fit(hull); - // A fit generated as the game generates its ships, for the faction when one is given. An empty hull means any - // ship hull. + // A fit generated as the game generates its ships, for the faction when one is given, from the run's item draws, + // which RunStart seeds from the galaxy's seed. An empty hull means any ship hull. public ScenarioFit Generated(string hull, Faction faction = null) { var generator = new LoadoutGenerator(ref _items.Random, _items, Galaxy, Arena.GalaxyZone, faction, 2); diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index ac57abce9..9fbd9e7e6 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -132,24 +132,55 @@ public sealed partial class RunStartTests Assert.Contains(main.arena.Pack.Entities, entity => entity is ShipPack { IsPlayerShip: false }); } + // Everything a galaxy's layout fixes: each zone's name, position, owner, factions and links, the homes, the + // entrance, the exit and the background. private static string Layout(Galaxy galaxy) => - string.Join(";", galaxy.Zones.Select(zone => $"{zone.Name}@{zone.Position}:{zone.Owner?.Name}")) + - $" entrance {Array.IndexOf(galaxy.Zones, galaxy.Entrance)} noise {galaxy.Background.NoisePosition}"; + string.Join(";", galaxy.Zones.Select(zone => + $"{zone.Name}@{zone.Position}:{zone.Owner?.Name}:[{string.Join(",", zone.Factions.Select(faction => faction.Name))}]" + + $":[{string.Join(",", zone.AdjacentZones.Select(adjacent => Array.IndexOf(galaxy.Zones, adjacent)).OrderBy(i => i))}]")) + + $" homes {string.Join(",", galaxy.HomeZones.Select(home => $"{home.Key.Name}={Array.IndexOf(galaxy.Zones, home.Value)}").OrderBy(home => home))}" + + $" entrance {Array.IndexOf(galaxy.Zones, galaxy.Entrance)} exit {Array.IndexOf(galaxy.Zones, galaxy.Exit)} noise {galaxy.Background.NoisePosition}"; - private static string Layout(ZonePack arena) => - $"{arena.Radius} {arena.Mass} {arena.Planets.Count()} {arena.Orbits.Count()} {arena.Entities.Count()}"; + // An arena's bodies and every entity in it, down to each item's design, maker, workmanship and role fills. + private string Layout(ZonePack arena) => + $"{arena.Radius} {arena.Mass} planets [{string.Join(",", arena.Planets.Select(planet => _cache.Get(planet)).Select(body => $"{body.GetType().Name}:{body.Mass}"))}]" + + $" orbits [{string.Join(",", arena.Orbits.Select(orbit => _cache.Get(orbit).Distance))}]" + + $" entities [{string.Join(",", arena.Entities.Select(Gear))}]"; - // A fixed seed fixes the galaxy, its background and its arena, launch after launch in one session. Seed zero takes - // the clock, so two launches at two clock readings are two galaxies. + // What a launch staged: the player and everything placed, each where it is and with exactly what it carries. + private string Fits(RunStart.Staged staged) => + string.Join(" | ", staged.Entities.Prepend(staged.Player).Select(entity => $"{entity.Name}@{entity.Position}:{Gear(EntitySerializer.Pack(entity))}")); + + private string Gear(EntityPack pack) => + $"{pack.GetType().Name}{(pack is ShipPack ship ? "@" + ship.Position : "")}:{string.Join("/", EntitySerializer.Items(pack).Select(Item))}"; + + private string Item(ItemInstance item) + { + var name = _items.GetData(item)?.Name; + if (!(item is CraftedItemInstance crafted)) return name; + var lot = _items.GetLot(crafted); + return $"{name}<{(lot.Origin as Attributed)?.Faction.Key.Value}>~{lot.Quality}" + + string.Concat((lot.Roles ?? new List()).Select(fill => $"/{fill.Role}={fill.Quality}")); + } + + // A fixed seed fixes the galaxy, its background, its arena and every fit, each item's maker and workmanship + // included, launch after launch in one session, whatever ran in between. Seed zero takes the clock, so two launches + // at two clock readings are two galaxies. [Fact] public void SeedRules() { - var arena = new DjinniShakedown(); - Assert.NotEqual(0u, arena.Seed); - var first = Launch(arena); - var second = Launch(arena); - Assert.Equal(Layout(first.galaxy), Layout(second.galaxy)); - Assert.Equal(Layout(first.arena.Pack), Layout(second.arena.Pack)); + foreach (var scenario in Scenarios.Tests) + { + Assert.NotEqual(0u, scenario.Seed); + var first = Launch(scenario); + Launch(new MainGalaxy(), Inputs(() => 4242)); + _items.Random = new CultMath.Random(0xC0FFEE); // play draws from the run's item random + var second = Launch(scenario); + Assert.True(first.failures.Count == 0, $"{scenario.Name}: {string.Join("; ", first.failures)}"); + Assert.Equal(Layout(first.galaxy), Layout(second.galaxy)); + Assert.Equal(Layout(first.arena.Pack), Layout(second.arena.Pack)); + Assert.Equal(Fits(first.staged), Fits(second.staged)); + } var clock = new Queue(new[] { 1000u, 2000u }); var inputs = Inputs(() => clock.Dequeue()); From 8a1e205cf069e86c1bc570fa4c52208a104914ea Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 22:03:50 +0200 Subject: [PATCH 111/113] A new run replaces the saved run only once it has staged; a failed start says why and leaves the saved run Launch cleared the run store before generating, and a generation or staging failure only reached the log: the Generating Galaxy dialog stayed up, StartGame threw, and the saved run was already gone. RunSave.Replace runs a start with the saved run still stored: when it returns a staged run the saved run's records go, and when it returns null or throws, only the records it wrote (the arena's orbits and bodies) go. StartGame's new-run branch is StartScenario, chosen by PendingScenario rather than by the absence of a SavedGame; it mints into an empty ledger, and a failed start clears CurrentGalaxy (so quitting saves nothing) and refuses on the main menu with every failure. Launch no longer clears; a galaxy that throws is refused on the title menu. The menu offers Continue whenever no run is live, which replaces MainMenu.InGame (cut, with its prefab value and scene override), so a failed start can resume the saved run in place. Co-Authored-By: Claude Opus 5.5 --- Assets/Prefabs/UI/Main Menu Canvas.prefab | 1 - Assets/Scenes/ARPG.unity | 5 -- Assets/Scripts/Gameplay/ActionGameManager.cs | 55 ++++++++++++++------ Assets/Scripts/ServerShared/SavedGame.cs | 28 ++++++++-- Assets/Scripts/UI/MainMenu.cs | 31 ++++++----- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 50 ++++++++++++++++++ 6 files changed, 132 insertions(+), 38 deletions(-) diff --git a/Assets/Prefabs/UI/Main Menu Canvas.prefab b/Assets/Prefabs/UI/Main Menu Canvas.prefab index a03628ba0..866d5fb33 100644 --- a/Assets/Prefabs/UI/Main Menu Canvas.prefab +++ b/Assets/Prefabs/UI/Main Menu Canvas.prefab @@ -915,7 +915,6 @@ MonoBehaviour: m_EditorClassIdentifier: Settings: {fileID: 11400000, guid: cd2344e2001a37544aab0960c164d4ff, type: 2} Dialog: {fileID: 4533226043449861657} - InGame: 0 PanelPrototype: {fileID: 7329274282942065594} FadeTime: 0.5 FadeDistance: 1024 diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index 1ef50e0ea..ad5c3d536 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -9636,11 +9636,6 @@ PrefabInstance: propertyPath: m_SizeDelta.y value: 90.91 objectReference: {fileID: 0} - - target: {fileID: 4533226043457584178, guid: 3d8d9cbf10b12214e98891c1f03c1e99, - type: 3} - propertyPath: InGame - value: 1 - objectReference: {fileID: 0} - target: {fileID: 4533226043457584178, guid: 3d8d9cbf10b12214e98891c1f03c1e99, type: 3} propertyPath: CloudRenderer diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 8ebcf413a..422ad2327 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -270,7 +270,9 @@ public class ActionGameManager : MonoBehaviour EntityInstance.EffectManagerParent = EffectManagerParent; ConsoleController.MessageReceiver = this; - ItemManager = new ItemManager(CultCache, RunSave.Lots(CultCache), Settings.GameplaySettings, Debug.Log); + // A new run mints into an empty ledger; the saved run's stays in the store until the new run has started. + ItemManager = new ItemManager(CultCache, PendingScenario != null ? new ProvenanceLedger() : RunSave.Lots(CultCache), + Settings.GameplaySettings, Debug.Log); ZoneRenderer.ItemManager = ItemManager; // If hiding minimap asteroids, turn them off to start with @@ -795,22 +797,10 @@ public class ActionGameManager : MonoBehaviour { if (CurrentGalaxy != null) { - var saved = CultCache.GetGlobal(); - if (saved == null) - { - var scenario = PendingScenario ?? throw new InvalidOperationException("A new run needs a scenario; MainMenu.Launch sets one."); - PendingScenario = null; - SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance)); - // RunStart owns what a new run starts with (docs/scenarios-cut.md, R.4); this only enters and binds. - RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, CurrentGalaxy, scenario); - PopulateLevel(CurrentGalaxy.Entrance); - var failures = new List(); - var staged = RunStart.Stage(ItemManager, Zone, scenario, Settings.StartingHullName, Settings.TutorialGenerationSettings, failures) - ?? throw new InvalidOperationException($"Scenario {scenario.Name} could not stage: {string.Join("; ", failures)}"); - BindToEntity(staged.Player); - } + if (PendingScenario != null) StartScenario(); else { + var saved = CultCache.GetGlobal(); foreach(var group in CurrentGalaxy.DiscoveredZones .GroupBy(dz=>dz.Distance[CurrentGalaxy.Entrance])) SectorMap.QueueZoneReveal(group); @@ -835,6 +825,41 @@ public class ActionGameManager : MonoBehaviour } } + // RunStart owns what a new run starts with (docs/scenarios-cut.md, R.4); this enters and binds. The saved run is + // replaced only once the scenario has staged. A start that fails leaves the saved run as it was and says why on + // the main menu, where Continue still resumes the saved run. + private void StartScenario() + { + var scenario = PendingScenario; + PendingScenario = null; + var failures = new List(); + RunStart.Staged staged = null; + try + { + staged = RunSave.Replace(CultCache, () => + { + RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, CurrentGalaxy, scenario); + PopulateLevel(CurrentGalaxy.Entrance); + return RunStart.Stage(ItemManager, Zone, scenario, Settings.StartingHullName, Settings.TutorialGenerationSettings, failures); + }); + } + catch (Exception exception) + { + Debug.LogException(exception); + failures.Add(exception.Message); + } + + if (staged == null) + { + CurrentGalaxy = null; + MainMenu.gameObject.SetActive(true); + MainMenu.Refuse($"{scenario.Name} could not start", string.Join("\n", failures)); + return; + } + SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance)); + BindToEntity(staged.Player); + } + private IEnumerator IntroCutscene(Ship ship) { ZoneRenderer.PerspectiveEntity = ship; diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index a674df81d..87bbeda60 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -155,17 +155,37 @@ public static class RunSave } // Removes every run record (SavedGame, SavedZone, OrbitData, BodyData, ProvenanceLedger) in one Commit. - public static void Clear(CultCache cache) + public static void Clear(CultCache cache) => Remove(cache, Records(cache)); + + // A new run replaces the saved one only once it has started: start runs with the saved run still in the store. + // When it returns a value, the saved run's records go. When it returns null or throws, the records it wrote go and + // the saved run stays as it was. + public static T Replace(CultCache cache, Func start) where T : class { - var run = cache.AllStoredDocuments + var saved = Records(cache); + T started = null; + try + { + started = start(); + } + finally + { + Remove(cache, started != null ? saved : Records(cache).Except(saved).ToArray()); + } + return started; + } + + private static CultRecordKey[] Records(CultCache cache) => + cache.AllStoredDocuments .Where(stored => IsRunRecord(stored.Descriptor.DocumentType)) .Select(stored => stored.Key) .ToArray(); + + private static void Remove(CultCache cache, CultRecordKey[] records) => cache.Commit(batch => { - foreach (var key in run) batch.Remove(key); + foreach (var key in records) batch.Remove(key); }); - } public static bool IsRunRecord(Type documentType) => AetheriaStores.RunTypes.Any(home => home.IsAssignableFrom(documentType)); diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index b5bdab2ee..f20208ff7 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -17,7 +17,6 @@ public class MainMenu : MonoBehaviour public VolumeCloudRenderer CloudRenderer; public GameSettings Settings; public ConfirmationDialog Dialog; - public bool InGame; public Prototype PanelPrototype; public float FadeTime = .5f; public float FadeDistance = 512; @@ -92,9 +91,10 @@ public class MainMenu : MonoBehaviour { _nextMenu.panel.Clear(); _nextMenu.panel.Title.text = TitleSubtitle("aetheria", "terminus"); - if (!InGame) + if (ActionGameManager.CurrentGalaxy == null) { - // A run exists only as the run store's SavedGame global. + // No run is live: at the title, after death, or after a new run failed to start. A saved run exists only as + // the run store's SavedGame global. var cache = ActionGameManager.CultCache; if (ActionGameManager.ModExclusions.Length > 0 && !_exclusionsShown) { @@ -156,11 +156,11 @@ public class MainMenu : MonoBehaviour } } - // The only way into a new run: clears the saved run, generates the scenario's galaxy off the main thread, and - // enters the game, where StartGame has RunStart stage it. + // The only way into a new run: generates the scenario's galaxy off the main thread and enters the game, where + // StartGame has RunStart stage it and only then replaces the saved run. A galaxy that cannot generate is refused + // here, with the saved run untouched. private void Launch(Scenario scenario) { - RunSave.Clear(ActionGameManager.CultCache); var generatorState = "Loading Database Contents"; Fade(true); @@ -181,16 +181,21 @@ public class MainMenu : MonoBehaviour ActionGameManager.GameDataDirectory.CreateSubdirectory("Narrative"), Debug.Log, state => generatorState = state); - Task.Run(() => - { - var galaxy = RunStart.Generate(scenario, stage); + Task.Run(() => RunStart.Generate(scenario, stage)).ContinueWith(generation => Observable.NextFrame().Subscribe(_ => { - ActionGameManager.CurrentGalaxy = galaxy; + if (generation.IsFaulted) + { + Debug.LogException(generation.Exception); + ShowMain(); + Fade(false); + Refuse($"{scenario.Name} could not generate its galaxy", generation.Exception.GetBaseException().Message); + return; + } + ActionGameManager.CurrentGalaxy = generation.Result; ActionGameManager.PendingScenario = scenario; EnterGame(); - }); - }).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted); + })); } // The one way into the game scene. Mod ship prototypes import asynchronously at boot, so the scene waits for them, @@ -209,7 +214,7 @@ public class MainMenu : MonoBehaviour SceneManager.LoadScene("ARPG"); } - private void Refuse(string title, string reason) + public void Refuse(string title, string reason) { Debug.LogError($"{title}: {reason}"); Dialog.Clear(); diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 6ce426995..51db6b137 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -126,6 +126,56 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(before, new[] { Catalog, Player }.Select(Hash).ToArray()); } + // A new run's start writes its arena's orbits and bodies while the saved run is still stored. A start that fails, + // by returning nothing or by throwing, takes away only what it wrote; one that starts takes away the saved run. + [Fact] + public void ANewRunReplacesTheSavedRunOnlyOnceItHasStarted() + { + using (var cache = Open()) + RunSave.Commit(cache, Game(cache), Zones(3, StageZoneContents(cache), 0), new ProvenanceLedger()); + string[] saved; + using (var cache = Open()) + saved = RunKeys(cache); + Assert.Equal(8, saved.Length); + + using (var cache = Open()) + Assert.Null(RunSave.Replace(cache, () => + { + StageZoneContents(cache); + return null; + })); + using (var cache = Open()) + { + Assert.Equal(saved, RunKeys(cache)); + Assert.NotNull(cache.GetGlobal()); + } + + using (var cache = Open()) + Assert.Throws(() => RunSave.Replace(cache, () => + { + StageZoneContents(cache); + throw new InvalidOperationException("the arena would not generate"); + })); + using (var cache = Open()) + Assert.Equal(saved, RunKeys(cache)); + + ZonePack started; + using (var cache = Open()) + started = RunSave.Replace(cache, () => StageZoneContents(cache)); + using (var cache = Open()) + { + Assert.Null(cache.GetGlobal()); + Assert.Equal(started.Orbits.Select(orbit => orbit.Key.Value).Concat(started.Planets.Select(body => body.Key.Value)) + .OrderBy(key => key, StringComparer.Ordinal), RunKeys(cache)); + } + } + + private static string[] RunKeys(CultCache cache) => cache.AllStoredDocuments + .Where(stored => RunSave.IsRunRecord(stored.Descriptor.DocumentType)) + .Select(stored => stored.Key.Value) + .OrderBy(key => key, StringComparer.Ordinal) + .ToArray(); + // Lot 1 sits on the root ship's hull; lot 3 (Produced from facility 4, input 5) sits in a docked child's // docking-bay contents; lots 2 and 6 are minted but referenced by nothing packed. Only the reachable closure // {1, 3, 4, 5} survives into the written copy; the live ledger keeps every lot. From d44df96fb0b5e83ab9ca802c3e540b3272282d7b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 22:04:17 +0200 Subject: [PATCH 112/113] SeedRules scrambles the item random between the arena and staging, so the staging step's own seed is pinned Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ScenarioTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index 9fbd9e7e6..ddb75aa8e 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -59,11 +59,13 @@ public sealed partial class RunStartTests _cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, clock); } - // What MainMenu.Launch and StartGame do with a scenario: its galaxy, its arena, then staging. - private (Galaxy galaxy, Zone arena, RunStart.Staged staged, List failures) Launch(Scenario scenario, GalaxyStage inputs = null) + // What MainMenu.Launch and StartGame do with a scenario: its galaxy, its arena, then staging. beforeStaging stands in + // for whatever the game runs between the arena and staging. + private (Galaxy galaxy, Zone arena, RunStart.Staged staged, List failures) Launch(Scenario scenario, GalaxyStage inputs = null, Action beforeStaging = null) { var galaxy = RunStart.Generate(scenario, inputs ?? Inputs()); var arena = new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, galaxy, scenario), galaxy.Entrance, galaxy); + beforeStaging?.Invoke(); var failures = new List(); var staged = RunStart.Stage(_items, arena, scenario, _startingHull, _tutorialSettings, failures); return (galaxy, arena, staged, failures); @@ -175,7 +177,7 @@ public sealed partial class RunStartTests var first = Launch(scenario); Launch(new MainGalaxy(), Inputs(() => 4242)); _items.Random = new CultMath.Random(0xC0FFEE); // play draws from the run's item random - var second = Launch(scenario); + var second = Launch(scenario, beforeStaging: () => _items.Random = new CultMath.Random(0xBADC0DE)); Assert.True(first.failures.Count == 0, $"{scenario.Name}: {string.Join("; ", first.failures)}"); Assert.Equal(Layout(first.galaxy), Layout(second.galaxy)); Assert.Equal(Layout(first.arena.Pack), Layout(second.arena.Pack)); From 8cd68f4e65cdb5318d49338403866fec9773f4db Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 22:36:04 +0200 Subject: [PATCH 113/113] Pin that a failed start's own writes are removed: the failed starts write a record the saved run does not hold, and the open store must not keep it Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 51db6b137..c8630cdfd 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -139,11 +139,14 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(8, saved.Length); using (var cache = Open()) + { Assert.Null(RunSave.Replace(cache, () => { - StageZoneContents(cache); + WriteAnOrbitTheSavedRunLacks(cache); return null; })); + Assert.Equal(saved, RunKeys(cache)); + } using (var cache = Open()) { Assert.Equal(saved, RunKeys(cache)); @@ -151,11 +154,14 @@ public sealed class RunSaveTests : IDisposable } using (var cache = Open()) + { Assert.Throws(() => RunSave.Replace(cache, () => { - StageZoneContents(cache); + WriteAnOrbitTheSavedRunLacks(cache); throw new InvalidOperationException("the arena would not generate"); })); + Assert.Equal(saved, RunKeys(cache)); + } using (var cache = Open()) Assert.Equal(saved, RunKeys(cache)); @@ -582,6 +588,9 @@ public sealed class RunSaveTests : IDisposable QualityPriceModifier = new ExponentialLerp() }; + // What a start that fails leaves behind: a record of its own, which the saved run does not hold. + private static void WriteAnOrbitTheSavedRunLacks(CultCache cache) => cache.Upsert(new OrbitData { Distance = 99 }); + // Two orbits and a body upserted into the run store and left staged, as zone generation leaves them. private static ZonePack StageZoneContents(CultCache cache) {