diff --git a/Assets/Content/Sprites.meta b/Assets/Content/Sprites.meta new file mode 100644 index 000000000..b24da7940 --- /dev/null +++ b/Assets/Content/Sprites.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 30e828fca27d36b449d7bc337d6a20d5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Editor/CultCacheDrawers.cs b/Assets/Scripts/Editor/CultCacheDrawers.cs index 75fe59ccd..c446212db 100644 --- a/Assets/Scripts/Editor/CultCacheDrawers.cs +++ b/Assets/Scripts/Editor/CultCacheDrawers.cs @@ -101,7 +101,7 @@ public sealed class InspectableSchematicShapeDrawer : ICultInspectorDrawer var shape = value as Shape ?? new Shape(); var item = inspector.Record as EquippableItemData; var hull = item as HullData; - var schematic = string.IsNullOrEmpty(item?.Schematic) ? null : AssetDatabase.LoadAssetAtPath(item.Schematic); + var schematic = string.IsNullOrEmpty(item?.Schematic) ? null : AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(item.Schematic)); EditorGUILayout.LabelField(label); using (new EditorGUILayout.HorizontalScope()) diff --git a/Assets/Scripts/Editor/EngineAssetCheck.cs b/Assets/Scripts/Editor/EngineAssetCheck.cs new file mode 100644 index 000000000..59eca26c8 --- /dev/null +++ b/Assets/Scripts/Editor/EngineAssetCheck.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using MessagePack; +using UnityEditor; +using UnityEditor.AddressableAssets; +using UnityEngine; +using Object = UnityEngine.Object; + +// docs/addressables-cut.md §2.5: validates every [CultInspectorAssetGuid] string member stored in +// the catalog. Read-only: it opens the catalog through AetheriaStores.Open exactly like the game +// does, and never writes. No repair path — a failure names the record, member, GUID and path. +public static class EngineAssetCheck +{ + // One row per attributed member (docs/addressables-cut.md §2.5 point 4). A member found on a + // stored document but missing here fails the check, so a new field can't skip validation. + private delegate bool Contract(object owner, Type ownerType, Object asset, out string reason); + + private static readonly Dictionary ConsumerContracts = new Dictionary + { + ["Icon"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract(asset, out reason), + ["ActionBarIcon"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract(asset, out reason), + ["Schematic"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract(asset, out reason), + ["Logo"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract(asset, out reason), + ["Prefab"] = (object owner, Type ownerType, Object asset, out string reason) => ComponentContract(asset as GameObject, out reason), + ["ParticlesPrefab"] = (object owner, Type ownerType, Object asset, out string reason) => ComponentContract(asset as GameObject, out reason), + ["Particles"] = (object owner, Type ownerType, Object asset, out string reason) => ComponentContract(asset as GameObject, out reason), + ["EffectPrefab"] = EffectPrefabContract, + }; + + // Menu entry for interactive use; -executeMethod EngineAssetCheck.Run for batchmode/CI, exit 1 on failure. + [MenuItem("Aetheria/Validate Engine Asset References")] + public static void Run() + { + var failures = RunCheck(out var checkedCount); + foreach (var failure in failures) Debug.LogError(failure); + Debug.Log(failures.Count == 0 + ? $"EngineAssetCheck: {checkedCount} stored reference(s) OK." + : $"EngineAssetCheck: {failures.Count} failure(s) of {checkedCount} checked."); + + if (Application.isBatchMode) + EditorApplication.Exit(failures.Count == 0 ? 0 : 1); + } + + // Returns one failure line per bad stored reference; empty when everything resolves. Exposed for the + // mutation tests (tests/mutation_tests.py) driving this same method through a batchmode -executeMethod. + public static List RunCheck(out int checkedCount) + { + var failures = new List(); + checkedCount = 0; + + var root = Directory.GetParent(Application.dataPath)!.FullName; + var catalogPath = Path.Combine(root, "GameData", "Aetheria.cc"); + var cache = AetheriaStores.Open(catalogPath); + try + { + foreach (var stored in cache.AllStoredDocuments.ToArray()) + { + var docType = stored.Descriptor.DocumentType; + var document = stored.Document; + var name = NameOf(document, docType); + var seen = new HashSet(); + + foreach (var (owner, field) in WalkAssetGuidFields(document, seen)) + { + var key = (string)field.GetValue(owner); + if (string.IsNullOrEmpty(key)) continue; + checkedCount++; + + var label = $"{docType.Name} \"{name}\" .{field.Name} = \"{key}\""; + + if (!CultAssetGuidKey.TryParse(key, out var guid, out var subAssetName)) + { + failures.Add($"{label}: not a valid Addressables key (guid or guid[subAsset])"); + continue; + } + if (guid.Length != 32 || !guid.All(IsLowerHex)) + { + failures.Add($"{label}: GUID \"{guid}\" is not 32 lowercase hex characters"); + continue; + } + + var path = AssetDatabase.GUIDToAssetPath(guid); + if (string.IsNullOrEmpty(path) || !File.Exists(Path.Combine(root, path))) + { + failures.Add($"{label}: GUID {guid} names no existing file"); + continue; + } + + var entry = AddressableAssetSettingsDefaultObject.Settings?.FindAssetEntry(guid, true); + if (entry == null) + { + failures.Add($"{label}: {path} ({guid}) is not addressable"); + continue; + } + + if (!ConsumerContracts.TryGetValue(field.Name, out var contract)) + { + failures.Add($"{label}: no consumer contract registered for member \"{field.Name}\"; add one to EngineAssetCheck.ConsumerContracts"); + continue; + } + + Object asset = string.IsNullOrEmpty(subAssetName) + ? AssetDatabase.LoadAssetAtPath(path) + : AssetDatabase.LoadAllAssetsAtPath(path).FirstOrDefault(a => a.name == subAssetName); + if (asset == null) + { + failures.Add($"{label}: {(string.IsNullOrEmpty(subAssetName) ? path : path + "[" + subAssetName + "]")} did not load"); + continue; + } + + if (!contract(owner, owner.GetType(), asset, out var reason)) + failures.Add($"{label}: {reason}"); + } + } + + return failures; + } + finally + { + cache.Dispose(); + } + } + + private static bool TypeContract(Object asset, out string reason) where T : Object + { + if (!(asset is T)) { reason = $"asset is a {asset.GetType().Name}, not a {typeof(T).Name}"; return false; } + reason = null; + return true; + } + + private static bool ComponentContract(GameObject gameObject, out string reason) where T : Component + { + if (gameObject == null) { reason = "asset is not a GameObject"; return false; } + if (gameObject.GetComponent() == null) { reason = $"GameObject has no {typeof(T).Name}"; return false; } + reason = null; + return true; + } + + private static bool EffectPrefabContract(object owner, Type ownerType, Object asset, out string reason) + { + var gameObject = asset as GameObject; + if (typeof(InstantWeaponData).IsAssignableFrom(ownerType)) return ComponentContract(gameObject, out reason); + if (typeof(ConstantWeaponData).IsAssignableFrom(ownerType)) return ComponentContract(gameObject, out reason); + reason = $"unrecognised WeaponData subtype {ownerType.Name} for EffectPrefab"; + return false; + } + + private static bool IsLowerHex(char c) => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + + private static string NameOf(object document, Type docType) + { + var nameField = docType.GetField("Name", BindingFlags.Public | BindingFlags.Instance); + return nameField?.GetValue(document) as string ?? "(unnamed)"; + } + + // Same shape as AetherDb's DanglingRefs / the migration's WalkAssetPathFields: plain fields, list + // elements, and nested [MessagePackObject]/[Union] values (a HullData's Behaviors list, say). + private static IEnumerable<(object Owner, FieldInfo Field)> WalkAssetGuidFields(object value, HashSet seen) + { + if (value == null || !seen.Add(value)) yield break; + foreach (var field in value.GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (field.FieldType == typeof(string)) + { + if (field.GetCustomAttribute() != null) + yield return (value, field); + continue; + } + + var member = field.GetValue(value); + if (member == null) continue; + + if (member is IDictionary dictionary) + { + foreach (var key in dictionary.Keys) + { + foreach (var nested in WalkAssetGuidFields(key, seen)) yield return nested; + foreach (var nested in WalkAssetGuidFields(dictionary[key], seen)) yield return nested; + } + } + else if (member is IList list && !(member is Array { Rank: > 1 })) + { + foreach (var element in list) + if (element != null && IsSerializedObject(element.GetType())) + foreach (var nested in WalkAssetGuidFields(element, seen)) yield return nested; + } + else if (IsSerializedObject(member.GetType())) + { + foreach (var nested in WalkAssetGuidFields(member, seen)) yield return nested; + } + } + } + + private static bool IsSerializedObject(Type type) + { + for (var t = type; t != null && t != typeof(object); t = t.BaseType) + if (t.IsDefined(typeof(MessagePackObjectAttribute), false) || t.IsDefined(typeof(UnionAttribute), false)) + return true; + return false; + } +} diff --git a/Assets/Scripts/Editor/EngineAssetCheck.cs.meta b/Assets/Scripts/Editor/EngineAssetCheck.cs.meta new file mode 100644 index 000000000..8445541bc --- /dev/null +++ b/Assets/Scripts/Editor/EngineAssetCheck.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 66eb9b3ae90c36b4e920ed38d0dc0036 \ No newline at end of file diff --git a/Assets/Scripts/EngineAssets.cs b/Assets/Scripts/EngineAssets.cs new file mode 100644 index 000000000..e72020146 --- /dev/null +++ b/Assets/Scripts/EngineAssets.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using GameCult.Caching; +using UnityEngine; +using UnityEngine.AddressableAssets; +using Object = UnityEngine.Object; + +// Turns a CultCache-stored Addressables key (bare GUID, or "guid[subAssetName]" for a sub-asset; +// see CultAssetGuidKey and docs/addressables-cut.md Ruling S) into a loaded asset. Replaces +// UnityHelpers.LoadAsset, which stripped an "Assets/Resources/" path prefix; there is no path to +// strip any more; the catalog names the asset by its stable .meta GUID and Addressables resolves +// it, in editor play mode and in a built player alike. Owns one Addressables handle per (key, +// type) for the process lifetime; nothing here releases a handle, which is the lifetime +// Resources.Load had. All call sites are synchronous construction paths that Instantiate at once, +// so loading stays synchronous (WaitForCompletion) rather than reordering entity construction. +public static class EngineAssets +{ + private static readonly Dictionary<(string Key, Type Type), Object> Cache = new Dictionary<(string, Type), Object>(); + + public static T Load(string key) where T : Object + { + if (string.IsNullOrEmpty(key)) return null; + + var cacheKey = (key, typeof(T)); + if (Cache.TryGetValue(cacheKey, out var cached)) return cached as T; + + T asset; + if (typeof(Component).IsAssignableFrom(typeof(T))) + { + // Addressables cannot load a component directly: load the GameObject and take it from there. + var handle = Addressables.LoadAssetAsync(key); + var gameObject = handle.WaitForCompletion(); + asset = gameObject == null ? null : gameObject.GetComponent(); + } + else + { + var handle = Addressables.LoadAssetAsync(key); + asset = handle.WaitForCompletion(); + } + + if (asset == null) + { + Debug.LogError($"EngineAssets: failed to load {typeof(T).Name} for Addressables key \"{key}\""); + return null; + } + + Cache[cacheKey] = asset; + return asset; + } +} diff --git a/Assets/Scripts/EngineAssets.cs.meta b/Assets/Scripts/EngineAssets.cs.meta new file mode 100644 index 000000000..932a2ac48 --- /dev/null +++ b/Assets/Scripts/EngineAssets.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3f3cca818d26268459d28af1cdb469a5 \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/ActionBarSlot.cs b/Assets/Scripts/Gameplay/ActionBarSlot.cs index 215c57f8b..42c2e3f1e 100644 --- a/Assets/Scripts/Gameplay/ActionBarSlot.cs +++ b/Assets/Scripts/Gameplay/ActionBarSlot.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using MessagePack; using Newtonsoft.Json; using TMPro; @@ -111,7 +110,7 @@ public class ActionBarConsumableBinding : ActionBarBinding { Slot.Label.gameObject.SetActive(false); Slot.Icon.gameObject.SetActive(true); - Slot.Icon.texture = Resources.Load(data.Icon.Substring("Assets/Resources/".Length).Split('.').First()); + Slot.Icon.texture = EngineAssets.Load(data.Icon); } else Slot.Icon.gameObject.SetActive(false); } @@ -150,7 +149,7 @@ public class ActionBarGearBinding : ActionBarBinding Slot.Icon.gameObject.SetActive(true); Slot.Label.gameObject.SetActive(false); if (!string.IsNullOrEmpty(Item.Data.ActionBarIcon)) - Slot.Icon.texture = Resources.Load(Item.Data.ActionBarIcon.Substring("Assets/Resources/".Length).Split('.').First()); + Slot.Icon.texture = EngineAssets.Load(Item.Data.ActionBarIcon); else if (data is WeaponItemData weaponItemData) Slot.Icon.texture = ActionGameManager.Instance.Settings.GetIcon(weaponItemData.WeaponType).texture; else Slot.Icon.texture = ActionGameManager.Instance.Settings.GetIcon(data.HardpointType).texture; diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 189438cf6..296457912 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -192,12 +192,12 @@ public class EntityInstance : MonoBehaviour var data = (InstantWeaponData) instantWeapon.Data; if (!_instantWeaponManagers.ContainsKey(data)) { - var managerPrefab = UnityHelpers.LoadAsset(data.EffectPrefab); + var managerPrefab = EngineAssets.Load(data.EffectPrefab); if(managerPrefab) { _instantWeaponManagers.Add(data, Instantiate(managerPrefab, EffectManagerParent)); } - else Debug.LogError($"No InstantWeaponEffectManager prefab found at path {data.EffectPrefab}"); + else Debug.LogError($"No InstantWeaponEffectManager prefab found for GUID {data.EffectPrefab}"); } instantWeapon.OnFire += () => @@ -221,12 +221,12 @@ public class EntityInstance : MonoBehaviour var data = (ConstantWeaponData) constantWeapon.Data; if (!_constantWeaponManagers.ContainsKey(data)) { - var managerPrefab = UnityHelpers.LoadAsset(data.EffectPrefab); + var managerPrefab = EngineAssets.Load(data.EffectPrefab); if(managerPrefab) { _constantWeaponManagers.Add(data, Instantiate(managerPrefab, EffectManagerParent)); } - else Debug.LogError($"No ConstantWeaponEffectManager prefab found at path {data.EffectPrefab}"); + else Debug.LogError($"No ConstantWeaponEffectManager prefab found for GUID {data.EffectPrefab}"); } constantWeapon.OnStartFiring += () => diff --git a/Assets/Scripts/Gameplay/ShipInstance.cs b/Assets/Scripts/Gameplay/ShipInstance.cs index 56ff14e3f..7999d27cb 100644 --- a/Assets/Scripts/Gameplay/ShipInstance.cs +++ b/Assets/Scripts/Gameplay/ShipInstance.cs @@ -50,7 +50,7 @@ public class ShipInstance : EntityInstance var drive = ship.GetBehavior(); if (drive != null) { - var particles = Instantiate(UnityHelpers.LoadAsset(drive.DriveData.Particles), transform, false); + var particles = Instantiate(EngineAssets.Load(drive.DriveData.Particles), transform, false); var main = particles.main; main.customSimulationSpace = LocalSpace; _aetherDrive = new AetherDriveInstance @@ -64,7 +64,7 @@ public class ShipInstance : EntityInstance _thrusters = ship.GetBehaviors().Select(thruster => { var effectData = (ThrusterData) thruster.Data; - var particles = Instantiate(UnityHelpers.LoadAsset(effectData.ParticlesPrefab), transform, false); + 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); diff --git a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs index 3e43ebc92..a49685780 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs @@ -47,7 +47,7 @@ public class AetherDriveData : BehaviorData [Inspectable, JsonProperty("torqueAudio"), Key(12), RuntimeInspectable] public uint TorqueRatioAudioParameter; - [Inspectable, CultInspectorAssetPath, JsonProperty("particles"), Key(13)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("particles"), Key(13)] public string Particles; public override Behavior CreateInstance(EquippedItem item) diff --git a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index 54aa39c34..76bbc1adb 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -23,7 +23,7 @@ public class ThrusterData : BehaviorData [Inspectable, JsonProperty("energy"), Key(4), RuntimeInspectable] public PerformanceStat EnergyUsage = new PerformanceStat(); - [Inspectable, CultInspectorAssetPath, JsonProperty("Particles"), Key(5)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("Particles"), Key(5)] public string ParticlesPrefab; public override Behavior CreateInstance(EquippedItem item) diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 5e5636f00..b7e8af738 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -40,7 +40,7 @@ public abstract class WeaponData : BehaviorData [InspectableAnimationCurve, JsonProperty("damageCurve"), Key(7)] public BezierCurve DamageCurve; - [Inspectable, CultInspectorAssetPath, JsonProperty("effect"), Key(8)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("effect"), Key(8)] public string EffectPrefab; [Inspectable, JsonProperty("energy"), Key(9), RuntimeInspectable] diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs index 23f9ba337..45edba408 100644 --- a/Assets/Scripts/ServerShared/Corporations.cs +++ b/Assets/Scripts/ServerShared/Corporations.cs @@ -22,7 +22,7 @@ public class Faction [Inspectable, CultInspectorTextArea, JsonProperty("description"), Key(3)] public string Description; - [Inspectable, CultInspectorAssetPath, JsonProperty("logo"), Key(4)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("logo"), Key(4)] public string Logo; [Inspectable, CultReference(typeof(PersonalityAttribute), many: true), JsonProperty("personality"), Key(5)] diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index b416cfef0..b4899b7d3 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -349,7 +349,7 @@ public class ConsumableItemData : CraftedItemData [Inspectable, JsonProperty("duration"), Key(12)] public float Duration; - [Inspectable, CultInspectorAssetPath, JsonProperty("icon"), Key(13)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("icon"), Key(13)] public string Icon; [Inspectable, JsonProperty("effectiveness"), Key(14)] @@ -359,7 +359,7 @@ public class ConsumableItemData : CraftedItemData [JsonObject(MemberSerialization.OptIn)] public abstract class EquippableItemData : CraftedItemData { - [Inspectable, CultInspectorAssetPath, JsonProperty("schematic"), Key(10)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("schematic"), Key(10)] public string Schematic; [Inspectable, JsonProperty("behaviors"), Key(11)] @@ -389,7 +389,7 @@ public abstract class EquippableItemData : CraftedItemData // [Inspectable, JsonProperty("sfx"), Key(19)] // public string SoundEffectTrigger; - [Inspectable, CultInspectorAssetPath, JsonProperty("actionIcon"), Key(20)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("actionIcon"), Key(20)] public string ActionBarIcon; [Inspectable, JsonProperty("soundBank"), Key(21)] @@ -496,7 +496,7 @@ public class HullData : EquippableItemData [Inspectable, JsonProperty("hardpoints"), Key(23)] public List Hardpoints = new List(); - [Inspectable, CultInspectorAssetPath, JsonProperty("prefab"), Key(24)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("prefab"), Key(24)] public string Prefab; [Inspectable, JsonProperty("hullType"), Key(25)] diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index 09d2c2f3a..0b437dcfe 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -292,7 +292,7 @@ public class ZoneRenderer : MonoBehaviour EntityInstance instance; if (entity is Ship) { - instance = Instantiate(UnityHelpers.LoadAsset(hullData.Prefab), ZoneRoot).GetComponent(); + instance = Instantiate(EngineAssets.Load(hullData.Prefab), ZoneRoot).GetComponent(); if (instance == null) { ItemManager.Log($"Failed to instantiate {hullData.Name} ship with invalid prefab: no ShipInstance component!"); @@ -301,7 +301,7 @@ public class ZoneRenderer : MonoBehaviour } else { - instance = Instantiate(UnityHelpers.LoadAsset(hullData.Prefab), ZoneRoot).GetComponent(); + instance = Instantiate(EngineAssets.Load(hullData.Prefab), ZoneRoot).GetComponent(); if (instance == null) { ItemManager.Log($"Failed to instantiate {hullData.Name} entity with invalid prefab: no EntityInstance component!"); diff --git a/Directory.Build.props b/Directory.Build.props index 22254e6cb..7c3d05600 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - a0813c6eed24d30bf88073ef615b633c77ebfcd6 + 45c2f4006f5f7dab357426cabd3197e8d0a17f66 C:\Program Files\Git\cmd\git.exe git diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 835af760f..a092eb297 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ef6362ff938bcd4e9e947a990b111541aec752cfa90da39f1c446c6a9a25fd4 -size 12683061 +oid sha256:102281a85c10aa407dec87ee6c7fba60b5660e25750cea5838cede8c69327754 +size 12682424 diff --git a/Packages/manifest.json b/Packages/manifest.json index eb02dabba..a05027430 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -51,8 +51,8 @@ "com.unity.modules.vr": "1.0.0", "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0", - "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.3.1", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.59", + "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.2.3" } } diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index bb8b8008b..26610201b 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -243,20 +243,20 @@ "url": "https://packages.unity.com" }, "org.gamecult.caching.unity": { - "version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.3.1", + "version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.59" + "org.gamecult.cultlib": "1.0.60" }, - "hash": "a0813c6eed24d30bf88073ef615b633c77ebfcd6" + "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.59", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", "depth": 0, "source": "git", "dependencies": {}, - "hash": "a0813c6eed24d30bf88073ef615b633c77ebfcd6" + "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" }, "org.gamecult.cultmath": { "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.2.3", diff --git a/tests/mutation_tests.py b/tests/mutation_tests.py index 9fdb06a1c..179163cdc 100644 --- a/tests/mutation_tests.py +++ b/tests/mutation_tests.py @@ -1,553 +1,275 @@ #!/usr/bin/env python3 -"""Mutation tests for the Cut B and Cut C provenance rules (docs/item-provenance-cut.md). +"""Mutation tests for docs/addressables-cut.md Cut 2. -For each rule this applies its named mutation to an exact, unique anchor of -source text, runs the one test that should catch it, restores the file -byte-for-byte, and reports whether the mutant was killed. A no-op control -mutation (anchor == replacement) proves the read/replace/write/restore path -itself is transparent: every test it touches must stay green. +EngineAssetCheck (Assets/Scripts/Editor/EngineAssetCheck.cs) is the rule that keeps every stored +[CultInspectorAssetGuid] value honest. A check nobody can break is a check nobody can trust: each +case here mutates the *real* catalog (GameData/Aetheria.cc) or the source in one specific way the +cut promises to catch, runs EngineAssetCheck.Run in Unity batchmode, asserts it fails naming the +record, then restores the catalog/source exactly. A "control" case first proves the unmutated +catalog passes, so a case that "fails" for the wrong reason (a bad harness, not a caught mutation) +cannot hide. -Usage: - python tests/mutation_tests.py --cultlib-root - -Requires the CultLib worktree described in docs/item-provenance-cut.md section 6. -Only touches files under this repository; every mutation is reversed before -the script exits, including on failure. +Run from the repo root: python tests/mutation_tests.py +Exit code 0 only if every case behaved as the cut's own verification section (Cut 2) demands. """ -from __future__ import annotations - -import argparse +import os +import re +import shutil import subprocess import sys -from dataclasses import dataclass +import tempfile from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" +REPO_ROOT = Path(__file__).resolve().parents[1] +CATALOG = REPO_ROOT / "GameData" / "Aetheria.cc" +UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe" +ITEM_DATA_CS = REPO_ROOT / "Assets" / "Scripts" / "ServerShared" / "ItemData.cs" +DIRECTORY_BUILD_PROPS = REPO_ROOT / "Directory.Build.props" + +# A known-good record to mutate: HullData "LonginusX", whose Prefab already resolves (see the Cut 2 +# migration report). Texture2D guid is its own Schematic (schema_Longinus.png), still a real, +# addressable asset, just the wrong type for Prefab (which wants a GameObject+EntityInstance). +TEXTURE_GUID = "51702555e534a7a4fb39eb105b80bbaf" +# A real, existing, non-addressable asset (a .cs script under Assets/Scripts, outside Assets/Content). +NON_ADDRESSABLE_GUID = "3f3cca818d26268459d28af1cdb469a5" +RANDOM_GUID = "deadbeefdeadbeefdeadbeefdeadbeef" +OLD_PATH = "Assets/Resources/Prefabs/Ships/Longinus.prefab" -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) +def run(cmd, **kwargs): + print(f"$ {' '.join(str(c) for c in cmd)}") + return subprocess.run(cmd, capture_output=True, text=True, **kwargs) -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=' [Key(0)] public int NextLot = 1;', - mutated=' [Key(0)] public int NextLot = 1;', - test="RunSaveTests.MissingLotIsLoud", - expect="green", - ), - # --- MissingLotIsLoud: the indexer and Reachable must throw on an absent lot --- - Mutation( - rule="ProvenanceLedger indexer throws on an absent id", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=( - " public Lot this[int id]\n" - " {\n" - " get\n" - " {\n" - " if (!Lots.TryGetValue(id, out var lot))\n" - " throw new InvalidOperationException($\"Lot {id} is not in the provenance ledger.\");\n" - " return lot;\n" - " }\n" - " }" - ), - mutated=( - " public Lot this[int id]\n" - " {\n" - " get\n" - " {\n" - " Lots.TryGetValue(id, out var lot);\n" - " return lot;\n" - " }\n" - " }" - ), - test="RunSaveTests.MissingLotIsLoud", - expect="red", - ), - Mutation( - rule="Reachable throws on a missing root instead of skipping it", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=" var lot = this[id];\n reached[id] = lot;", - mutated=" if (!Lots.TryGetValue(id, out var lot)) continue;\n reached[id] = lot;", - test="RunSaveTests.MissingLotIsLoud", - expect="red", - ), - # --- MatchingHardpointsShareALot: the second unit must mint from the first unit's lot --- - Mutation( - rule="matching hardpoints share a lot, not a fresh mint", - file="Assets/Scripts/ServerShared/LoadoutGenerator.cs", - anchor=( - " // Matching hardpoints carry matching units: same lot\n" - " var item = (previousItem != null\n" - " ? ItemManager.CreateInstance(previousItem.EquippableItem.Lot)\n" - " : ItemManager.CreateInstance(entry.product)) as EquippableItem;" - ), - mutated=( - " // Matching hardpoints carry matching units: same lot\n" - " var item = ItemManager.CreateInstance(entry.product) as EquippableItem;" - ), - test="LoadoutTests.MatchingHardpointsShareALot", - expect="red", - ), - # --- FirstAvailableProductInKeyOrderBuildsTheSlot: Brand and Materialize must both respect key order --- - Mutation( - rule="Brand matches the lot's maker, not just the design", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=( - " var product = ItemData.GetAll()\n" - " .Where(p => p.Manufacturer.Key.Equals(maker.Key) && p.Design.Key.Equals(item.Data.Key))" - ), - mutated=( - " var product = ItemData.GetAll()\n" - " .Where(p => p.Design.Key.Equals(item.Data.Key))" - ), - test="LoadoutTests.FirstAvailableProductInKeyOrderBuildsTheSlot", - expect="red", - ), - Mutation( - rule="Materialize resolves a design's product in record-key order", - file="Assets/Scripts/ServerShared/Loadout.cs", - anchor="var products = cache.GetAll().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();", - mutated="var products = cache.GetAll().ToArray();", - test="LoadoutTests.FirstAvailableProductInKeyOrderBuildsTheSlot", - expect="red", - ), - # --- BrandPicksFirstProductInKeyOrderForSameMakerAndDesign: Brand's own tie-break must be ascending key order --- - Mutation( - rule="Brand's tie-break among a maker's products for one design is ascending record-key order", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" .OrderBy(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)", - mutated=" .OrderByDescending(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)", - test="LoadoutTests.BrandPicksFirstProductInKeyOrderForSameMakerAndDesign", - expect="red", - ), - # --- StatsReadTheLot: stats must read the lot, including its per-role fills --- - Mutation( - rule="Evaluate reads the lot's per-role fill for a role stat", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor="var quality = pow(lot.QualityForRole(stat.FromRole), stat.QualityExponent);", - mutated="var quality = pow(lot.Quality, stat.QualityExponent);", - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - Mutation( - rule="QualityForRole falls back to the lot's own workmanship, not a fixed value", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=" if (string.IsNullOrEmpty(role) || Roles == null) return Quality;", - mutated=" if (string.IsNullOrEmpty(role) || Roles == null) return 1f;", - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - # --- F2 (item-provenance-cut.md): the GC root walk must cover every EntityPack root --- - Mutation( - rule="Items yields the Equipment root, not just the hull", - file="Assets/Scripts/ServerShared/EntitySerializer.cs", - anchor=( - " yield return pack.Hull;\n" - " foreach (var (_, item) in pack.Equipment) yield return item;" - ), - mutated=" yield return pack.Hull;", - test="LoadoutTests.MaterializedLotsSurviveSaveAndReload", - expect="red", - ), - # --- F3: GetTier and CreateInstance(int) must read/preserve the lot's real quality --- - Mutation( - rule="GetTier reads the lot's quality, not a fixed value", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" var quality = GetLot(item).Quality;", - mutated=" var quality = .999f;", - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - Mutation( - rule="CreateInstance(int) does not re-roll the lot's quality", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=( - " public CraftedItemInstance CreateInstance(int lot)\n" - " {\n" - " var l = Lots[lot];" - ), - mutated=( - " public CraftedItemInstance CreateInstance(int lot)\n" - " {\n" - " var l = Lots[lot];\n" - " l.Quality = RollQuality();" - ), - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - # --- F4: GetPrice, the durability/thermal exponent paths, role fills, Produced brand, multi-zone roots --- - Mutation( - rule="GetPrice reads the lot's quality, not a fixed value", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" return (int) (GameplaySettings.QualityPriceModifier.Evaluate(GetLot(item).Quality) * data.Price);", - mutated=" return (int) (GameplaySettings.QualityPriceModifier.Evaluate(.5f) * data.Price);", - test="LoadoutTests.GetPriceReadsLotQuality", - expect="red", - ), - Mutation( - rule="Evaluate's durability exponent reads the lot's quality", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=( - " var durabilityExponent = lerp(\n" - " GameplaySettings.DurabilityQualityMin,\n" - " GameplaySettings.DurabilityQualityMax,\n" - " pow(lot.Quality, GameplaySettings.DurabilityQualityExponent));" - ), - mutated=( - " var durabilityExponent = lerp(\n" - " GameplaySettings.DurabilityQualityMin,\n" - " GameplaySettings.DurabilityQualityMax,\n" - " .5f);" - ), - test="LoadoutTests.EvaluateDurabilityExponentReadsLotQuality", - expect="red", - ), - Mutation( - rule="EquippedItem's thermal exponent reads the lot's quality", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " ThermalExponent = lerp(\n" - " ItemManager.GameplaySettings.ThermalQualityMin,\n" - " ItemManager.GameplaySettings.ThermalQualityMax,\n" - " pow(Lot.Quality, ItemManager.GameplaySettings.ThermalQualityExponent));" - ), - mutated=( - " ThermalExponent = lerp(\n" - " ItemManager.GameplaySettings.ThermalQualityMin,\n" - " ItemManager.GameplaySettings.ThermalQualityMax,\n" - " .5f);" - ), - test="LoadoutTests.EquippedItemThermalExponentReadsLotQuality", - expect="red", - ), - Mutation( - rule="CreateLot(product) fills roles from the product's own spread, not the design default", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();", - mutated=" var build = new ProductRole();", - test="LoadoutTests.CreateLotFillsRolesFromProductSpread", - expect="red", - ), - Mutation( - rule="Brand reads the maker off a Produced origin too, not only Attributed", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=( - " var maker = lot.Origin switch\n" - " {\n" - " Attributed attributed => attributed.Faction,\n" - " Produced produced => produced.Faction,\n" - " _ => default\n" - " };" - ), - mutated=( - " var maker = lot.Origin switch\n" - " {\n" - " Attributed attributed => attributed.Faction,\n" - " _ => default\n" - " };" - ), - test="LoadoutTests.BrandReadsProducedFaction", - expect="red", - ), - Mutation( - rule="RunSave.Commit takes GC roots from every zone, not just the first", - file="Assets/Scripts/ServerShared/SavedGame.cs", - anchor=" var roots = zones\n .SelectMany(zone => zone.Contents?.Entities ?? new List())", - mutated=" var roots = zones.Take(1)\n .SelectMany(zone => zone.Contents?.Entities ?? new List())", - test="RunSaveTests.CommitTakesRootsFromEveryZone", - expect="red", - ), - # --- Cut C: the manufacturer moves from the design to the product; the catalog fixture and its - # round-trip assertion (FactionIsASingletonInstance) must actually depend on that wiring. --- - Mutation( - rule="the catalog fixture's product carries the maker, not an unset reference", - file="tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs", - anchor=( - 'cache.Upsert(new FactionProductData { Name = "Lance", ' - "Design = new CultRecordRef(lance.Key), Manufacturer = cache.RefOf(faction) });" - ), - mutated=( - 'cache.Upsert(new FactionProductData { Name = "Lance", ' - "Design = new CultRecordRef(lance.Key), Manufacturer = default });" - ), - test="AetheriaStoresTests.FactionIsASingletonInstance", - expect="red", - ), - # --- Hand-testing combat (2026-09-17): IFF overrides, grudge, detection-gating, and the shooter- - # stance weapon fire gate on Entity/Weapon (docs not yet written; see IffAndCombatTests.cs). --- - Mutation( - rule="an IFF override decides IsHostileTo outright at the top level", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;", - mutated=" if (false)\n return default;", - test="IffAndCombatTests.OverrideDecidesOutrightOverDerivedRule", - expect="red", - ), - Mutation( - rule="the recursive reciprocity sub-query skips overrides (no mirroring another entity's stance)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;", - mutated=" if (_iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;", - test="IffAndCombatTests.DerivedReciprocityDoesNotMirrorAnotherEntitysOverride", - expect="red", - ), - Mutation( - rule="leaving the zone clears this entity's IFF overrides on the departing entity", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" _iffOverrides.Remove(remove.Value);\n if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))", - mutated=" if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))", - test="IffAndCombatTests.LeavingZoneClearsOverrides", - expect="red", - ), - Mutation( - rule="an override change refreshes EntityHostility immediately (event-driven, no tick required)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key)));\n _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key)));", - mutated=" // _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key)));\n // _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key)));", - test="IffAndCombatTests.OverrideDecidesOutrightOverDerivedRule", - expect="red", - ), - Mutation( - rule="non-player entities wire a grudge watch; player-controlled entities never do", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (!IsPlayerControlled) WatchForGrudge(entity);", - mutated=" WatchForGrudge(entity);", - test="IffAndCombatTests.PlayerControlledEntitiesNeverAutoGrudge", - expect="red", - ), - Mutation( - rule="grudge only takes hold while this entity currently detects the marker", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other))", - mutated=" if (otherIsHostileToThis && !_iffOverrides.ContainsKey(other))", - test="IffAndCombatTests.NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker", - expect="red", - ), - Mutation( - rule="detecting an already-hostile entity grudges it immediately (VisibleEntities.ObserveAdd wiring)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " VisibleEntities.ObserveAdd()\n" - " .Where(add => add.Value == other)\n" - " .Subscribe(_ =>\n" - " {\n" - " if (other.EntityHostility.TryGetValue(this, out var hostile))\n" - " CheckGrudge(hostile);\n" - " })" - ), - mutated=( - " VisibleEntities.ObserveAdd()\n" - " .Where(add => false)\n" - " .Subscribe(_ =>\n" - " {\n" - " if (other.EntityHostility.TryGetValue(this, out var hostile))\n" - " CheckGrudge(hostile);\n" - " })" - ), - test="IffAndCombatTests.NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker", - expect="red", - ), - Mutation( - rule="a grudge is sticky: it never re-evaluates (mirrors current stance) once already grudged", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other))\n SetIff(other, true);", - mutated=" if (VisibleEntities.Contains(other))\n SetIff(other, otherIsHostileToThis);", - test="IffAndCombatTests.GrudgeIsStickyAndDoesNotForgiveWhenMarkerGoesNeutral", - expect="red", - ), - Mutation( - rule="PerceivedStanceOf is unknown (null) until this entity detects the other", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " public bool? PerceivedStanceOf(Entity other) =>\n" - " VisibleEntities.Contains(other) && other.EntityHostility.TryGetValue(this, out var hostile)\n" - " ? hostile\n" - " : (bool?) null;" - ), - mutated=( - " public bool? PerceivedStanceOf(Entity other) =>\n" - " other.EntityHostility.TryGetValue(this, out var hostile)\n" - " ? hostile\n" - " : (bool?) null;" - ), - test="IffAndCombatTests.PerceivedStanceIsUnknownUntilDetectedThenReadsTheActualStance", - expect="red", - ), - Mutation( - rule="Weapon.StanceAllowsFire gates on the shooter's own stance, not an unconditional true", - file="Assets/Scripts/ServerShared/Behaviors/Weapon.cs", - anchor=" public bool StanceAllowsFire => Entity.Target.Value == null || Entity.IsHostileTo(Entity.Target.Value);", - mutated=" public bool StanceAllowsFire => true;", - test="IffAndCombatTests.WeaponDoesNotFireWhenShooterIsNotHostileToItsTarget", - expect="red", - ), - Mutation( - rule="InstantWeapon.Trigger is safed by StanceAllowsFire before it starts a burst", - file="Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs", - anchor=" if (!StanceAllowsFire) return;", - mutated=" if (false) return;", - test="IffAndCombatTests.WeaponDoesNotFireWhenShooterIsNotHostileToItsTarget", - expect="red", - ), - Mutation( - rule="LockWeapon builds lock on the shooter's own hostility toward the target, not the reverse", - file="Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs", - anchor=" if (Entity.Target.Value != null && Entity.IsHostileTo(Entity.Target.Value))", - mutated=" if (Entity.Target.Value != null && Entity.Target.Value.IsHostileTo(Entity))", - test="IffAndCombatTests.LockWeaponBuildsLockOnTheShootersOwnStanceNotTheTargets", - expect="red", - ), - Mutation( - rule="ZoneGenerator's neutral-wanderer pool excludes the zone owner and the nearest faction", - file="Assets/Scripts/ServerShared/ZoneGenerator.cs", - anchor="\tpublic static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>\n\t\tfactions.Where(f => f != owner && f != nearest).ToArray();", - mutated="\tpublic static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>\n\t\tfactions.ToArray();", - test="IffAndCombatTests.EligibleWandererFactionsExcludesOwnerAndNearest", - expect="red", - ), - # --- Capability event contract (docs/headless-playground-cut.md fork L, 2026-09-17): each event - # kind must reach only its own subscribers. --- - Mutation( - rule="control (no-op round trip) for the capability event routing test", - file="Assets/Scripts/ServerShared/CapabilityEvents.cs", - anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", - mutated=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", - test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers", - expect="green", - ), - Mutation( - rule="PublishAbsorb notifies only the Absorb stream, not Thrust as well", - file="Assets/Scripts/ServerShared/CapabilityEvents.cs", - anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", - mutated=" public void PublishAbsorb(AbsorbEvent e) { _absorb.OnNext(e); _thrust.OnNext(default); }", - test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers", - expect="red", - ), - # --- Cut 0 (docs/settings-globals-cut.md): Entity must copy the EntitySettings it is given, so no - # entity aliases GameplaySettings.DefaultEntitySettings (or, once settings are a catalog global, the - # cached record itself). --- - Mutation( - rule="control (no-op round trip) for the entity-settings-copy test", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" Settings = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(settings));", - mutated=" Settings = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(settings));", - test="LoadoutTests.EntitiesDoNotShareDefaultEntitySettings", - expect="green", - ), - Mutation( - rule="Entity's constructor copies the settings it is given, instead of aliasing the template", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" Settings = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(settings));", - mutated=" Settings = settings;", - test="LoadoutTests.EntitiesDoNotShareDefaultEntitySettings", - expect="red", - ), -] +def pinned_cultlib_revision() -> str: + text = DIRECTORY_BUILD_PROPS.read_text(encoding="utf-8") + match = re.search(r"([0-9a-f]{40})", text) + if not match: + raise SystemExit("could not find CultLibRevision in Directory.Build.props") + return match.group(1) -def run_test(cultlib_root: str, filter_expr: str) -> tuple[str, str]: - """Runs the one filtered test. Returns ("ERROR", ...) when the build itself failed (a - compile error is never a legitimate kill or survival — it means the mutation broke - something the test never got a chance to exercise), otherwise ("PASS"|"FAIL", output).""" - proc = subprocess.run( - [ - "dotnet", "test", str(TEST_PROJECT), - f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{filter_expr}", - "--nologo", - ], - cwd=REPO_ROOT, - capture_output=True, - text=True, +def resolve_cultlib_root(scratch: Path) -> str: + """The mutator (like the rest of the .NET build) must build against the exact CultLib + revision Directory.Build.props pins, not whatever CultLib main happens to be on disk right + now. Reuse CULTLIB_ROOT / the sibling checkout when it already matches; otherwise check out a + detached scratch worktree at the pinned revision, same as the cut's own verification pass.""" + revision = pinned_cultlib_revision() + + candidates = [] + if os.environ.get("CULTLIB_ROOT"): + candidates.append(Path(os.environ["CULTLIB_ROOT"])) + candidates.append(REPO_ROOT.parent / "CultLib") + + for candidate in candidates: + if not candidate.exists(): + continue + head = run(["git", "-C", str(candidate), "rev-parse", "HEAD"]).stdout.strip() + dirty = run(["git", "-C", str(candidate), "status", "--porcelain"]).stdout.strip() + if head == revision and not dirty: + return str(candidate) + + worktree = scratch / f"cultlib-{revision[:12]}" + if not worktree.exists(): + result = run(["git", "-C", str(REPO_ROOT.parent / "CultLib"), "worktree", "add", "--detach", str(worktree), revision]) + if result.returncode != 0: + print(result.stdout, result.stderr) + raise SystemExit("failed to create pinned CultLib worktree for the mutator build") + return str(worktree) + + +def build_mutator(build_dir: Path, cultlib_root: str) -> Path: + build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / "Mutate.csproj").write_text( + """ + + Exe + net10.0 + disable + disable + + + + + +""" % (REPO_ROOT / "Aetheria.Shared" / "Aetheria.Shared.csproj"), + encoding="utf-8", ) - output = proc.stdout + proc.stderr - # A build failure never reaches "Test run for ..."/"Passed!"/"Failed!"; it reports - # "Build FAILED." and one or more "error CS..." lines instead. - if "Build FAILED" in output or "error CS" in output: - return "ERROR", output - passed = proc.returncode == 0 and "Failed!" not in output - return ("PASS" if passed else "FAIL"), output + (build_dir / "Program.cs").write_text( + r"""using System; +using System.IO; +using System.Linq; +using System.Reflection; +using GameCult.Caching; + +// Scratch-only (not landed): sets HullData "LonginusX".Prefab to an arbitrary string in a copy of +// the catalog, for tests/mutation_tests.py to feed to EngineAssetCheck and prove it fails. +internal static class Program +{ + private static int Main(string[] args) + { + var catalogPath = args[0]; + var newValue = args[1]; + var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); + try + { + var stored = cache.AllStoredDocuments.First(s => + s.Descriptor.DocumentType.Name == "HullData" && + (string)s.Descriptor.DocumentType.GetField("Name").GetValue(s.Document) == "LonginusX"); + var field = stored.Descriptor.DocumentType.GetField("Prefab", BindingFlags.Public | BindingFlags.Instance); + field.SetValue(stored.Document, newValue); + cache.UpsertAsync(stored.Descriptor.DocumentType, stored.Document, stored.Key).Wait(); + cache.FlushAllBackingStores(); + Console.WriteLine($"Set HullData \"LonginusX\".Prefab = \"{newValue}\""); + return 0; + } + finally + { + cache.Dispose(); + } + } +} +""", + encoding="utf-8", + ) + build = run([ + "dotnet", "build", str(build_dir / "Mutate.csproj"), + "-c", "Release", "-o", str(build_dir / "out"), + f"-p:CultLibRoot={cultlib_root}", + ]) + if build.returncode != 0: + print(build.stdout, build.stderr) + raise SystemExit("mutator build failed") + return build_dir / "out" / "Mutate.dll" -def detect_newline(original: bytes) -> bytes: - return b"\r\n" if b"\r\n" in original else b"\n" +def mutate_catalog(mutator_dll: Path, scratch_catalog: Path, value: str): + shutil.copyfile(CATALOG, scratch_catalog) + result = run(["dotnet", str(mutator_dll), str(scratch_catalog), value]) + if result.returncode != 0: + print(result.stdout, result.stderr) + raise SystemExit("mutation failed to apply") -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() +def run_engine_asset_check(log_path: Path) -> tuple[int, str]: + proc = subprocess.run( + [UNITY_EXE, "-batchmode", "-quit", "-projectPath", str(REPO_ROOT), + "-executeMethod", "EngineAssetCheck.Run", "-logFile", str(log_path)], + timeout=600, + ) + text = log_path.read_text(encoding="utf-8", errors="replace") + return proc.returncode, text - results = [] + +def assert_contains(log_text: str, needle: str, case_name: str): + if needle not in log_text: + raise SystemExit(f"[{case_name}] FAIL: expected log to contain {needle!r}; it did not") + + +def main(): + if not CATALOG.exists(): + raise SystemExit(f"catalog not found: {CATALOG}") + + original_catalog = CATALOG.read_bytes() + original_item_data = ITEM_DATA_CS.read_text(encoding="utf-8") + scratch = Path(tempfile.mkdtemp(prefix="aetheria-mutation-")) failures = [] - for m in MUTATIONS: - path = REPO_ROOT / m.file - original = path.read_bytes() - newline = detect_newline(original).decode("ascii") - # Anchors are authored with plain "\n"; translate to the file's own line ending - # before matching, so CRLF-checked-out files (this repo's default) still match. - anchor = m.anchor.replace("\n", newline) - mutated = m.mutated.replace("\n", newline) - text = original.decode("utf-8") - count = text.count(anchor) - if count != 1: - print(f"[ABORT] anchor for '{m.rule}' matches {count} times in {m.file}, expected exactly 1") - failures.append(m.rule) - continue + try: + cultlib_root = resolve_cultlib_root(scratch) + print(f"CultLibRoot for the mutator build: {cultlib_root}") + mutator_dll = build_mutator(scratch / "mutator", cultlib_root) - mutated_text = text.replace(anchor, mutated, 1) - path.write_bytes(mutated_text.encode("utf-8")) + # Control: the real, unmutated catalog must pass with 0 failures. If this fails, every + # other case below is meaningless (the harness itself, not the mutation, would be at fault). + print("\n=== control: unmutated catalog ===") + log = scratch / "control.log" + code, text = run_engine_asset_check(log) + if code != 0 or "0 failure" not in text and "stored reference(s) OK" not in text: + failures.append("control: expected exit 0 and a clean report on the real catalog") + else: + print("control OK: exit 0, no failures") + + cases = [ + ("wrong-type (Texture2D guid for a Prefab field)", TEXTURE_GUID, "not a GameObject"), + ("non-addressable guid (real asset outside Assets/Content)", NON_ADDRESSABLE_GUID, "is not addressable"), + ("random 32-hex guid (no such asset)", RANDOM_GUID, "names no existing file"), + ("old Resources path (pre-migration form)", OLD_PATH, "not 32 lowercase hex characters"), + ] + + for name, value, expect in cases: + print(f"\n=== mutation: {name} ===") + try: + mutated = scratch / "mutated.cc" + mutate_catalog(mutator_dll, mutated, value) + shutil.copyfile(mutated, CATALOG) + + log = scratch / (name.split()[0] + ".log") + code, text = run_engine_asset_check(log) + + if code == 0: + failures.append(f"{name}: EngineAssetCheck exited 0; the mutation was not caught") + continue + if "LonginusX" not in text: + failures.append(f"{name}: failure did not name the record (LonginusX)") + continue + assert_contains(text, expect, name) + print(f"OK: caught, exit {code}, named LonginusX, mentioned {expect!r}") + except SystemExit as exc: + failures.append(f"{name}: {exc}") + finally: + CATALOG.write_bytes(original_catalog) + + # Missing consumer contract: a new [CultInspectorAssetGuid] member EngineAssetCheck's table + # doesn't know about must fail loudly, not be silently skipped. + print("\n=== mutation: attributed member missing from the consumer contract table ===") try: - status_code, output = run_test(args.cultlib_root, m.test) + marker = " [Inspectable, JsonProperty(\"hardpoints\"), Key(23)] \n public List Hardpoints = new List();" + replacement = marker + ( + "\n\n // Scratch mutation (tests/mutation_tests.py): an attributed member with no " + "row in EngineAssetCheck.ConsumerContracts. Must fail the check, not be skipped.\n" + " [Inspectable, CultInspectorAssetGuid, JsonProperty(\"scratchMutation\"), Key(30)]\n" + f" public string ScratchMutationField = \"{TEXTURE_GUID}\";" + ) + if marker not in original_item_data: + raise SystemExit("anchor text for the scratch field insertion was not found in ItemData.cs") + mutated_source = original_item_data.replace(marker, replacement, 1) + ITEM_DATA_CS.write_text(mutated_source, encoding="utf-8") + + log = scratch / "missing-contract.log" + code, text = run_engine_asset_check(log) + if code == 0: + failures.append("missing-contract: EngineAssetCheck exited 0; an untabled member was not caught") + elif "ScratchMutationField" not in text or "no consumer contract registered" not in text: + failures.append("missing-contract: failure did not name the untabled member and reason") + else: + print("OK: caught, exit", code, "named ScratchMutationField with no registered contract") finally: - # Reverse write: restore the exact original bytes regardless of outcome. - path.write_bytes(original) - restored = path.read_bytes() - if restored != original: - print(f"[FATAL] {m.file} did not restore byte-exact after '{m.rule}'!") - return 2 + ITEM_DATA_CS.write_text(original_item_data, encoding="utf-8") - if status_code == "ERROR": - status = "ERROR (build failure)" - failures.append(m.rule) - elif m.expect == "red": - killed = status_code == "FAIL" - status = "KILLED" if killed else "SURVIVED (bad)" - if not killed: - failures.append(m.rule) - else: # control: must stay green - passed = status_code == "PASS" - status = "GREEN (ok)" if passed else "RED (bad: round trip corrupted something)" - if not passed: - failures.append(m.rule) + # Restoration proof: after reverting the source, a fresh batchmode compile + check must be + # clean again — the mutation must leave no residue once undone. + print("\n=== restoration check: reverted source recompiles clean ===") + log = scratch / "restored.log" + code, text = run_engine_asset_check(log) + if code != 0: + failures.append("restoration: EngineAssetCheck did not exit 0 after reverting the scratch field") + else: + print("OK: restored source compiles and the check is clean again") - results.append((m.rule, m.test, status)) - print(f"{status:22} | {m.test:55} | {m.rule}") - - print() - print("Summary:") - for rule, test, status in results: - print(f" {status:22} {test:55} {rule}") + finally: + CATALOG.write_bytes(original_catalog) + ITEM_DATA_CS.write_text(original_item_data, encoding="utf-8") + shutil.rmtree(scratch, ignore_errors=True) + print(f"\nrestored {CATALOG} and {ITEM_DATA_CS} to their original content.") if failures: - print(f"\n{len(failures)} mutation(s) did not behave as expected:") + print("\n=== MUTATION TESTS FAILED ===") for f in failures: - print(f" - {f}") + print(" -", f) return 1 - print(f"\nAll {len(results)} mutations behaved as expected.") + print("\n=== all mutation tests passed ===") return 0