diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 4b1fe544a..1a402d63b 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -816,6 +816,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks BehaviorChainsGateAuthoritativeStatModifiers, ResourceBehaviorsStopEquipmentChainsInAuthoredOrder, MiningAndScanningResultsGateTrailingBehaviorChains, + BehaviorStateProjectionIsReadStableAndReconcilesShapeChanges, ThermotoggleSeedsAndGatesFromDaemonOwnedTarget, InputCapabilityPublishesExactBehaviorLevers, InputCapabilityPublishesConsumableActionBarState, @@ -1328,6 +1329,55 @@ internal sealed class AetheriaDaemonYmirSmokeChecks } } + private static void BehaviorStateProjectionIsReadStableAndReconcilesShapeChanges() + { + var thermostat = CatalogItem("projection-thermostat", + new AetheriaRuntimeBehaviorPayload(0, "Thermotoggle", 0, + [ + new AetheriaRuntimeBehaviorField(1, Number(300)) + ])); + var catalog = new AetheriaRuntimeCatalogSnapshot([thermostat], [], []); + var entity = Entity(0, 0, "projection-player"); + entity.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit + { + Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = thermostat.ItemKey, Enabled = true, Durability = 1, Quality = 1 } + }]; + + AetheriaRuntimeBehaviorStateProjector.EnsureEquipmentBehaviorStates(entity, catalog); + var originalStates = entity.BehaviorStates; + var originalThermostat = originalStates.Single(); + originalThermostat.ThermotoggleTargetTemperature = 412; + + AetheriaRuntimeBehaviorStateProjector.EnsureEquipmentBehaviorStates(entity, catalog); + Require(ReferenceEquals(originalStates, entity.BehaviorStates), + "an unchanged equipment graph must remain a read and preserve the behavior-state array"); + RequireNear(412, entity.BehaviorStates.Single().ThermotoggleTargetTemperature, 0.000001, + "an unchanged equipment graph must preserve daemon-owned mutable behavior state"); + + var expandedThermostat = CatalogItem("projection-thermostat", + new AetheriaRuntimeBehaviorPayload(0, "Thermotoggle", 0, + [ + new AetheriaRuntimeBehaviorField(1, Number(300)) + ]), + new AetheriaRuntimeBehaviorPayload(1, "Radiator", 0, [])); + var expandedCatalog = new AetheriaRuntimeCatalogSnapshot([expandedThermostat], [], []); + AetheriaRuntimeBehaviorStateProjector.EnsureEquipmentBehaviorStates(entity, expandedCatalog); + + Require(!ReferenceEquals(originalStates, entity.BehaviorStates), + "a changed catalog behavior shape must reconcile the projected behavior-state array"); + RequireEqual(2, entity.BehaviorStates.Count, + "catalog reconciliation must add the newly authored behavior state exactly once"); + Require(ReferenceEquals(originalThermostat, + entity.BehaviorStates.Single(state => state.BehaviorKind == "Thermotoggle")), + "catalog reconciliation must retain matching daemon-owned behavior state"); + RequireNear(412, + entity.BehaviorStates.Single(state => state.BehaviorKind == "Thermotoggle") + .ThermotoggleTargetTemperature, + 0.000001, + "catalog reconciliation must not reset retained mutable behavior state"); + } + private static void ThermotoggleSeedsAndGatesFromDaemonOwnedTarget() { var thermostat = CatalogItem("thermostat", diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorSimulation.cs index 497ad8dd2..45111981a 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorSimulation.cs @@ -37,7 +37,17 @@ namespace GameCult.Aetheria.State.Verse int targetEquipmentIndex, AetheriaRuntimeBehaviorPayload targetBehavior, int targetFieldKey, - double baseline) + double baseline) => + Apply(entity, catalog, targetEquipmentIndex, targetBehavior, targetFieldKey, baseline, null); + + internal static double Apply( + AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot catalog, + int targetEquipmentIndex, + AetheriaRuntimeBehaviorPayload targetBehavior, + int targetFieldKey, + double baseline, + IReadOnlyList? equippedModifiers) { var targetStatName = StatName(targetBehavior?.Kind, targetFieldKey); if (entity == null || catalog == null || targetEquipmentIndex < 0 || @@ -46,7 +56,7 @@ namespace GameCult.Aetheria.State.Verse double multiplier = 1; double constant = 0; - foreach (var modifier in ActiveEquippedModifiers(entity, catalog) + foreach (var modifier in ActiveEquippedModifiers(entity, catalog, equippedModifiers) .Concat(ActiveConsumableModifiers(entity, catalog))) { if (!Targets(modifier.Payload, entity, catalog, targetEquipmentIndex, targetBehavior, targetStatName)) @@ -360,9 +370,11 @@ namespace GameCult.Aetheria.State.Verse private static IEnumerable ActiveEquippedModifiers( AetheriaRuntimeEntitySnapshotCommit entity, - AetheriaRuntimeCatalogSnapshot catalog) + AetheriaRuntimeCatalogSnapshot catalog, + IReadOnlyList? equippedModifiers = null) { - foreach (var behavior in AetheriaRuntimeEquippedBehaviorQueries.FindOperational(entity, catalog, "StatModifier")) + foreach (var behavior in equippedModifiers ?? + AetheriaRuntimeEquippedBehaviorQueries.FindOperational(entity, catalog, "StatModifier")) { if (!behavior.State.StatModifierApplied) continue; diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorStateProjector.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorStateProjector.cs index 9175de931..0fb1147f3 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorStateProjector.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorStateProjector.cs @@ -18,6 +18,8 @@ namespace GameCult.Aetheria.State.Verse return; var existing = entity.BehaviorStates ?? Array.Empty(); + if (EquipmentBehaviorStatesMatch(existing, entity.Equipment, catalog)) + return; var projected = CreateEquipmentBehaviorStates(entity.Equipment, catalog); if (projected.Length == 0) { @@ -95,5 +97,37 @@ namespace GameCult.Aetheria.State.Verse state.BehaviorIndex.ToString(System.Globalization.CultureInfo.InvariantCulture), state.BehaviorKind ?? ""); } + + private static bool EquipmentBehaviorStatesMatch( + IReadOnlyList existing, + IReadOnlyList? equipment, + AetheriaRuntimeCatalogSnapshot? catalog) + { + catalog ??= new AetheriaRuntimeCatalogSnapshot(); + var matched = 0; + var slots = equipment ?? Array.Empty(); + for (var equipmentIndex = 0; equipmentIndex < slots.Count; equipmentIndex++) + { + var item = slots[equipmentIndex]?.Item; + var payloads = catalog.FindItem(item?.ItemKey ?? "")?.BehaviorPayloads ?? + Array.Empty(); + for (var behaviorIndex = 0; behaviorIndex < payloads.Count; behaviorIndex++) + { + var payload = payloads[behaviorIndex]; + if (payload == null || string.IsNullOrWhiteSpace(payload.Kind)) + continue; + matched++; + if (!existing.Any(state => state != null && + string.Equals(state.OwnerKind, EquipmentOwnerKind, StringComparison.Ordinal) && + state.OwnerIndex == equipmentIndex && + state.BehaviorIndex == behaviorIndex && + string.Equals(state.BehaviorKind, payload.Kind, StringComparison.Ordinal))) + return false; + } + } + + return existing.Count(state => state != null && + string.Equals(state.OwnerKind, EquipmentOwnerKind, StringComparison.Ordinal)) == matched; + } } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index bc5a2026d..74eca05aa 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -36,10 +36,15 @@ namespace GameCult.Aetheria.State.Verse Environment.GetEnvironmentVariable("AETHERIA_TRACE_SIMULATION_PHASES"), "1", StringComparison.Ordinal); + var traceSimulationPhaseThreshold = int.TryParse( + Environment.GetEnvironmentVariable("AETHERIA_TRACE_SIMULATION_PHASE_THRESHOLD_MS"), + out var configuredTraceThreshold) + ? Math.Max(0, configuredTraceThreshold) + : 20; var phase = Stopwatch.StartNew(); void TracePhase(string name) { - if (traceSimulationPhases && phase.ElapsedMilliseconds >= 20) + if (traceSimulationPhases && phase.ElapsedMilliseconds >= traceSimulationPhaseThreshold) Console.WriteLine($"Aetheria simulation phase {name} took {phase.ElapsedMilliseconds}ms."); phase.Restart(); } @@ -597,9 +602,13 @@ namespace GameCult.Aetheria.State.Verse RefreshShieldProjection(entity, catalog); foreach (var attacker in combatEntities) { - var requestedWeapons = ResolveWeaponTriggers(zone, attacker, entities, intents, catalog); var operationalBehaviors = AetheriaRuntimeEquippedBehaviorQueries .FindAllOperational(attacker, catalog); + if (!operationalBehaviors.Any(behavior => + AetheriaRuntimeBehaviorMetadataCatalog.IsKindOrDescendant( + behavior.Payload.Kind, "Weapon"))) + continue; + var requestedWeapons = ResolveWeaponTriggers(zone, attacker, entities, intents, catalog); StepDeployableWeapons(run, zone, attacker, operationalBehaviors, requestedWeapons, deltaSeconds, settings, catalog, frameId); StepChargedWeapons(run, zone, attacker, byIndex, requestedWeapons, deltaSeconds, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEquippedBehaviorQueries.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEquippedBehaviorQueries.cs index 6fa57e4ef..7ef0ac470 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEquippedBehaviorQueries.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEquippedBehaviorQueries.cs @@ -29,6 +29,8 @@ namespace GameCult.Aetheria.State.Verse public sealed class AetheriaRuntimeEquippedBehavior { + private IReadOnlyList? modifierBehaviors; + public AetheriaRuntimeEquippedBehavior( AetheriaRuntimeEntitySnapshotCommit entity, AetheriaRuntimeCatalogSnapshot catalog, @@ -61,6 +63,9 @@ namespace GameCult.Aetheria.State.Verse public AetheriaRuntimeBehaviorPayload Payload { get; } public AetheriaRuntimeBehaviorStateCommit State { get; } + internal void BindModifierBehaviors(IReadOnlyList value) => + modifierBehaviors = value ?? Array.Empty(); + public double EvaluateStat(int fieldKey, double thermalPerformance = 1.0) { var field = (Payload.Fields ?? Array.Empty()) @@ -70,7 +75,7 @@ namespace GameCult.Aetheria.State.Verse Item, Math.Max(0, Math.Min(1, thermalPerformance))); return AetheriaRuntimeBehaviorSimulation.Apply( - Entity, Catalog, EquipmentIndex, Payload, fieldKey, baseline); + Entity, Catalog, EquipmentIndex, Payload, fieldKey, baseline, modifierBehaviors); } } @@ -131,6 +136,18 @@ namespace GameCult.Aetheria.State.Verse state)); } } + + var equipmentStates = (entity.EquipmentStates ?? Array.Empty()) + .Where(state => state != null) + .ToDictionary(state => state.EquipmentIndex); + var modifierBehaviors = found + .Where(value => AetheriaRuntimeBehaviorMetadataCatalog.IsKindOrDescendant( + value.Payload.Kind, "StatModifier")) + .Where(value => value.Item.Enabled && value.Item.Durability > 0.01 && + (!equipmentStates.TryGetValue(value.EquipmentIndex, out var state) || state.Online)) + .ToArray(); + foreach (var behavior in found) + behavior.BindModifierBehaviors(modifierBehaviors); return found; } diff --git a/docs/aetheria-current-codebase-model.md b/docs/aetheria-current-codebase-model.md index e8888619d..9e1767c43 100644 --- a/docs/aetheria-current-codebase-model.md +++ b/docs/aetheria-current-codebase-model.md @@ -353,6 +353,18 @@ lifecycle state in the authoritative snapshot. Runtime stat reads derive the fossil multiplier-product and constant-sum result from those sources, including required-behavior and descendant-kind targeting. This makes reconnect replay idempotent and keeps the Unity fossil out of the gameplay authority path. +Behavior-state projection is a reconciliation write only: when equipment and +catalog behavior shape already match, reads preserve the existing state array +and every mutable daemon-owned value. An actual shape change rebuilds the +projection while retaining states whose owner, equipment index, behavior index, +and kind still match. + +Each equipped-behavior query also derives one ephemeral set of operational +`StatModifier` behaviors and binds that set to every wrapper returned by the +query. All stat evaluations through those wrappers reuse the same set instead +of recursively rebuilding the equipment graph for every field. The set is not +persisted, cannot outlive the query result, and cannot write authority; modifier +lifecycle remains in the entity snapshot. The same daemon organ executes common resource gates in group order. Energy, thermal cells, cargo, and durability remain owned by their narrow transaction subsystems; behavior composition decides only whether and when to call them. @@ -367,6 +379,8 @@ same set. It is not persisted and cannot outlive the step; mutations still land on the referenced authoritative item and behavior state. This prevents each weapon family from independently rebuilding the same equipment graph without introducing a cache owner beside the entity snapshot. +Entities without an operational descendant of `Weapon` do not enter the weapon +actor loop. They remain valid targets and physical-payload participants. Migration target: