From 95641b52c9b2ba9aa9a9e56473b81333f2c9ecf2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 17 Jul 2026 02:55:07 +0100 Subject: [PATCH] Run stat modifier behavior chains in daemon --- Aetheria.State.Daemon.Smoke/Program.cs | 75 +++++ .../AetheriaRuntimeConsumableSimulation.cs | 21 +- .../AetheriaRuntimeDaemonSimulation.cs | 5 + .../AetheriaRuntimeEquippedBehaviorQueries.cs | 12 +- .../AetheriaRuntimeSnapshotDocuments.cs | 9 + .../AetheriaRuntimeStatModifierSimulation.cs | 267 ++++++++++++++++++ docs/aetheria-current-codebase-model.md | 7 + docs/aetheria-perfect-machine-map.md | 13 + docs/original-game-migration-matrix.md | 2 +- 9 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStatModifierSimulation.cs diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 656479e07..b8db948fd 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -174,6 +174,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks CargoTransferUsesSpatialAtomicTransaction, DockedRefitSurfacePublishesGenericCommands, FossilVelocityBehaviorsRunInDaemonFlightStep, + BehaviorChainsGateAuthoritativeStatModifiers, ReflectorUsesSharedStellarLightFieldWithoutAccumulating, SensorPingIsActorScopedEnergyGatedAndReconnectable, TurretControllerAcquiresAimsAndTriggersExactWeapons, @@ -222,6 +223,70 @@ internal sealed class AetheriaDaemonYmirSmokeChecks RunCheck(check); } + private static void BehaviorChainsGateAuthoritativeStatModifiers() + { + var thruster = CatalogItem("modifier-target", new AetheriaRuntimeBehaviorPayload( + 0, "Thruster", 0, + [new AetheriaRuntimeBehaviorField(1, PerformanceStat(10))])); + var controls = CatalogItem("modifier-controls", + new AetheriaRuntimeBehaviorPayload(0, "Switch", 0, []), + new AetheriaRuntimeBehaviorPayload(1, "StatModifier", 0, + [ + new AetheriaRuntimeBehaviorField(1, StatReference("ThrusterData", "Thrust")), + new AetheriaRuntimeBehaviorField(2, PerformanceStat(2)), + new AetheriaRuntimeBehaviorField(3, StringValue("Multiplier")) + ]), + new AetheriaRuntimeBehaviorPayload(2, "Trigger", 1, []), + new AetheriaRuntimeBehaviorPayload(3, "StatModifier", 1, + [ + new AetheriaRuntimeBehaviorField(1, StatReference("Thruster", "Thrust")), + new AetheriaRuntimeBehaviorField(2, PerformanceStat(5)), + new AetheriaRuntimeBehaviorField(3, StringValue("Constant")) + ])); + var catalog = new AetheriaRuntimeCatalogSnapshot([thruster, controls], [], []); + var entity = Entity(0, 0, "player"); + entity.Equipment = + [ + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = thruster.ItemKey, Enabled = true, Durability = 1, Quality = 1 } }, + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = controls.ItemKey, Enabled = true, Durability = 1, Quality = 1 } } + ]; + var activate = new[] + { + new AetheriaRuntimeDaemonBehaviorIntent + { ActorEntityKey = "zone.0.entity.0", EquipmentIndex = 1, BehaviorIndex = 0, Active = true }, + new AetheriaRuntimeDaemonBehaviorIntent + { ActorEntityKey = "zone.0.entity.0", EquipmentIndex = 1, BehaviorIndex = 2, Active = true } + }; + + AetheriaRuntimeStatModifierSimulation.Step(0, [entity], + [new AetheriaRuntimeDaemonBehaviorIntent + { ActorEntityKey = "zone.1.entity.0", EquipmentIndex = 1, BehaviorIndex = 0, Active = true }], + catalog); + var target = AetheriaRuntimeEquippedBehaviorQueries.FindOperational(entity, catalog, "Thruster").Single(); + RequireNear(10, target.EvaluateStat(1), 0.000001, + "a behavior command for the same entity index in another zone must not cross-activate this ship"); + + AetheriaRuntimeStatModifierSimulation.Step(0, [entity], activate, catalog); + RequireNear(25, target.EvaluateStat(1), 0.000001, + "switch and one-shot trigger groups must multiply then add through the shared daemon stat path"); + var modifierStates = entity.BehaviorStates.Where(value => value.BehaviorKind == "StatModifier").ToArray(); + Require(modifierStates.All(value => value.StatModifierApplied && value.StatModifierTargetStatCount == 1), + "applied modifier state must name the exact live target count"); + + AetheriaRuntimeStatModifierSimulation.Step(0, [entity], [], catalog); + RequireNear(20, target.EvaluateStat(1), 0.000001, + "trigger modifiers must disappear on the next tick without another pull"); + + AetheriaRuntimeStatModifierSimulation.Step(0, [entity], + [new AetheriaRuntimeDaemonBehaviorIntent + { ActorEntityKey = "zone.0.entity.0", EquipmentIndex = 1, BehaviorIndex = 0, Active = false }], + catalog); + RequireNear(10, target.EvaluateStat(1), 0.000001, + "deactivating the switch must restore the unmodified authored stat exactly"); + } + private static void DockedRefitSurfacePublishesGenericCommands() { var hull = HullCatalogItem("surface-hull", 9, 7, 100); @@ -3072,6 +3137,16 @@ internal sealed class AetheriaDaemonYmirSmokeChecks Array.Empty(), Array.Empty()); + private static AetheriaRuntimeBehaviorValue StringValue(string value) => new( + "string", value, 0, false, "", "", + Array.Empty(), + Array.Empty()); + + private static AetheriaRuntimeBehaviorValue StatReference(string target, string stat) => new( + "stat-reference", "", 0, false, "", "", + [StringValue(""), StringValue(target), StringValue(stat)], + Array.Empty()); + private static AetheriaRuntimeBehaviorValue Vector(params double[] values) => new( "float3", "", diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeConsumableSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeConsumableSimulation.cs index 3ad4b4819..9500ea4f6 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeConsumableSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeConsumableSimulation.cs @@ -101,6 +101,11 @@ namespace GameCult.Aetheria.State.Verse BehaviorStopped(run, zone, entity, effect, frameId, -1, "duplicate-behavior-id"); return; } + for (var behaviorIndex = 0; behaviorIndex < payloads.Count; behaviorIndex++) + if (payloads[behaviorIndex] != null && + string.Equals(payloads[behaviorIndex].Kind, "StatModifier", StringComparison.Ordinal)) + FindBehaviorState(effect, behaviorIndex, payloads[behaviorIndex]).StatModifierTargetStatCount = + AetheriaRuntimeStatModifierSimulation.CountTargets(entity, catalog!, payloads[behaviorIndex]); UpdateAlwaysUpdatedBehaviors(effect, payloads, effectiveness, deltaSeconds); for (var behaviorIndex = 0; behaviorIndex < payloads.Count; behaviorIndex++) { @@ -129,6 +134,9 @@ namespace GameCult.Aetheria.State.Verse reason = ""; switch (payload.Kind) { + case "StatModifier": + FindBehaviorState(effect, behaviorIndex, payload).StatModifierExecuted = true; + return true; case "Cooldown": { var state = FindBehaviorState(effect, behaviorIndex, payload); @@ -223,7 +231,18 @@ namespace GameCult.Aetheria.State.Verse for (var behaviorIndex = 0; behaviorIndex < payloads.Count; behaviorIndex++) { var payload = payloads[behaviorIndex]; - if (payload == null || !string.Equals(payload.Kind, "Cooldown", StringComparison.Ordinal)) + if (payload == null) + continue; + + if (string.Equals(payload.Kind, "StatModifier", StringComparison.Ordinal)) + { + var state = FindBehaviorState(effect, behaviorIndex, payload); + state.StatModifierApplied = state.StatModifierExecuted; + state.StatModifierExecuted = false; + continue; + } + + if (!string.Equals(payload.Kind, "Cooldown", StringComparison.Ordinal)) continue; var duration = Evaluate(payload, 1, effect, effectiveness); diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index 4356fd17f..71f6bec8c 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -68,6 +68,11 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeThermalSimulation.UpdateEquipmentStates(entity, catalog, deltaSeconds); AetheriaRuntimeEnergySimulation.BeginTick(entity, catalog); } + AetheriaRuntimeStatModifierSimulation.Step( + zone.ZoneIndex, + entities, + intents?.Behaviors, + catalog); AetheriaRuntimeConsumableSimulation.StepZone( run, zone, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEquippedBehaviorQueries.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEquippedBehaviorQueries.cs index 145ecb52a..cf7fc94d4 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEquippedBehaviorQueries.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEquippedBehaviorQueries.cs @@ -30,6 +30,8 @@ namespace GameCult.Aetheria.State.Verse public sealed class AetheriaRuntimeEquippedBehavior { public AetheriaRuntimeEquippedBehavior( + AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot catalog, int equipmentIndex, int behaviorIndex, AetheriaRuntimeLoadoutItemSlotCommit slot, @@ -38,6 +40,8 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeBehaviorPayload payload, AetheriaRuntimeBehaviorStateCommit state) { + Entity = entity; + Catalog = catalog; EquipmentIndex = equipmentIndex; BehaviorIndex = behaviorIndex; Slot = slot; @@ -47,6 +51,8 @@ namespace GameCult.Aetheria.State.Verse State = state; } + public AetheriaRuntimeEntitySnapshotCommit Entity { get; } + public AetheriaRuntimeCatalogSnapshot Catalog { get; } public int EquipmentIndex { get; } public int BehaviorIndex { get; } public AetheriaRuntimeLoadoutItemSlotCommit Slot { get; } @@ -59,10 +65,12 @@ namespace GameCult.Aetheria.State.Verse { var field = (Payload.Fields ?? Array.Empty()) .FirstOrDefault(candidate => candidate != null && candidate.Key == fieldKey); - return AetheriaRuntimeDaemonItemStatQueries.EvaluatePerformanceStat( + var baseline = AetheriaRuntimeDaemonItemStatQueries.EvaluatePerformanceStat( field?.Value, Item, Math.Max(0, Math.Min(1, thermalPerformance))); + return AetheriaRuntimeStatModifierSimulation.Apply( + Entity, Catalog, EquipmentIndex, Payload, fieldKey, baseline); } } @@ -100,6 +108,8 @@ namespace GameCult.Aetheria.State.Verse continue; } found.Add(new AetheriaRuntimeEquippedBehavior( + entity, + catalog, equipmentIndex, behaviorIndex, equipment[equipmentIndex], diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs index 37151636f..f4c7d2761 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs @@ -1253,6 +1253,15 @@ namespace GameCult.Aetheria.State.Verse [Key(3)] public string BehaviorId { get; set; } = ""; + + [Key(4)] + public bool StatModifierApplied { get; set; } + + [Key(5)] + public bool StatModifierExecuted { get; set; } + + [Key(6)] + public int StatModifierTargetStatCount { get; set; } } [MessagePackObject] diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStatModifierSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStatModifierSimulation.cs new file mode 100644 index 000000000..e8de4b5c8 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStatModifierSimulation.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +#nullable enable + +namespace GameCult.Aetheria.State.Verse +{ + public static class AetheriaRuntimeStatModifierSimulation + { + public static void Step( + int zoneIndex, + IReadOnlyList entities, + IEnumerable? intents, + AetheriaRuntimeCatalogSnapshot? catalog) + { + if (catalog == null) + return; + + var intentArray = (intents ?? Enumerable.Empty()).ToArray(); + foreach (var entity in entities ?? Array.Empty()) + { + if (entity == null) + continue; + + AetheriaRuntimeBehaviorStateProjector.EnsureEquipmentBehaviorStates(entity, catalog); + ApplyControlIntents(zoneIndex, entity, intentArray); + ExecuteEquipmentChains(entity, catalog); + } + } + + public static double Apply( + AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot catalog, + int targetEquipmentIndex, + AetheriaRuntimeBehaviorPayload targetBehavior, + int targetFieldKey, + double baseline) + { + var targetStatName = StatName(targetBehavior?.Kind, targetFieldKey); + if (entity == null || catalog == null || targetEquipmentIndex < 0 || + targetBehavior == null || string.IsNullOrWhiteSpace(targetStatName)) + return baseline; + + double multiplier = 1; + double constant = 0; + foreach (var modifier in ActiveEquippedModifiers(entity, catalog) + .Concat(ActiveConsumableModifiers(entity, catalog))) + { + if (!Targets(modifier.Payload, entity, catalog, targetEquipmentIndex, targetBehavior, targetStatName)) + continue; + if (IsMultiplier(modifier.Payload)) + multiplier *= modifier.Value; + else + constant += modifier.Value; + } + return baseline * multiplier + constant; + } + + public static int CountTargets( + AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot catalog, + AetheriaRuntimeBehaviorPayload modifier) + { + var count = 0; + var equipment = entity.Equipment ?? Array.Empty(); + for (var equipmentIndex = 0; equipmentIndex < equipment.Count; equipmentIndex++) + { + var item = catalog.FindItem(equipment[equipmentIndex]?.Item?.ItemKey ?? ""); + foreach (var behavior in item?.BehaviorPayloads ?? Array.Empty()) + { + if (behavior == null) + continue; + foreach (var stat in PerformanceStats(behavior.Kind)) + if (Targets(modifier, entity, catalog, equipmentIndex, behavior, stat.Name)) + count++; + } + } + return count; + } + + private static void ApplyControlIntents( + int zoneIndex, + AetheriaRuntimeEntitySnapshotCommit entity, + IReadOnlyList intents) + { + var states = entity.BehaviorStates ?? Array.Empty(); + foreach (var intent in intents.Where(value => value != null && + ((AetheriaRuntimeRunCheckpointCommit.TryParseEntityKey(value.ActorEntityKey ?? "", out var actorZone, out var actorEntity) && + actorZone == zoneIndex && actorEntity == entity.EntityIndex) || + string.Equals(value.ActorEntityKey, entity.EntityId, StringComparison.Ordinal)))) + { + var state = states.FirstOrDefault(value => value != null && + string.Equals(value.OwnerKind, AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, StringComparison.Ordinal) && + value.OwnerIndex == intent.EquipmentIndex && value.BehaviorIndex == intent.BehaviorIndex); + if (state == null) + continue; + if (string.Equals(state.BehaviorKind, "Switch", StringComparison.Ordinal)) + state.SwitchActivated = intent.Active; + else if (string.Equals(state.BehaviorKind, "Trigger", StringComparison.Ordinal) && intent.Active) + state.TriggerPulled = true; + } + } + + private static void ExecuteEquipmentChains( + AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot catalog) + { + var states = (entity.BehaviorStates ?? Array.Empty()) + .Where(value => value != null && string.Equals(value.OwnerKind, + AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, StringComparison.Ordinal)) + .ToDictionary(value => (value.OwnerIndex, value.BehaviorIndex)); + var online = (entity.EquipmentStates ?? Array.Empty()) + .Where(value => value != null).ToDictionary(value => value.EquipmentIndex); + var equipment = entity.Equipment ?? Array.Empty(); + for (var equipmentIndex = 0; equipmentIndex < equipment.Count; equipmentIndex++) + { + var equipped = equipment[equipmentIndex]?.Item; + var item = catalog.FindItem(equipped?.ItemKey ?? ""); + var operational = equipped != null && equipped.Enabled && equipped.Durability > 0.01 && + (!online.TryGetValue(equipmentIndex, out var equipmentState) || equipmentState.Online); + var payloads = item?.BehaviorPayloads ?? Array.Empty(); + + foreach (var entry in payloads.Select((payload, index) => (Payload: payload, Index: index))) + if (entry.Payload != null && string.Equals(entry.Payload.Kind, "StatModifier", StringComparison.Ordinal) && + states.TryGetValue((equipmentIndex, entry.Index), out var modifierState)) + modifierState.StatModifierExecuted = false; + + if (operational) + foreach (var group in payloads.Select((payload, index) => (Payload: payload, Index: index)) + .Where(value => value.Payload != null).GroupBy(value => value.Payload.Group).OrderBy(value => value.Key)) + foreach (var entry in group) + { + if (!states.TryGetValue((equipmentIndex, entry.Index), out var state)) + break; + if (string.Equals(entry.Payload.Kind, "Switch", StringComparison.Ordinal) && !state.SwitchActivated) + break; + if (string.Equals(entry.Payload.Kind, "Trigger", StringComparison.Ordinal)) + { + if (!state.TriggerPulled) + break; + state.TriggerPulled = false; + } + if (string.Equals(entry.Payload.Kind, "StatModifier", StringComparison.Ordinal)) + state.StatModifierExecuted = true; + } + + foreach (var entry in payloads.Select((payload, index) => (Payload: payload, Index: index))) + { + if (entry.Payload == null || !string.Equals(entry.Payload.Kind, "StatModifier", StringComparison.Ordinal) || + !states.TryGetValue((equipmentIndex, entry.Index), out var state)) + continue; + state.StatModifierApplied = state.StatModifierExecuted; + state.StatModifierTargetStatCount = CountTargets(entity, catalog, entry.Payload); + } + } + } + + private static IEnumerable ActiveEquippedModifiers( + AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot catalog) + { + foreach (var behavior in AetheriaRuntimeEquippedBehaviorQueries.FindOperational(entity, catalog, "StatModifier")) + { + if (!behavior.State.StatModifierApplied) + continue; + var value = Field(behavior.Payload, 2)?.Value; + var thermal = (entity.EquipmentStates ?? Array.Empty()) + .FirstOrDefault(state => state?.EquipmentIndex == behavior.EquipmentIndex)?.ThermalPerformance ?? 1; + yield return new ModifierValue(behavior.Payload, + AetheriaRuntimeDaemonItemStatQueries.EvaluatePerformanceStat(value, behavior.Item, thermal)); + } + } + + private static IEnumerable ActiveConsumableModifiers( + AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot catalog) + { + foreach (var effect in entity.ActiveConsumables ?? Array.Empty()) + { + var item = catalog.FindItem(effect?.ItemKey ?? ""); + if (effect == null || item == null) + continue; + var elapsed = effect.Duration <= 0 ? 1 : Math.Max(0, Math.Min(1, + (effect.Duration - effect.RemainingDuration) / effect.Duration)); + var effectiveness = item.EffectivenessCurveKeys == null || item.EffectivenessCurveKeys.Count == 0 + ? 1 : AetheriaRuntimeDaemonItemStatQueries.SampleCurve(item.EffectivenessCurveKeys, elapsed); + foreach (var entry in (item.BehaviorPayloads ?? Array.Empty()) + .Select((payload, index) => (Payload: payload, Index: index))) + { + var state = (effect.BehaviorStates ?? Array.Empty()) + .FirstOrDefault(value => value?.BehaviorIndex == entry.Index); + if (entry.Payload != null && string.Equals(entry.Payload.Kind, "StatModifier", StringComparison.Ordinal) && + state?.StatModifierApplied == true) + yield return new ModifierValue(entry.Payload, + AetheriaRuntimeDaemonItemStatQueries.EvaluateConsumablePerformanceStat( + Field(entry.Payload, 2)?.Value, effect.Quality, effectiveness)); + } + } + } + + private static bool Targets( + AetheriaRuntimeBehaviorPayload modifier, + AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot catalog, + int targetEquipmentIndex, + AetheriaRuntimeBehaviorPayload targetBehavior, + string targetStatName) + { + var reference = Field(modifier, 1)?.Value; + var expectedKind = NormalizeKind(ChildString(reference, 1)); + if (!AetheriaRuntimeBehaviorMetadataCatalog.IsKindOrDescendant(targetBehavior?.Kind ?? "", expectedKind) || + !string.Equals(ChildString(reference, 2), targetStatName, StringComparison.Ordinal)) + return false; + var requiredKind = NormalizeKind(ReadString(Field(modifier, 4)?.Value)); + if (string.IsNullOrWhiteSpace(requiredKind)) + return true; + var targetItemKey = (entity.Equipment ?? Array.Empty()) + .ElementAtOrDefault(targetEquipmentIndex)?.Item?.ItemKey ?? ""; + return (catalog.FindItem(targetItemKey)?.BehaviorPayloads ?? Array.Empty()) + .Any(value => AetheriaRuntimeBehaviorMetadataCatalog.IsKindOrDescendant(value?.Kind ?? "", requiredKind)); + } + + private static bool IsMultiplier(AetheriaRuntimeBehaviorPayload payload) + { + var value = Field(payload, 3)?.Value; + return string.Equals(ReadString(value), "Multiplier", StringComparison.OrdinalIgnoreCase) || + (string.IsNullOrWhiteSpace(ReadString(value)) && Math.Round(value?.NumberValue ?? 0) == 1); + } + + private static string NormalizeKind(string value) => + !string.IsNullOrWhiteSpace(value) && value.EndsWith("Data", StringComparison.Ordinal) + ? value.Substring(0, value.Length - 4) : value ?? ""; + + private static string StatName(string? behaviorKind, int fieldKey) => + PerformanceStats(behaviorKind).FirstOrDefault(value => value.Key == fieldKey)?.Name ?? ""; + + private static IEnumerable PerformanceStats(string? behaviorKind) => + AetheriaRuntimeBehaviorMetadataCatalog.Get(behaviorKind ?? "")?.DisplayFields? + .Where(value => value.ValueKind == AetheriaRuntimeBehaviorFieldValueKind.PerformanceStat) + ?? Enumerable.Empty(); + + private static AetheriaRuntimeBehaviorField? Field(AetheriaRuntimeBehaviorPayload payload, int key) => + (payload?.Fields ?? Array.Empty()).FirstOrDefault(value => value?.Key == key); + + private static string ChildString(AetheriaRuntimeBehaviorValue? value, int index) => + value != null && value.Children != null && value.Children.Count > index + ? ReadString(value.Children[index]) : ""; + + private static string ReadString(AetheriaRuntimeBehaviorValue? value) => + !string.IsNullOrWhiteSpace(value?.StringValue) ? value.StringValue : + !string.IsNullOrWhiteSpace(value?.LegacyIdValue) ? value.LegacyIdValue : + value?.ItemKeyValue ?? ""; + + private readonly struct ModifierValue + { + public ModifierValue(AetheriaRuntimeBehaviorPayload payload, double value) + { + Payload = payload; + Value = value; + } + + public AetheriaRuntimeBehaviorPayload Payload { get; } + public double Value { get; } + } + } +} diff --git a/docs/aetheria-current-codebase-model.md b/docs/aetheria-current-codebase-model.md index 7693eef42..88b313fa3 100644 --- a/docs/aetheria-current-codebase-model.md +++ b/docs/aetheria-current-codebase-model.md @@ -274,6 +274,13 @@ runtime by behavior and entity. This is a partial modifier system, but it is attached to the old `PerformanceStat` object graph rather than a compact blueprint/instance stat set. +The daemon translation does not retain those object-owned dictionaries. +`AetheriaRuntimeStatModifierSimulation` persists switch, trigger, and modifier +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. + Migration target: - blueprint/catalog rows define base stat ids and default values; diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 370fc8763..700beae20 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -292,6 +292,19 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. equipped-item and active-consumable behavior instances through narrow behavior-owned restore methods; construction/loadout blueprints do not own behavior replay. +- Behavior-chain and stat-modifier authority is daemon-owned. Typed behavior + activation intents mutate only the addressed equipment `Switch` or `Trigger`; + the daemon then executes each authored equipment group in stable payload + order. A false switch or unpulled trigger stops that group, a trigger is + consumed exactly once, and reached `StatModifier` behaviors commit applied + state plus their live target count. Equipped modifiers apply in that same + fixed step. Active-consumable modifiers retain the fossil's update-before- + execute ordering and therefore apply one step after first execution. Every + `AetheriaRuntimeEquippedBehavior.EvaluateStat` call derives constant and + multiplier sources from the persisted snapshot, multiplies all multiplier + values, sums all constants, and returns `base * product + sum`. There is no + client modifier owner and no reconnect-time mutable modifier cache to double + apply. Offline, disabled, or broken source equipment contributes nothing. - Sensor contact information is daemon-owned. Each operational installed `Sensor` behavior executes the fossil information equation against target visibility, distance, observer-relative angle, its authored sensitivity diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index 69dd4bc9e..39dad2cf2 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -43,7 +43,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. | Save/resume | original persistence paths | daemon/CultCache | published current state | clients rehydrate | owner-exists; not parity-proven | mid-loop restart | | Terminus loop | original game composition | daemon/Ymir/Eve | pilot surface | Aetheria.Unity | live minimum-loop proof: generic Unity discovers the provider, lowers the pilot world/assets, single-steps dock and undock while paused, buys daemon-priced station stock, single-steps Ymir salvage collection, resumes, moves, targets, fires, and presents authoritative receipts/shot outcome; the released full-session profile starts with no pickup, destroys a deterministic target, observes its canonical cargo drop, tractors it into a Ymir Begin contact, and proves exactly one `ymir-fact:` collection receipt plus cargo `0` to `1` | richer trade content, docking transitions, and sustained combat encounters | | Starbridge | original/shared architecture intent | one daemon session | pilot + commander surfaces | Unity + Electron | commander task board and autonomous explore execution wired; Electron Eve lowering and split-target proof missing | commander orders plus four pilots and autonomous workers | -| Behavior engine | `ServerShared/Behaviors` composition | daemon | executable action/status projection | action bar and feedback | tick reconciles equipped catalog payloads into persistent behavior state; shared evaluated-stat query proven; execution families remain partial | every behavior family, ordering, resource transaction and modifier lifecycle | +| Behavior engine | `ServerShared/Behaviors` composition | daemon | executable action/status projection | action bar and feedback | tick reconciles equipped catalog payloads into persistent behavior state; `Switch` and one-shot `Trigger` gate ordered equipment groups; equipped and timed-consumable `StatModifier` sources persist daemon-owned lifecycle state and every runtime stat reader applies the fossil `base × multiplier product + constant sum` rule through one query; legacy `Data` kind references and required-behavior filters are honored | remaining behavior families, full resource-gated chain ordering, and canonical consumable-content proof | | Sensors/visibility | installed sensor sensitivity and angular curve, `Visibility`, `Reflector`, decaying signature sources, stellar light, ping state, and observer information thresholds | daemon | accumulated contacts, named visibility sources, and ping events | indicators/maps and provider environment lowering | every operational installed Sensor executes the fossil visibility/sensitivity/angle/distance information equation and decay; visibility is thresholded by the provider setting. Actor-scoped ping owns energy, cooldown, expansion radius, reconnect-stable crossed-target state, one-time information boost, and transient signature. Operational `Visibility` equipment publishes evaluated constant signature; committed weapons publish authored signature including charged-shot multiplication; transient weapon and ping sources decay by the fossil exponential rule. Reflectors multiply evaluated cross-section by the exponent-8 stellar `PowerPulse` after thermal settlement. Gameplay light and environment projection share resolved authored orbit/body centers and provider-owned sun radius inputs. Contact refresh no longer invents perfect information or overwrites emitted visibility; docked craft consume one daemon-derived union of craft and dock-parent contacts through both Eve rows and SoA bodies, with sensor-source provenance | active consumable visibility/reflector effects, complete source cardinality, classification timelines, and canonical catalog station-array data | | AI/tasks | corporation task scheduler plus agent state machines and installed orbital `TurretController` | daemon | commander task board, corporation survey ledger, controller aim/state, and shared semantic controls | Eve commander/pilot clients | point exploration, loadout-driven attack, haul, mining/offload, resource surveying, physical station towing, persistent orbit patrol, route-aware assignment, and autonomous return home are wired through shared commands. Ship combat samples the fossil DPS/range policy, selects live weapon groups, leads projectiles, strafes relative to target attitude, and gates fire on catalog placement rotation. Operational orbital controllers independently acquire the first visible hostile ship, publish predictive look direction, and trigger only exact aligned in-range weapon behaviors through the shared combat lifecycle; non-player identity no longer grants implicit group-zero fire. Cross-zone task and home routes survive the full daemon-owned wormhole timeline; attack admission rejects missing/friendly targets and unavailable weapon groups before durable queueing | cancellation, reconnect, multi-client command transfer, and model-local barrel-direction metadata | | Consumables | cargo-backed timed behavior containers | daemon | dynamic actions, active effects, and lifecycle facts | action bar/schematic | daemon atomically resolves typed metadata and concrete cargo, preserves consumed-instance quality, enforces authored stacking, evaluates effectiveness/quality, and executes ordered `EnergyDraw`, fossil-compatible no-op `Heat`, `ItemUsage`, and stateful `Cooldown` payloads after energy tick initialization; every effect and payload state has stable authored identity; cooldowns update before the ordered chain, require state strictly below zero, reset to one on success, and continue aging behind earlier failures; false and unsupported payloads stop later execution with a typed fact; timers retain the fossil's exact-zero final tick; Unity reconciles presentation by effect identity and cannot restore active behavior state by list index | catalog revision pinning, stat modifiers, remaining behavior families, and reconnect presentation proof |