Eliminate repeated behavior graph rebuilds

This commit is contained in:
Meta Vens 2026-07-19 10:24:57 +01:00
commit bd89cb4c85
6 changed files with 143 additions and 7 deletions

View file

@ -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",

View file

@ -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<AetheriaRuntimeEquippedBehavior>? 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<ModifierValue> ActiveEquippedModifiers(
AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeCatalogSnapshot catalog)
AetheriaRuntimeCatalogSnapshot catalog,
IReadOnlyList<AetheriaRuntimeEquippedBehavior>? 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;

View file

@ -18,6 +18,8 @@ namespace GameCult.Aetheria.State.Verse
return;
var existing = entity.BehaviorStates ?? Array.Empty<AetheriaRuntimeBehaviorStateCommit>();
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<AetheriaRuntimeBehaviorStateCommit> existing,
IReadOnlyList<AetheriaRuntimeLoadoutItemSlotCommit>? equipment,
AetheriaRuntimeCatalogSnapshot? catalog)
{
catalog ??= new AetheriaRuntimeCatalogSnapshot();
var matched = 0;
var slots = equipment ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
for (var equipmentIndex = 0; equipmentIndex < slots.Count; equipmentIndex++)
{
var item = slots[equipmentIndex]?.Item;
var payloads = catalog.FindItem(item?.ItemKey ?? "")?.BehaviorPayloads ??
Array.Empty<AetheriaRuntimeBehaviorPayload>();
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;
}
}
}

View file

@ -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,

View file

@ -29,6 +29,8 @@ namespace GameCult.Aetheria.State.Verse
public sealed class AetheriaRuntimeEquippedBehavior
{
private IReadOnlyList<AetheriaRuntimeEquippedBehavior>? 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<AetheriaRuntimeEquippedBehavior> value) =>
modifierBehaviors = value ?? Array.Empty<AetheriaRuntimeEquippedBehavior>();
public double EvaluateStat(int fieldKey, double thermalPerformance = 1.0)
{
var field = (Payload.Fields ?? Array.Empty<AetheriaRuntimeBehaviorField>())
@ -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<AetheriaRuntimeEquipmentStateCommit>())
.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;
}

View file

@ -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: