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()