From 460bb85ed04b66a3c1208383b07d4c85bd58b2ac Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 06:43:52 +0200 Subject: [PATCH] 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)); + } +}