Project behavior state from daemon catalog payloads

This commit is contained in:
Meta Vens 2026-07-05 20:19:25 +01:00
commit 62f9bccfc1
5 changed files with 277 additions and 89 deletions

View file

@ -1952,6 +1952,7 @@ static async Task<AetheriaRuntimeRunCheckpointCommit?> ReadRuntimeRunCheckpointA
if (run == null)
return null;
var catalog = node.RuntimeCatalog().Latest() ?? new AetheriaRuntimeCatalogSnapshot();
var zones = new List<AetheriaRuntimeZoneSnapshotCommit>();
var zoneKeys = run.ZoneKeys ?? Array.Empty<string>();
for (var zoneIndex = 0; zoneIndex < zoneKeys.Length; zoneIndex++)
@ -1960,7 +1961,7 @@ static async Task<AetheriaRuntimeRunCheckpointCommit?> 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<AetheriaRuntimeZoneSnapshotCommit> ToRuntimeZoneAsync(
AetheriaStateNode node,
AetheriaZoneState zone,
int zoneIndex,
AetheriaRuntimeDaemonRenderSettings renderSettings)
AetheriaRuntimeDaemonRenderSettings renderSettings,
AetheriaRuntimeCatalogSnapshot catalog)
{
var entityKeys = zone.EntityKeys ?? Array.Empty<string>();
var entityIndices = entityKeys
@ -2002,7 +2004,7 @@ static async Task<AetheriaRuntimeZoneSnapshotCommit> ToRuntimeZoneAsync(
{
var entity = await node.MutableDocument<AetheriaEntitySnapshot>(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<AetheriaRuntimeZoneSnapshotCommit> ToRuntimeZoneAsync(
static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity(
AetheriaEntitySnapshot entity,
int entityIndex,
IReadOnlyDictionary<string, int> entityIndices)
IReadOnlyDictionary<string, int> 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<string>())
@ -2092,7 +2096,7 @@ static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity(
})
.ToArray(),
WeaponStates = Array.Empty<AetheriaRuntimeWeaponStateCommit>(),
BehaviorStates = Array.Empty<AetheriaRuntimeBehaviorStateCommit>(),
BehaviorStates = AetheriaRuntimeBehaviorStateProjector.CreateEquipmentBehaviorStates(equipment, catalog),
CargoContents = ToCargoBayCommits(entity.CargoContents),
DockingBayContents = ToCargoBayCommits(entity.DockingBayContents),
DockingBayAssignments = (entity.DockingBayAssignments ?? Array.Empty<int>()).ToArray(),
@ -2103,6 +2107,8 @@ static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity(
.Where(contact => contact.TargetEntityIndex >= 0)
.ToArray()
};
AetheriaRuntimeBehaviorStateProjector.EnsureEquipmentBehaviorStates(runtimeEntity, catalog);
return runtimeEntity;
}
static AetheriaRuntimeEntityContactCommit ToRuntimeContact(

View file

@ -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[]

View file

@ -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<AetheriaRuntimeBehaviorField>()),
new AetheriaRuntimeBehaviorPayload(
4,
"Shield",
1,
Array.Empty<AetheriaRuntimeBehaviorField>())
})
},
Array.Empty<AetheriaRuntimeCorporation>(),
Array.Empty<AetheriaRuntimeNameFile>());
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<AetheriaRuntimeBehaviorField>())
})
},
Array.Empty<AetheriaRuntimeCorporation>(),
Array.Empty<AetheriaRuntimeNameFile>());
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()
{

View file

@ -50,6 +50,7 @@
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeAssemblyInfo.cs" />
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeEquippedItemDetailsSurfaceBuilder.cs" />
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeBehaviorMetadata.cs" />
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeBehaviorStateProjector.cs" />
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeAssets.cs" />
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeStateBoundary.cs" />
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeTradeItemDetailsSurfaceBuilder.cs" />

View file

@ -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<AetheriaRuntimeBehaviorStateCommit>();
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<AetheriaRuntimeLoadoutItemSlotCommit>? equipment,
AetheriaRuntimeCatalogSnapshot? catalog)
{
catalog ??= new AetheriaRuntimeCatalogSnapshot();
return (equipment ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.SelectMany((slot, equipmentIndex) => CreateEquipmentBehaviorStates(slot?.Item, equipmentIndex, catalog))
.ToArray();
}
private static IEnumerable<AetheriaRuntimeBehaviorStateCommit> 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<AetheriaRuntimeBehaviorPayload>();
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 ?? "");
}
}
}