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/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.meta b/Assets/Content/Prefabs/ShipModTemplate.prefab.meta similarity index 57% rename from Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.meta rename to Assets/Content/Prefabs/ShipModTemplate.prefab.meta index 6a291ff20..d2ea9f827 100644 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.meta +++ b/Assets/Content/Prefabs/ShipModTemplate.prefab.meta @@ -1,7 +1,6 @@ fileFormatVersion: 2 -guid: a6e88469e3b81a541a0bc8a66e60d0c7 -folderAsset: yes -DefaultImporter: +guid: 5d1f3a8c7e2b4f60a9c4b7e1d3f2a860 +PrefabImporter: externalObjects: {} userData: assetBundleName: 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/Editor/ShipModPlaySmoke.cs b/Assets/Scripts/Editor/ShipModPlaySmoke.cs new file mode 100644 index 000000000..f0753e4e6 --- /dev/null +++ b/Assets/Scripts/Editor/ShipModPlaySmoke.cs @@ -0,0 +1,121 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +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. +// +// 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 +// +// 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 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 broken = InstallWithBrokenSibling(packagePath, package, modsRoot); + var composition = ShipModCatalog.Compose(Path.GetFullPath(Option("-shippedCatalog", "GameData/Aetheria.cc")), derived, modsRoot); + 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); + 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(); + 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 skipped={broken.Visual.Id}"); + } + Clean(zone, work); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + 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/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 diff --git a/Assets/Scripts/Editor/ShipModPreview.cs b/Assets/Scripts/Editor/ShipModPreview.cs new file mode 100644 index 000000000..9ed4fcec4 --- /dev/null +++ b/Assets/Scripts/Editor/ShipModPreview.cs @@ -0,0 +1,133 @@ +using System; +using System.Linq; +using UnityEditor; +using UnityEngine; + +public static class ShipModPreview +{ + 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(); + 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 + { + 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); + } + catch (Exception exception) + { + Debug.LogException(exception); + visual?.Destroy(); + EditorApplication.Exit(1); + } + } + + 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"); CheckExhaust(clone); } + finally { UnityEngine.Object.DestroyImmediate(clone.gameObject); } + Debug.Log($"SHIP_MOD_ASSEMBLY_SMOKE thrusters={plan.Thrusters.Length} exhaust=ok collider=hidden " + + $"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(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) && + 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"); + 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"); + } + + // 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() + { + var path = EditorUtility.OpenFilePanel("Ship authoring record", "GameData/Mods", "cc"); + if (string.IsNullOrEmpty(path)) return; + try + { + var visual = await ShipModVisual.LoadAsync(ShipModCatalog.ReadPackage(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/Editor/ShipPrefabAuthoring.cs b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs index 988fe15e7..5d5b7352c 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,13 +154,14 @@ 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; 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; @@ -182,7 +182,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..9f740f557 100644 --- a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs +++ b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs @@ -23,18 +23,18 @@ 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"); 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/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index b144f4019..c8f0bb6e2 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -42,7 +42,13 @@ 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. + private static string DerivedCatalogPath => Path.Combine(Application.persistentDataPath, "Aetheria.modded.cc"); public static CultCache CultCache { @@ -52,12 +58,18 @@ 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. + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(CatalogPath, DerivedCatalogPath, ModsPath); + ModExclusions = excluded; _cultCache = AetheriaStores.Open( - CatalogPath, + catalog, 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; } } @@ -94,7 +106,8 @@ public class ActionGameManager : MonoBehaviour } public static Galaxy CurrentGalaxy; - public static bool IsTutorial; + // 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"; @@ -238,7 +251,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); } @@ -257,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 @@ -539,26 +554,12 @@ 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 + 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()); @@ -691,7 +692,7 @@ public class ActionGameManager : MonoBehaviour Settings.ZoneSettings, CurrentGalaxy, galaxyZone, - IsTutorial + CurrentGalaxy.IsPrelude ); galaxyZone.Contents = new Zone(ItemManager, Settings.PlanetSettings, galaxyZone.PackedContents, galaxyZone, CurrentGalaxy); } @@ -705,8 +706,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); @@ -770,25 +770,10 @@ public class ActionGameManager : MonoBehaviour { if (CurrentGalaxy != null) { - var saved = CultCache.GetGlobal(); - if (saved == null) - { - SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance)); - 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); - } + if (PendingScenario != null) StartScenario(); else { + var saved = CultCache.GetGlobal(); foreach(var group in CurrentGalaxy.DiscoveredZones .GroupBy(dz=>dz.Distance[CurrentGalaxy.Entrance])) SectorMap.QueueZoneReveal(group); @@ -813,6 +798,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; @@ -1021,12 +1041,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 923199200..31b6d093c 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,23 +240,13 @@ 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(); 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..8f4fe40bf 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; + .FirstOrDefault(t => t.name == thruster.Item.Hardpoint.Transform); + ThrusterHardpoint.WireExhaust(particles, thrusterHardpoint); // if (!string.IsNullOrEmpty(thruster.Item.Data.SoundEffectTrigger) && thrusterHardpoint != null) // { // AkSoundEngine.RegisterGameObj(thrusterHardpoint.gameObject); diff --git a/Assets/Scripts/Gameplay/ShipModShips.cs b/Assets/Scripts/Gameplay/ShipModShips.cs new file mode 100644 index 000000000..94177e0a5 --- /dev/null +++ b/Assets/Scripts/Gameplay/ShipModShips.cs @@ -0,0 +1,182 @@ +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 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) + { + 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); + // 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) + { + 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 (Exception error) + { + visual?.Destroy(); + Debug.LogError($"Mod ship {hull.Name} skipped: {error.Message}"); + } + } + } + + // 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; + // 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) Hide(hullRenderer, template.Invisible); + 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 => + { + // 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); + Hide(hardpoint.Emitter, template.Invisible); + 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.ThrusterHardpoints = thrusters; + instance.WeaponHardpoints = weapons; + instance.RadiatorHardpoints = radiators; + instance.ArticulationPoints = Array.Empty(); + instance.DestroyEffect = template.DestroyEffect; + instance.TractorBeam = tractor; + 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; + 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/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/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/ServerShared/NIH/MIConvexHull/ConvexHull/Collections.cs.meta b/Assets/Scripts/Gameplay/ShipModTemplate.cs.meta similarity index 83% rename from Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/Collections.cs.meta rename to Assets/Scripts/Gameplay/ShipModTemplate.cs.meta index 323e260ef..8effd0b5a 100644 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull/Collections.cs.meta +++ b/Assets/Scripts/Gameplay/ShipModTemplate.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 3969283304824fe4a8eca1eb522fd3a7 +guid: 7c3e9a52d8b14f0e8a6d2b1f4c95e703 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Scripts/Gameplay/ShipModVisual.cs b/Assets/Scripts/Gameplay/ShipModVisual.cs new file mode 100644 index 000000000..c75bc577d --- /dev/null +++ b/Assets/Scripts/Gameplay/ShipModVisual.cs @@ -0,0 +1,113 @@ +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(ShipModCatalog.Package package, Transform parent) + { + var ship = package.Visual; + + 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/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; + } } 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/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 86e76163a..f265151c9 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), typeof(FieldKindData) }; + public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData) }; 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/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/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/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/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index fba59dd8e..853bea91e 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -207,7 +207,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(); @@ -866,8 +868,10 @@ 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(); Mass = hullData.Mass; Temperature = new float[hullData.Shape.Width, hullData.Shape.Height]; NewTemperature = new float[hullData.Shape.Width, hullData.Shape.Height]; @@ -1050,12 +1054,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) { - // If the given coordinate isn't even in the ship it obviously won't fit - if (!hullData.Shape[hullCoord]) return false; - + hardpoint = null; // Items without specific hardpoints on the ship can be freely rotated and placed anywhere if (itemData.HardpointType == HardpointType.Tool) { @@ -1072,40 +1075,34 @@ public abstract class Entity } else { - var hardpoint = Hardpoints[hullCoord.x, hullCoord.y]; - - // If there's no hardpoint there, it won't fit + hardpoint = HardpointAt(itemData, hullData, hullCoord); 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 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) - 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; } + // 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) { @@ -1114,7 +1111,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) @@ -1134,7 +1131,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; @@ -1146,14 +1143,21 @@ public abstract class Entity // Search the ship for an empty hardpoint that matches the type and shape of the item else { + // 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) { - foreach (var hardpointCoord in hardpoint.Shape.Coordinates) + 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 + hardpointCoord; - if (ItemFits(itemData, hullData, item, hullCoord2)) + var hullCoord2 = hardpoint.Position + int2(x, y); + if (ItemFits(itemData, hullData, item, hullCoord2, out _)) { hullCoord = hullCoord2; return true; @@ -1179,7 +1183,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) @@ -1199,13 +1203,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); } @@ -1270,8 +1274,7 @@ public abstract class Entity bay.DockedShip = null; ship.RemoveParent(); - Zone.Entities.Add(ship); - ship.Activate(); + Zone.Admit(ship, piloted: false); return true; } @@ -1634,6 +1637,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; } @@ -1730,11 +1737,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; @@ -1983,7 +1991,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/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/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 6f413d05c..04e960d33 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; } diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 730daadf0..a5660f52e 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; @@ -29,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; @@ -105,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; @@ -139,6 +143,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, @@ -155,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(); @@ -177,11 +187,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 +196,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 +211,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 +227,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 +293,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 +303,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 +321,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; } } @@ -332,12 +338,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 @@ -359,22 +366,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/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/NIH/MIConvexHull/Constants.cs.meta b/Assets/Scripts/ServerShared/GalaxyStage.cs.meta similarity index 83% rename from Assets/Scripts/ServerShared/NIH/MIConvexHull/Constants.cs.meta rename to Assets/Scripts/ServerShared/GalaxyStage.cs.meta index 3c5287e11..9d23d3f4a 100644 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Constants.cs.meta +++ b/Assets/Scripts/ServerShared/GalaxyStage.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: ee065f025d2f5374db2ceaa973b822d4 +guid: f5cf458f47074786bf11e72b5d93c545 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 59d93c1ba..40ec27532 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) { @@ -530,6 +531,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. + [Inspectable, JsonProperty("visual"), Key(32)] + public CultRecordRef Visual; + [IgnoreMember] public Shape InteriorCells { @@ -567,6 +573,20 @@ public class HardpointData return $"{Enum.GetName(typeof(HardpointType), Type)} Hardpoint {Rotation.Arrow()}"; } + // 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 _); + + 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/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index d8b868cf5..0c445a100 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, @@ -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); @@ -86,36 +89,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 +124,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 +154,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/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 11e277f7f..2fb2a69f9 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) { @@ -123,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(); @@ -147,12 +167,22 @@ 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; 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. 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 @@ -174,10 +204,11 @@ 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); + // 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); } private void OutfitEntity(Entity entity) @@ -209,7 +240,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/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) { 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/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/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/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/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/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/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/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs new file mode 100644 index 000000000..4397f4575 --- /dev/null +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -0,0 +1,65 @@ +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 +// 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; // 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."); + } + + 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, + // unless the whole scenario staged. + 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; + failures.AddRange(stage.Failures); + if (!stage.PlayerPlaced) + failures.Add("player: the scenario places no player"); + if (failures.Count > reported) return null; + + var player = stage.PlayerShip; + player.IsPlayerShip = true; + arena.Admit(player, piloted: false); + foreach (var placed in stage.Placed) + { + var hostile = placed.Stance == ScenarioStance.Hostile; + placed.Entity.SetIff(player, hostile); + player.SetIff(placed.Entity, hostile); + arena.Admit(placed.Entity, placed.Piloted); + } + 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/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/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index 41cd613ac..87bbeda60 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 }; @@ -91,6 +91,39 @@ 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 designs = new List(); + foreach (var zoneRef in game.Zones ?? Array.Empty>()) + { + 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(); + 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."); + } + + 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(); @@ -122,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/ServerShared/Scenario.cs b/Assets/Scripts/ServerShared/Scenario.cs new file mode 100644 index 000000000..acd2f10b2 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenario.cs @@ -0,0 +1,32 @@ +// 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 +{ + public abstract string Name { get; } + + // The conditions and what to verify in them, shown under the scenario's button. + 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, 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 + // 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 +{ + 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/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs new file mode 100644 index 000000000..feac93e66 --- /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, 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); + 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/NIH/MIConvexHull/Cell2.cs.meta b/Assets/Scripts/ServerShared/ScenarioStage.cs.meta similarity index 83% rename from Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2.cs.meta rename to Assets/Scripts/ServerShared/ScenarioStage.cs.meta index 52993549b..91b73afb6 100644 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/Cell2.cs.meta +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: adf902e96e0c51049b65bc002d6ef4ca +guid: d8cd00dc23604dfb91e128e1a9a4a3d5 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Scripts/ServerShared/NIH/MIConvexHull.meta b/Assets/Scripts/ServerShared/Scenarios.meta similarity index 77% rename from Assets/Scripts/ServerShared/NIH/MIConvexHull.meta rename to Assets/Scripts/ServerShared/Scenarios.meta index 5f927b59d..d6472c75c 100644 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull.meta +++ b/Assets/Scripts/ServerShared/Scenarios.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 4a109a0b0bb6e8f40935ca2ec55e5bd7 +guid: 35866d82219241ae9fdb0030039afbed folderAsset: yes DefaultImporter: externalObjects: {} 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/NIH/MIConvexHull/ConvexHull.cs.meta b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs.meta similarity index 83% rename from Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.cs.meta rename to Assets/Scripts/ServerShared/Scenarios/Arcs.cs.meta index 0be10d4af..70bff8250 100644 --- a/Assets/Scripts/ServerShared/NIH/MIConvexHull/ConvexHull.cs.meta +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 447289e858e19c14abbe91dfbd0e3089 +guid: 724a07b881f34e8c9b32b3f2f338ac78 MonoImporter: externalObjects: {} serializedVersion: 2 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/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs new file mode 100644 index 000000000..ec5f2a3c2 --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; + +// 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 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(); +} + +[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-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; + // 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 +{ + // ShipAnchor.Role's vocabulary. + private static readonly HashSet Roles = new HashSet(StringComparer.Ordinal) + { + "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(); + try + { + cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, !writable), new[] { typeof(HullData), typeof(ShipAuthoring) }); + return cache; + } + catch + { + cache.Dispose(); + throw; + } + } + + // Writes one ship's two records under their deterministic keys, the shape Load expects. + public static void Write(CultCache cache, HullData hull, ShipAuthoring visual) + { + cache.UpsertAsync(typeof(HullData), hull, ShipModCatalog.HullKey(visual.Id)).GetAwaiter().GetResult(); + 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) + { + 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(); + 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); + } + + // 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) + { + 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."); + 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."); + 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) || + 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); + 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 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."); + + 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.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)) + 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++) + 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."); + } + // 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); + 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) + { + 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()) + { + 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) || 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/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs new file mode 100644 index 000000000..38216f481 --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -0,0 +1,219 @@ +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 sealed class Package + { + public HullData Hull; + public ShipAuthoring Visual; + 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); + + // 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); + 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 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; + 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)); + // 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); + Composition composition; + using (var cache = AetheriaStores.Open(temporary, catalogWritable: true)) + { + // 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); + 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 composition; + } + finally + { + if (Directory.Exists(workspace)) Directory.Delete(workspace, true); + } + } + + public static Package ReadPackage(string shipPath) + { + var path = Path.GetFullPath(shipPath); + 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 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, Array.Empty()); + 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); + } + + 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)); + // 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) + 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.ToDictionary(node => node.Key, node => node.Value.Index, StringComparer.Ordinal) + }; + } + + // 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); + 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 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, node["mesh"] != null && node["mesh"].Type != JTokenType.Null))) + throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}."); + index++; + } + 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/Assets/Scripts/ServerShared/ShipModPlan.cs b/Assets/Scripts/ServerShared/ShipModPlan.cs new file mode 100644 index 000000000..bb80647a1 --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipModPlan.cs @@ -0,0 +1,42 @@ +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; + // Mount ids whose anchor (role thruster-emitter / radiator-mesh) is the emitter / mesh node itself. + public string[] Thrusters; + public string[] Radiators; + // Each weapon mount (role weapon-mount) and its muzzle anchors, by Order then id. + public (string Mount, string[] Muzzles)[] Weapons; + + // 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(); + string Only(string wanted) => visual.Anchors.Single(anchor => anchor.Role == wanted).Id; + 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(); + + return new ShipModPlan + { + MapIcon = Only("map-icon"), + HullCollider = Only("hull-collider"), + Shield = Only("shield"), + Tractor = Only("tractor"), + Thrusters = Mounts(HardpointType.Thruster), + Radiators = Mounts(HardpointType.Radiator), + 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() + }; + } +} 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 diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 863061eec..d3973d92e 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -94,7 +94,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(); })); @@ -142,19 +142,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); diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index e3b7ae340..2ff4fe005 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; @@ -206,7 +209,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; @@ -309,7 +312,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++) @@ -331,9 +334,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/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 8ace9b3c3..f20208ff7 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -5,22 +5,18 @@ 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 { public VolumeCloudRenderer CloudRenderer; public GameSettings Settings; public ConfirmationDialog Dialog; - public bool InGame; public Prototype PanelPrototype; public float FadeTime = .5f; public float FadeDistance = 512; @@ -89,90 +85,38 @@ public class MainMenu : MonoBehaviour _fadeFromRight = fromRight; } + private static bool _exclusionsShown; + private void ShowMain() { _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) + { + _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"); _nextMenu.panel.AddButton("Continue", saved == null ? (Action) null : () => { - ActionGameManager.IsTutorial = saved.IsTutorial; + try { RunSave.RequireDesigns(cache, saved); } + catch (InvalidOperationException error) { Refuse("Cannot continue this run", error.Message); return; } ActionGameManager.CurrentGalaxy = new Galaxy(cache, saved, Debug.Log); - SceneManager.LoadScene("ARPG"); + EnterGame(); }); } _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(); - - 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; - SceneManager.LoadScene("ARPG"); - }); - }).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted); - } - else - { - 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); - - ActionGameManager.IsTutorial = true; - 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; - SceneManager.LoadScene("ARPG"); - }); - }).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted); - } }); _nextMenu.panel.AddButton("Settings", () => @@ -183,6 +127,102 @@ 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: 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) + { + 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(() => RunStart.Generate(scenario, stage)).ContinueWith(generation => + Observable.NextFrame().Subscribe(_ => + { + 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(); + })); + } + + // 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()); + + 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"); + } + + public 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/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 3b1643dab..c4fe6737a 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/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index 13164eac3..b6021027a 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -293,10 +293,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!"); @@ -305,7 +308,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/GameData/Aetheria.cc b/GameData/Aetheria.cc index 39076ca16..20377e0db 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b698e224f18306f398b8509b7234127c0343e76925eebf39eff2674c8510195b -size 12699332 +oid sha256:70a7b0a9f5a59bf64a0bff80f5d266083910e88cc141745b5082424e3ae30b79 +size 12701773 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 new file mode 100644 index 000000000..650286d75 --- /dev/null +++ b/docs/moddable-ship-authoring.md @@ -0,0 +1,253 @@ +# 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 + +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, +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 the +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. +Create a draft with `dotnet run --project tools/AetherDb -- ship-authoring +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 +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` 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), +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. +**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, and judges `ship.glb` once it exists. + +## Objective + +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. + +## 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 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 + 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. + +`ship-authoring validate`, the catalog composer, and the visual importer call +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 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 +and color are retained; radius and material-specific stroke styling are not +rendered yet. The game reads mod records through the boot path described +under Runtime catalog seam. + +## 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` (`mod-hull:`) and its runtime authoring record +(`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. + +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 +``` + +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 +a data-path smoke, not a playable Quiet design. + +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. `ShipModShips` preloads one assembled, inactive prototype per hull +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 +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 + +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. **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 + 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. +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, +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, `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. + +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/docs/moddable-ships-cut.md b/docs/moddable-ships-cut.md new file mode 100644 index 000000000..70b59e696 --- /dev/null +++ b/docs/moddable-ships-cut.md @@ -0,0 +1,516 @@ +# 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. + +--- + +## 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. + +- **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) + +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 `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**. 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`), + `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 | +|---|---|---| +| 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. + +**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 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 + +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. diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md index d7354ecbc..25102e378 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,151 @@ 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. +- **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. + **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. + **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. + **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. + - 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"). + - 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. + - 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. + - **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. + - **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. + - **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 + 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"**. + 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. **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. + - **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). + Respawn is `Zone.ChunkWear.BrokenUntil`. **Merge rule:** when scenarios and mining meet, mining's side wins + 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 + 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 + 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" + 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. + +--- + ## 0. Target ### 0.1 The request, in the operator's words (2026-09-30) @@ -226,7 +371,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 +436,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 +467,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 +476,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 +574,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 +623,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. --- @@ -672,3 +826,51 @@ 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. + +**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. + +**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. + +**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. diff --git a/docs/ship-collection-authoring.md b/docs/ship-collection-authoring.md index b4e85f7a1..198a083dc 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 @@ -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/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"); + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 5bf529d46..92b26bbce 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() { @@ -2600,15 +2601,12 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Null(w.Fuse); } - // Mining Cut 3: a catalog written since slot 32 existed (AetherDb field-kinds rewrote every record) stores - // the slot, so its weapons load exactly. A catalog that predates it may drift by slot 32 and nothing else. var reports = store.LastSchemaMigrationReports.Where(r => r.LocalSchemaName == "aetheria.weaponitemdata").ToList(); Assert.NotEmpty(reports); foreach (var report in reports) { - if (report.Kind == CultSchemaMigrationKind.Exact) continue; - 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/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs new file mode 100644 index 000000000..12daaee32 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -0,0 +1,291 @@ +/* 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, and once it is, the search finds no space. + [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}"); + + 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, + // 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(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())}"); + } + + // 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"); + } +} diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 48cd75cbd..0807dd234 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() @@ -264,13 +287,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 +344,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 +946,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); diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 0bde21341..328009b24 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)); } @@ -284,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); @@ -319,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."); } @@ -338,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++) { @@ -347,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); @@ -371,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."); } @@ -479,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/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 86ca9a77e..c8630cdfd 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)); } @@ -126,6 +126,62 @@ 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, () => + { + WriteAnOrbitTheSavedRunLacks(cache); + return null; + })); + Assert.Equal(saved, RunKeys(cache)); + } + using (var cache = Open()) + { + Assert.Equal(saved, RunKeys(cache)); + Assert.NotNull(cache.GetGlobal()); + } + + using (var cache = Open()) + { + Assert.Throws(() => RunSave.Replace(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)); + + 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. @@ -284,6 +340,220 @@ 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)); + } + + // --- 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 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(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 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) + { + 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 => + { + 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); + }); + + 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] + 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, @@ -318,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) { diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs new file mode 100644 index 000000000..755dd91bb --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -0,0 +1,701 @@ +/* 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; + +// 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; + + 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; + private readonly TutorialGenerationSettings _tutorialSettings; + + 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 = _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); + _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."); + } + + // 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); + + // 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(); + + // 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); + + // 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, and a reactor to power it. + [Fact] + public void EveryGeneratedStationCarriesOneHeaterAndAReactor() + { + var zone = Arena(true); + 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"); + Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"station {i} has no reactor"); + } + } + + 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 MainSectorGalaxy() + { + 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 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).Product)); + + 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 (LoadoutGenerator.IsAvailable, the one reach rule). + [Fact] + public void EveryFactionsStationsCarryOnlyGearItCanReachIncludingAHeaterAndAReactor() + { + var galaxy = MainSectorGalaxy(); + Assert.False(galaxy.IsPrelude); + var zone = Arena(true); + 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 => 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 " + Maker(u.product)))}"); + } + } + } + + // 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 = MainSectorGalaxy(); + 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. + [Fact] + public void AStationWithoutZhestokostAccessIsPoweredAndStaysAboveFreezing() + { + 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); + 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); + // 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(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.) + [Fact] + public void AnIdleStationStaysAboveFreezing() + { + 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; + 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)"); + } + + // 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(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(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] + 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); + } + + // 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(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()}"); + } + + private LoadoutGenerator PreludeGenerator() => + new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); + + 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 = "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(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 + // 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)}"); + } + + // 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); + } + } + + // 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(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(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"); + } + + // 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, BareLoadout(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, new Scripted(true, _ => { })); + 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, 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); + } +} 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)}]"); + } } diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs new file mode 100644 index 000000000..ddb75aa8e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -0,0 +1,568 @@ +/* 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.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': 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. 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); + } + + 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 }); + } + + // 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}:[{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}"; + + // 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))}]"; + + // 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() + { + 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, 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)); + Assert.Equal(Fits(first.staged), Fits(second.staged)); + } + + 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)); + } + + // 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)); + } + + // 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() + { + 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(true); + var (prelude, _) = RunSave.Capture(_cache, _galaxy, arena, null, new SavedActionBarBinding[0]); + Assert.True(prelude.IsTutorial); + + var (main, _) = RunSave.Capture(_cache, MainSectorGalaxy(), arena, null, new SavedActionBarBinding[0]); + Assert.False(main.IsTutorial); + } + + // 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() + { + 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, 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); + 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)); + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs new file mode 100644 index 000000000..f969ff187 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -0,0 +1,467 @@ +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; + +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(); + ship.Validate(); + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual); + await cache.FlushAsync(); + } + 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); } + } + + [Fact] + public void InvalidSchematicOrDanglingMountCannotBePublished() + { + var ship = Fixture(); + ship.Hull.Shape.Cells[1, 0] = false; + Assert.Contains("outside the hull schematic", Assert.Throws(ship.Validate).Message); + + ship = Fixture(); + ship.Visual.Anchors.RemoveAt(ship.Visual.Anchors.Count - 1); + Assert.Contains("has no model anchor", Assert.Throws(ship.Validate).Message); + + ship = Fixture(); + 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 + { + Type = HardpointType.Thruster, + Position = new int2(1, 0), + Shape = new Shape(), + Transform = "thruster.starboard" + }); + 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); + } + + // 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("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; + 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); + + // 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() + { + 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); + } + + 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(); + 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; + } + + 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 MuzzleRulesBelongToTheValidator() + { + ShipParts Armed() + { + 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)); + + // 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); + Assert.Equal("gun", mount); + Assert.Equal(new[] { "gun.z", "gun.a", "gun.b" }, muzzles); + } + + internal static ShipParts Fixture() + { + var shape = new Shape(2, 2); + shape.Cells[0, 0] = true; + shape.Cells[1, 0] = true; + return new ShipParts + { + Hull = new HullData + { + Name = "Skiff", + Shape = shape, + Visual = new CultRecordRef(ShipModCatalog.AuthoringKey("mod.skiff")), + Hardpoints = new List + { + new HardpointData + { + Type = HardpointType.Thruster, + Position = new int2(1, 0), + Shape = new Shape(), + Transform = "thruster.port" + } + } + }, + Visual = new ShipAuthoring + { + Id = "mod.skiff", + ModelAsset = "skiff.glb", + 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 } + } + } + } + }; + } + + // 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. +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..9f2e1c75f --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipFixture.cs @@ -0,0 +1,88 @@ +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 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", "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. + // 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) + ? @",""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, 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); + 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((uint)binPadded.Length).CopyTo(bytes, 20 + padded.Length); + BitConverter.GetBytes(0x004E4942u).CopyTo(bytes, 24 + padded.Length); + binPadded.CopyTo(bytes, 28 + 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, 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. + 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.muzzle", Role = "weapon-muzzle", ModelNodeId = "gun-muzzle", ParentId = "gun" }); + 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, meshNodes), MeshBytes())); + return directory; + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs new file mode 100644 index 000000000..a2426906b --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -0,0 +1,613 @@ +using System; +using System.Collections.Generic; +using System.IO; +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. +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 = ShipFixture.Nodes; + + 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).Included.Length); + + 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(); + } + + // 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 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() + { + 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).Included.Length); + + 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 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() + { + WritePackage("mod.skiff"); + Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length); + WritePackage("mod.barge", hullName: "Barge"); + + 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 ABadPackageIsExcludedAndNamedWhileTheGoodOnesCompose() + { + 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"); + + 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(); + } + + // 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 APackageWhoseKeyAlreadyExistsInTheCatalogIsExcluded(string key, bool authoring) + { + WritePackage("mod.skiff"); + WritePackage("mod.other", hullName: "Other"); + using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) + { + if (authoring) + { + var stranger = ShipAuthoringTests.Fixture().WithId("stranger").Visual; + 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(); + } + + 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 APackageWithAHullNamedLikeAShippedItemIsExcluded(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"); + + 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 APackageWhoseShipIdAShippedAuthoringRecordAlreadyUsesIsExcluded() + { + using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) + { + cache.UpsertAsync(typeof(ShipAuthoring), ShipAuthoringTests.Fixture().Visual, new CultRecordKey("shipped:skiff")).GetAwaiter().GetResult(); + cache.FlushAsync().Wait(); + } + WritePackage("mod.skiff", hullName: "Different"); + + 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"); + 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.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, ["gun"] = 5, ["gun-muzzle"] = 6 }, 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 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() + { + 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); + } + + // 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"":""""}},{""mesh"":0,""extras"":{""aetheria.id"":""b""}}]}")); + Assert.Equal(new Dictionary { ["a"] = (1, false), ["b"] = (4, true) }, ShipModCatalog.ReadNodeIds(path)); + } + + [Fact] + public void AGlbWithoutNodesHasNoIds() => + 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", ShipFixture.Glb(@"{""nodes"":[{""extras"":{""aetheria.id"":""a""}},{""extras"":{""aetheria.id"":""a""}}]}")))).Message); + + [Fact] + public void AMalformedGlbHeaderIsRefused() + { + var good = ShipFixture.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("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"); + } + + [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 }, 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, + // 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")).Catalog); + Directory.CreateDirectory(Path.Combine(Mods, "notes")); + 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).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).Catalog); + WritePackage("mod.barge", hullName: "Barge"); + 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)); + } + + // 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() + { + 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); + + // 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); + 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(); + 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 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", + 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) + { + 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/ShipModPlanTests.cs b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs new file mode 100644 index 000000000..6df76dc6b --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs @@ -0,0 +1,74 @@ +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. The muzzles' Order runs against their ids 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 = "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" }); + 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" }, plan.Thrusters); + Assert.Equal(new[] { "fin" }, plan.Radiators); + var (mount, muzzles) = Assert.Single(plan.Weapons); + Assert.Equal("gun", mount); + Assert.Equal(new[] { "gun.z", "gun.a" }, muzzles); + } + + // 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); + } + + [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); + } + + [Fact] + public void ABodyThatFailsTheSharedValidatorIsNeverPlanned() + { + var ship = Rig(); + ship.Hull.Prefab = "Djinni"; + Assert.Contains("names both a Unity prefab and a visual record", Refusal(ship)); + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs new file mode 100644 index 000000000..e674ed23a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +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 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 +{ + 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))] + 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 PythonSchemaNamesAreTheDocumentsTheyNameAndTheRetiredSlotIsNotReused() + { + 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() + { + 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 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() + { + Assert.Equal(EnumNames(), Strings("HARDPOINT_TYPE_NAMES")); + Assert.Equal(EnumNames(), Strings("ROTATION_NAMES")); + } + + [Fact] + public void SerializedShipHasTheNestedArrayShapesPythonIndexes() + { + 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 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(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(hullBody[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))); + } + + 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 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); + 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/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs new file mode 100644 index 000000000..c67f3e6d3 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -0,0 +1,255 @@ +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() => ShipAuthoringTests.Fixture().Validate(); + + [Theory, MemberData(nameof(CaseNames))] + public void ValidatorRefusesEachBreakageByItsOwnGuard(string name) + { + var ship = ShipAuthoringTests.Fixture(); + Cases[name].Break(ship); + var error = Assert.Throws(ship.Validate); + 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.Visual.Anchors.RemoveAll(anchor => anchor.Id == "thruster.port"); + 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); + + [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.WithId(id).Validate(); + } + + [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.WithId(id); + Assert.Contains("ship ID must use", Assert.Throws(ship.Validate).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.Visual.ModelAsset = asset; + ship.Validate(); + } + + [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.Visual.ModelAsset = asset; + Assert.Contains("relative package path", Assert.Throws(ship.Validate).Message); + } + + [Fact] + public void RootedModelAssetsAreRefused() + { + var ship = ShipAuthoringTests.Fixture(); + 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.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 }; + ship.Validate(); + line.Radii = null; + line.Opacities = null; + line.Color = null; + ship.Validate(); + ship.Visual.SchematicLines = null; + ship.Hull.Hardpoints = null; + ship.Visual.Anchors.RemoveAt(ship.Visual.Anchors.Count - 1); + ship.Validate(); + } + + [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; + ship.Validate(); + } + + [Fact] + public void AnEmptyParentIdMeansNoParent() + { + var ship = ShipAuthoringTests.Fixture(); + ship.Visual.Anchors[0].ParentId = ""; + ship.Validate(); + } + + [Fact] + public void AnAnchorMayNameItsHardpointAsParent() + { + var ship = ShipAuthoringTests.Fixture(); + 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(); + } + + [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)) + { + 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() + { + 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.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"); + 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-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"); + + 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.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("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"); + 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-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"); + 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.Visual.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" }); + }, "overlaps another hardpoint"); + 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.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; + } +} + +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); +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 3a0495d7b..c65f4c3f2 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,9 +36,10 @@ 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()); case "field-kinds": return FieldKindsCatalog(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], field-kinds [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|compose, field-kinds [apply]"); return 1; } } @@ -467,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" + diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs new file mode 100644 index 000000000..dc5dcfb8b --- /dev/null +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -0,0 +1,89 @@ +using GameCult.Caching; +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 +{ + private const string Usage = "ship-authoring create [--like ] | inspect | validate | compose "; + + public static int Run(string[] args) + { + if (args.Length == 0) + { + Console.Error.WriteLine(Usage); + return 1; + } + try + { + if (args[0] == "compose" && args.Length == 4) + { + 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) + { + 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) + { + // 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 || 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 = 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); + cache.FlushAsync().Wait(); + } + Console.WriteLine($"Created draft {path}; fill its model bindings and schematic in Blender."); + return 0; + } + Console.Error.WriteLine(Usage); + return 1; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + 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; + } +} 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 diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py new file mode 100644 index 000000000..bcf9f6f7f --- /dev/null +++ b/tools/blender/aetheria_ships/__init__.py @@ -0,0 +1,502 @@ +"""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": "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, 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)) +ROTATIONS = tuple((str(i), name, name) for i, name in enumerate(ROTATION_NAMES)) + + +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: + fields = decode_hardpoint(raw) + hp = state.hardpoints.add() + 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: + 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 = [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, + [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): + 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 + + +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) + 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, 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"] = stored + self.report({"INFO"}, f"Bound {collection.name} to {ship_id}") + 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" + 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 + 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) + 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, + 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_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" + 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.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") + + layout.separator() + 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) + 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") + 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_OT_package_ship, AETHERIA_AP_preferences, 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" + ) + 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", + ) + + +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 + del bpy.types.Scene.aetheria_package_report + 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..f16fe72a5 --- /dev/null +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -0,0 +1,234 @@ +"""Aetheria's Blender-side view of one ship file: a typed HullData record and the ShipAuthoring visual it names. + +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 + +import sys +import hashlib +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +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). 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") +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): + 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 + + +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] + 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 = [] + 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 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: + file = read(path, cultlib_packages) + if not lines: + raise ValueError("No Grease Pencil strokes were captured; the ship record was not changed") + file.ship.body[SCHEMATIC_LINES_SLOT] = lines + file.store.push(_stamped(file.ship, file.msgpack)) + 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") + 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): + 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: + file = read(path, cultlib_packages) + if file.ship.body[0] != expected_id: + raise ValueError("The bound ship ID changed; reload its layout") + 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): + 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) + 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: + 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 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") + updated_hardpoints.append(hardpoint + previous.get(mount, [])) + hull[HULL_SHAPE_SLOT] = [shape] + hull[HULL_HARDPOINTS_SLOT] = updated_hardpoints + 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]]: + 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 diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py new file mode 100644 index 000000000..de5958ad1 --- /dev/null +++ b/tools/blender/tests/smoke_package.py @@ -0,0 +1,201 @@ +"""Headless Blender smoke for the Package Ship action, end to end. + + 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'. +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. + +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 +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 + + +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: + 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 + bind_cases(ship, path, mods) + + 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) diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py new file mode 100644 index 000000000..d0f80150a --- /dev/null +++ b/tools/blender/tests/test_ship_cc.py @@ -0,0 +1,595 @@ +"""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: + + 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 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 + +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_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])] + 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[2] = "skiff.glb" + body[3] = [["map", "map-icon", "map", None, 0]] + body[ship_cc.SCHEMATIC_LINES_SLOT] = LINES + return body + + +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): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.path = str(Path(directory.name) / "ship.cc") + self.write(ship_body(), hull_body()) + + 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 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_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_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_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(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_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): + 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) + + +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.ship() + self.assertEqual(1, ship_cc.replace_lines(self.path, PACKAGES, new_lines)) + 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_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) + 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 = 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) + + 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 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) + + 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() + 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]) + 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): + _, _, 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=()) + 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() + ship_before, hull_before = self.ship(), self.hull() + ship_envelope = self.envelopes()[(ship_cc.SCHEMA, "mod-ship:mod.skiff")] + self.edit(revision) + 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(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() + 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 = 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() + 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_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() + 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_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) + 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()