From 62f9bccfc1ecab0226b2deebf3b331897a8d9d87 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 5 Jul 2026 20:19:25 +0100 Subject: [PATCH] Project behavior state from daemon catalog payloads --- Aetheria.State.Daemon/Program.cs | 20 ++- Aetheria.State.Verify/Program.cs | 121 +++++++----------- .../Tests/DaemonRuntimeDocumentTests.cs | 115 +++++++++++++++++ GameCult.Aetheria.State.Unity.csproj | 1 + .../AetheriaRuntimeBehaviorStateProjector.cs | 95 ++++++++++++++ 5 files changed, 270 insertions(+), 82 deletions(-) create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorStateProjector.cs diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index 7dcd1c371..60a16f3f3 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -1952,6 +1952,7 @@ static async Task ReadRuntimeRunCheckpointA if (run == null) return null; + var catalog = node.RuntimeCatalog().Latest() ?? new AetheriaRuntimeCatalogSnapshot(); var zones = new List(); var zoneKeys = run.ZoneKeys ?? Array.Empty(); for (var zoneIndex = 0; zoneIndex < zoneKeys.Length; zoneIndex++) @@ -1960,7 +1961,7 @@ static async Task ReadRuntimeRunCheckpointA if (zone == null) continue; - zones.Add(await ToRuntimeZoneAsync(node, zone, zoneIndex, renderSettings).ConfigureAwait(false)); + zones.Add(await ToRuntimeZoneAsync(node, zone, zoneIndex, renderSettings, catalog).ConfigureAwait(false)); } return new AetheriaRuntimeRunCheckpointCommit @@ -1990,7 +1991,8 @@ static async Task ToRuntimeZoneAsync( AetheriaStateNode node, AetheriaZoneState zone, int zoneIndex, - AetheriaRuntimeDaemonRenderSettings renderSettings) + AetheriaRuntimeDaemonRenderSettings renderSettings, + AetheriaRuntimeCatalogSnapshot catalog) { var entityKeys = zone.EntityKeys ?? Array.Empty(); var entityIndices = entityKeys @@ -2002,7 +2004,7 @@ static async Task ToRuntimeZoneAsync( { var entity = await node.MutableDocument(new CultRecordKey(entityKeys[entityIndex])).ReadAsync().ConfigureAwait(false); if (entity != null) - entities.Add(ToRuntimeEntity(entity, entityIndex, entityIndices)); + entities.Add(ToRuntimeEntity(entity, entityIndex, entityIndices, catalog)); } return new AetheriaRuntimeZoneSnapshotCommit @@ -2030,9 +2032,11 @@ static async Task ToRuntimeZoneAsync( static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity( AetheriaEntitySnapshot entity, int entityIndex, - IReadOnlyDictionary entityIndices) + IReadOnlyDictionary entityIndices, + AetheriaRuntimeCatalogSnapshot catalog) { - return new AetheriaRuntimeEntitySnapshotCommit + var equipment = ToEntitySlotCommits(entity.Equipment); + var runtimeEntity = new AetheriaRuntimeEntitySnapshotCommit { EntityIndex = entityIndex, Name = entity.Name ?? "", @@ -2044,7 +2048,7 @@ static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity( DirectionY = entity.Direction?.Y ?? 1, FactionKey = entity.FactionKey ?? "", HullItemKey = entity.HullItemKey ?? "", - Equipment = ToEntitySlotCommits(entity.Equipment), + Equipment = equipment, CargoBays = ToEntitySlotCommits(entity.CargoBays), DockingBays = ToEntitySlotCommits(entity.DockingBays), ChildEntityIndices = (entity.ChildEntityKeys ?? Array.Empty()) @@ -2092,7 +2096,7 @@ static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity( }) .ToArray(), WeaponStates = Array.Empty(), - BehaviorStates = Array.Empty(), + BehaviorStates = AetheriaRuntimeBehaviorStateProjector.CreateEquipmentBehaviorStates(equipment, catalog), CargoContents = ToCargoBayCommits(entity.CargoContents), DockingBayContents = ToCargoBayCommits(entity.DockingBayContents), DockingBayAssignments = (entity.DockingBayAssignments ?? Array.Empty()).ToArray(), @@ -2103,6 +2107,8 @@ static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity( .Where(contact => contact.TargetEntityIndex >= 0) .ToArray() }; + AetheriaRuntimeBehaviorStateProjector.EnsureEquipmentBehaviorStates(runtimeEntity, catalog); + return runtimeEntity; } static AetheriaRuntimeEntityContactCommit ToRuntimeContact( diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 9d877d891..0a4bbce28 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -576,7 +576,7 @@ Console.WriteLine($"Name files: {nameFiles.Length}"); Console.WriteLine("Live gameplay source purity: no serializer or legacy database symbols in Assets/Scripts"); Console.WriteLine("Package serializer boundary: MessagePack execution remains in named CultCache transport files; document DTOs may carry schema annotations"); Console.WriteLine("Shared Eve package ownership: generic Unity Eve packages import from the neighboring Eve repo instead of local staged copies"); -Console.WriteLine("Typed runtime behavior coverage: live behavior kinds have typed factory plus progress/state restore coverage"); +Console.WriteLine("Typed runtime behavior coverage: daemon behavior-state rows derive from catalog payloads; Unity behavior objects are presentation restore only"); Console.WriteLine("Zone construction/runtime key authority: body/orbit wrappers and task shells retain typed keys instead of GUID sidecars"); Console.WriteLine("Eve runtime bootstrap: daemon-published game surface mounts through UI Toolkit presenter"); Console.WriteLine("Renderer-local console authority: deleted; UI commands flow through Eve command documents"); @@ -3151,98 +3151,69 @@ static void RequireFactionKeyIdentity(string root) static void RequireTypedRuntimeBehaviorCoverage(string root) { - var itemManagerPath = Path.Combine(root, "Assets", "Scripts", "ServerShared", "ItemManager.cs"); var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); var observedEntityRestorerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedEntityRestorer.cs"); + var behaviorStateProjectorPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeBehaviorStateProjector.cs"); + var daemonProgramPath = Path.Combine(root, "Aetheria.State.Daemon", "Program.cs"); + var unityProjectPath = Path.Combine(root, "GameCult.Aetheria.State.Unity.csproj"); - var itemManager = File.Exists(itemManagerPath) - ? File.ReadAllText(itemManagerPath) - : throw new InvalidOperationException("Cannot verify typed runtime behavior coverage; ItemManager.cs is missing."); var actionGameManager = File.Exists(actionGameManagerPath) ? File.ReadAllText(actionGameManagerPath) : throw new InvalidOperationException("Cannot verify typed runtime behavior coverage; ActionGameManager.cs is missing."); var observedEntityRestorer = File.Exists(observedEntityRestorerPath) ? File.ReadAllText(observedEntityRestorerPath) : throw new InvalidOperationException("Cannot verify typed runtime behavior coverage; AetheriaUnityObservedEntityRestorer.cs is missing."); + var behaviorStateProjector = File.Exists(behaviorStateProjectorPath) + ? File.ReadAllText(behaviorStateProjectorPath) + : throw new InvalidOperationException("Cannot verify typed runtime behavior coverage; behavior-state projector is missing."); + var daemonProgram = File.Exists(daemonProgramPath) + ? File.ReadAllText(daemonProgramPath) + : throw new InvalidOperationException("Cannot verify typed runtime behavior coverage; daemon program is missing."); + var unityProject = File.Exists(unityProjectPath) + ? File.ReadAllText(unityProjectPath) + : throw new InvalidOperationException("Cannot verify typed runtime behavior coverage; Unity project is missing."); var runtimeRestoreSource = actionGameManager + "\n" + observedEntityRestorer; - var requiredFactoryMappings = new[] + var requiredBehaviorStateProjection = new[] { - "case \"AetherDrive\": return new AetherDrive(definition, item);", - "case \"AutoWeapon\": return new AutoWeapon(definition, item);", - "case \"Capacitor\": return new Capacitor(definition, item);", - "case \"ChargedWeapon\": return new ChargedWeapon(definition, item);", - "case \"Cockpit\": return new Cockpit(definition, item);", - "case \"Cooldown\": return new Cooldown(definition, item);", - "case \"ConstantWeapon\": return new ConstantWeapon(definition, item);", - "case \"EnergyDraw\": return new EnergyDraw(definition, item);", - "case \"GuidedWeapon\": return new InstantWeapon(definition, item);", - "case \"Heat\": return new Heat(definition, item);", - "case \"HeatStorage\": return new HeatStorage(definition, item);", - "case \"InstantWeapon\": return new InstantWeapon(definition, item);", - "case \"ItemUsage\": return new ItemUsage(definition, item);", - "case \"Launcher\": return new LockWeapon(definition, item);", - "case \"LockWeapon\": return new LockWeapon(definition, item);", - "case \"MiningTool\": return new MiningTool(definition, item);", - "case \"Radiator\": return new Radiator(definition, item);", - "case \"Reactor\": return new Reactor(definition, item);", - "case \"Reflector\": return new Reflector(definition, item);", - "case \"ResourceScanner\": return new ResourceScanner(definition, item);", - "case \"Sensor\": return new Sensor(definition, item);", - "case \"Shield\": return new Shield(definition, item);", - "case \"StatModifier\": return new StatModifier(definition, item);", - "case \"Switch\": return new Switch(definition, item);", - "case \"Thermotoggle\": return new Thermotoggle(definition, item);", - "case \"Thruster\": return new Thruster(definition, item);", - "case \"Trigger\": return new Trigger(definition, item);", - "case \"TurretController\": return new TurretController(definition, item);", - "case \"VelocityConversion\": return new VelocityConversion(definition, item);", - "case \"VelocityLimit\": return new VelocityLimit(definition, item);", - "case \"Visibility\": return new Visibility(definition, item);", - "case \"Wear\": return new Wear(definition, item);", - "case \"AetherDrive\": return new AetherDrive(definition, effect);", - "case \"AutoWeapon\": return new InstantWeapon(definition, effect);", - "case \"Capacitor\": return new Capacitor(definition, effect);", - "case \"ChargedWeapon\": return new ChargedWeapon(definition, effect);", - "case \"Cockpit\": return new Cockpit(definition, effect);", - "case \"Cooldown\": return new Cooldown(definition, effect);", - "case \"ConstantWeapon\": return new ConstantWeapon(definition, effect);", - "case \"EnergyDraw\": return new EnergyDraw(definition, effect);", - "case \"GuidedWeapon\": return new InstantWeapon(definition, effect);", - "case \"Heat\": return new Heat(definition, effect);", - "case \"HeatStorage\": return new HeatStorage(definition, effect);", - "case \"InstantWeapon\": return new InstantWeapon(definition, effect);", - "case \"ItemUsage\": return new ItemUsage(definition, effect);", - "case \"Launcher\": return new LockWeapon(definition, effect);", - "case \"LockWeapon\": return new LockWeapon(definition, effect);", - "case \"MiningTool\": return new MiningTool(definition, effect);", - "case \"Radiator\": return new Radiator(definition, effect);", - "case \"Reactor\": return new Reactor(definition, effect);", - "case \"Reflector\": return new Reflector(definition, effect);", - "case \"ResourceScanner\": return new ResourceScanner(definition, effect);", - "case \"Sensor\": return new Sensor(definition, effect);", - "case \"Shield\": return new Shield(definition, effect);", - "case \"StatModifier\": return new StatModifier(definition, effect);", - "case \"Switch\": return new Switch(definition, effect);", - "case \"Thermotoggle\": return new Thermotoggle(definition, effect);", - "case \"Thruster\": return new Thruster(definition, effect);", - "case \"Trigger\": return new Trigger(definition, effect);", - "case \"TurretController\": return new TurretController(definition, effect);", - "case \"VelocityConversion\": return new VelocityConversion(definition, effect);", - "case \"VelocityLimit\": return new VelocityLimit(definition, effect);", - "case \"Visibility\": return new Visibility(definition, effect);", - "case \"Wear\": return new Wear(definition, effect);" + "public static class AetheriaRuntimeBehaviorStateProjector", + "public static void EnsureEquipmentBehaviorStates(", + "public static AetheriaRuntimeBehaviorStateCommit[] CreateEquipmentBehaviorStates(", + "AetheriaRuntimeCatalogSnapshot? catalog", + "catalog.FindItem(item.ItemKey ?? \"\")", + "typedItem?.BehaviorPayloads", + "OwnerKind = EquipmentOwnerKind", + "BehaviorKind = payload.Kind" }; - var missingFactoryMappings = requiredFactoryMappings - .Where(symbol => !itemManager.Contains(symbol, StringComparison.Ordinal)) + var missingBehaviorStateProjection = requiredBehaviorStateProjection + .Where(symbol => !behaviorStateProjector.Contains(symbol, StringComparison.Ordinal)) .ToArray(); - if (missingFactoryMappings.Length > 0) + if (missingBehaviorStateProjection.Length > 0) { throw new InvalidOperationException( - "Typed runtime behavior factory no longer covers the live behavior payload kinds: " + - string.Join(", ", missingFactoryMappings)); + "Typed runtime behavior authority must derive daemon behavior-state rows from catalog payloads instead of Unity behavior objects: " + + string.Join(", ", missingBehaviorStateProjection)); + } + + if (!daemonProgram.Contains("node.RuntimeCatalog().Latest() ?? new AetheriaRuntimeCatalogSnapshot()", StringComparison.Ordinal) || + !daemonProgram.Contains("AetheriaRuntimeBehaviorStateProjector.CreateEquipmentBehaviorStates(equipment, catalog)", StringComparison.Ordinal) || + !daemonProgram.Contains("AetheriaRuntimeBehaviorStateProjector.EnsureEquipmentBehaviorStates(runtimeEntity, catalog)", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Daemon runtime checkpoint projection must publish typed behavior-state rows from managed catalog payloads."); + } + + if (!unityProject.Contains("AetheriaRuntimeBehaviorStateProjector.cs", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Unity-compatible shared runtime builds must include the typed behavior-state projector."); } var requiredWeaponStateCoverage = new[] diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index 325e64881..26d613ab1 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -4138,6 +4138,121 @@ public class DaemonRuntimeDocumentTests Assert.IsTrue(result.Intents.SensorPingRequested); } + [Test] + public void BehaviorStateProjectorDerivesEquipmentRowsFromCatalogPayloads() + { + var catalog = new AetheriaRuntimeCatalogSnapshot( + new[] + { + CatalogItem( + "test-relay", + new[] + { + new AetheriaRuntimeBehaviorPayload( + 3, + "SensorRelay", + 0, + Array.Empty()), + new AetheriaRuntimeBehaviorPayload( + 4, + "Shield", + 1, + Array.Empty()) + }) + }, + Array.Empty(), + Array.Empty()); + var equipment = new[] + { + new AetheriaRuntimeLoadoutItemSlotCommit + { + Item = new AetheriaRuntimeLoadoutItemCommit + { + ItemKey = "test-relay", + Enabled = true + } + } + }; + + var states = AetheriaRuntimeBehaviorStateProjector.CreateEquipmentBehaviorStates(equipment, catalog); + + Assert.AreEqual(2, states.Length); + Assert.AreEqual(AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, states[0].OwnerKind); + Assert.AreEqual(0, states[0].OwnerIndex); + Assert.AreEqual(0, states[0].BehaviorIndex); + Assert.AreEqual("SensorRelay", states[0].BehaviorKind); + Assert.AreEqual(AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, states[1].OwnerKind); + Assert.AreEqual(0, states[1].OwnerIndex); + Assert.AreEqual(1, states[1].BehaviorIndex); + Assert.AreEqual("Shield", states[1].BehaviorKind); + } + + [Test] + public void DaemonOperationsAcceptsBehaviorIntentFromCatalogProjectedState() + { + var run = RunWithTwoEntities(); + var actor = run.Zones[0].Entities[0]; + actor.BehaviorStates = new[] + { + new AetheriaRuntimeBehaviorStateCommit + { + OwnerKind = AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, + OwnerIndex = 0, + BehaviorIndex = 1, + BehaviorKind = "StaleLocalBehavior" + } + }; + actor.Equipment = new[] + { + new AetheriaRuntimeLoadoutItemSlotCommit + { + Item = new AetheriaRuntimeLoadoutItemCommit + { + ItemKey = "test-relay", + Enabled = true + } + } + }; + var catalog = new AetheriaRuntimeCatalogSnapshot( + new[] + { + CatalogItem( + "test-relay", + new[] + { + new AetheriaRuntimeBehaviorPayload( + 7, + "SensorRelay", + 0, + Array.Empty()) + }) + }, + Array.Empty(), + Array.Empty()); + AetheriaRuntimeBehaviorStateProjector.EnsureEquipmentBehaviorStates(actor, catalog); + Assert.AreEqual(1, actor.BehaviorStates.Count); + Assert.AreEqual("SensorRelay", actor.BehaviorStates[0].BehaviorKind); + var command = AetheriaRuntimeDaemonCommandDocument.Create( + AetheriaRuntimeDaemonCommandKinds.SetBehaviorActive, + "codex", + "session-intents", + 36, + "zone.0.entity.0"); + command.EquipmentIndex = 0; + command.BehaviorIndex = 0; + command.ScalarValue = 1.0; + + var result = AetheriaRuntimeDaemonOperations.Execute(run, new[] { command }); + + Assert.AreEqual(1, result.AppliedCommandIds.Count); + Assert.AreEqual(0, result.RejectedCommandIds.Count); + Assert.AreEqual(1, result.Intents.Behaviors.Count); + Assert.AreEqual("zone.0.entity.0", result.Intents.Behaviors[0].ActorEntityKey); + Assert.AreEqual(0, result.Intents.Behaviors[0].EquipmentIndex); + Assert.AreEqual(0, result.Intents.Behaviors[0].BehaviorIndex); + Assert.IsTrue(result.Intents.Behaviors[0].Active); + } + [Test] public void DaemonOperationsRejectsWeaponGroupIntentForMissingGroup() { diff --git a/GameCult.Aetheria.State.Unity.csproj b/GameCult.Aetheria.State.Unity.csproj index 2f1300ec7..6ab4cf7e6 100644 --- a/GameCult.Aetheria.State.Unity.csproj +++ b/GameCult.Aetheria.State.Unity.csproj @@ -50,6 +50,7 @@ + diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorStateProjector.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorStateProjector.cs new file mode 100644 index 000000000..a5775c082 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorStateProjector.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +#nullable enable + +namespace GameCult.Aetheria.State.Verse +{ + public static class AetheriaRuntimeBehaviorStateProjector + { + public const string EquipmentOwnerKind = "equipment"; + + public static void EnsureEquipmentBehaviorStates( + AetheriaRuntimeEntitySnapshotCommit? entity, + AetheriaRuntimeCatalogSnapshot? catalog) + { + if (entity == null) + return; + + var existing = entity.BehaviorStates ?? Array.Empty(); + var projected = CreateEquipmentBehaviorStates(entity.Equipment, catalog); + if (projected.Length == 0) + { + entity.BehaviorStates = existing + .Where(state => state != null && !string.Equals(state.OwnerKind, EquipmentOwnerKind, StringComparison.Ordinal)) + .ToArray(); + return; + } + + var existingByKey = existing + .Where(state => state != null) + .GroupBy(StateKey, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); + var nonEquipmentStates = existing + .Where(state => state != null && !string.Equals(state.OwnerKind, EquipmentOwnerKind, StringComparison.Ordinal)); + var equipmentStates = projected + .Select(state => existingByKey.TryGetValue(StateKey(state), out var current) ? current : state); + entity.BehaviorStates = nonEquipmentStates + .Concat(equipmentStates) + .OrderBy(state => state.OwnerKind, StringComparer.Ordinal) + .ThenBy(state => state.OwnerIndex) + .ThenBy(state => state.BehaviorIndex) + .ToArray(); + } + + public static AetheriaRuntimeBehaviorStateCommit[] CreateEquipmentBehaviorStates( + IReadOnlyList? equipment, + AetheriaRuntimeCatalogSnapshot? catalog) + { + catalog ??= new AetheriaRuntimeCatalogSnapshot(); + return (equipment ?? Array.Empty()) + .SelectMany((slot, equipmentIndex) => CreateEquipmentBehaviorStates(slot?.Item, equipmentIndex, catalog)) + .ToArray(); + } + + private static IEnumerable CreateEquipmentBehaviorStates( + AetheriaRuntimeLoadoutItemCommit? item, + int equipmentIndex, + AetheriaRuntimeCatalogSnapshot catalog) + { + if (item == null || equipmentIndex < 0) + yield break; + + var typedItem = catalog.FindItem(item.ItemKey ?? ""); + var payloads = typedItem?.BehaviorPayloads ?? Array.Empty(); + for (var behaviorIndex = 0; behaviorIndex < payloads.Count; behaviorIndex++) + { + var payload = payloads[behaviorIndex]; + if (payload == null || string.IsNullOrWhiteSpace(payload.Kind)) + continue; + + yield return new AetheriaRuntimeBehaviorStateCommit + { + OwnerKind = EquipmentOwnerKind, + OwnerIndex = equipmentIndex, + BehaviorIndex = behaviorIndex, + BehaviorKind = payload.Kind + }; + } + } + + private static string StateKey(AetheriaRuntimeBehaviorStateCommit? state) + { + if (state == null) + return ""; + + return string.Join( + "/", + state.OwnerKind ?? "", + state.OwnerIndex.ToString(System.Globalization.CultureInfo.InvariantCulture), + state.BehaviorIndex.ToString(System.Globalization.CultureInfo.InvariantCulture), + state.BehaviorKind ?? ""); + } + } +}