Eliminate repeated behavior graph rebuilds
This commit is contained in:
parent
289051f71a
commit
bd89cb4c85
6 changed files with 143 additions and 7 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue