From b0d335435745fc63bfa98e9411f7b2044e9131d4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 18 Sep 2026 20:41:42 +0200 Subject: [PATCH] Gate 1: give the resolver a Forget(owner); correct the recompute-cost comment StatResolver's _cache, _modifiers and _generations were keyed by owner and nothing ever removed an entry, contradicting Sec 0b's promise that a resolver entry is "destroyed at unequip / entity teardown." A ConsumableItemEffect that expired still had a generations entry, and an unequipped EquippedItem's entries lived on for the rest of the process -- the same shape of leak the catalog object had before Cut 2, one level down. Forget(owner) drops every entry for that owner across all three dictionaries; Entity.TryUnequip and the consumable-expiry branch in Entity.Update now call it. Also adds GenerationOwnerCount/CacheEntryCount/ModifierEntryCount, a test-only surface a mutation test pins the retained-set claim against. F3: InvalidateSource's comment claimed a stat "recomputes only when a term's source changes." Entity.cs's UpdatePerformance calls it unconditionally every tick for Heat and Durability, regardless of whether the value moved, so the true invariant is "at most once per tick per (item, stat)." Corrected, and noted Quality as the one source this scheme still meaningfully defends against a real no-op tick (see F6 in ItemData.cs). Co-Authored-By: Claude Opus 5 --- Assets/Scripts/ServerShared/Entity.cs | 14 ++++++- Assets/Scripts/ServerShared/StatResolver.cs | 41 +++++++++++++++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index f841223cc..ba3e09df0 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -620,6 +620,11 @@ public abstract class Entity Sensor = null; } + // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): the resolver's §0b lifecycle promise -- a resolver + // entry is "destroyed at unequip" -- was never wired up. Without this, every unequipped item stayed + // reachable from the resolver's dictionaries for the entity's whole remaining life. + Resolver.Forget(item); + return item.EquippableItem; } @@ -1024,7 +1029,14 @@ public abstract class Entity for (var i = 0; i < _activeConsumables.Count; i++) { _activeConsumables[i].Update(delta); - if(_activeConsumables[i].RemainingDuration < 0) _activeConsumables.RemoveAt(i--); + if (_activeConsumables[i].RemainingDuration < 0) + { + // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): an expired consumable dropped out of this + // list without ever telling the resolver, leaving its generation/cache/modifier entries + // reachable (keyed by this ConsumableItemEffect instance) for the rest of the process. + Resolver.Forget(_activeConsumables[i]); + _activeConsumables.RemoveAt(i--); + } } foreach (var equippedItem in _orderedEquipment) diff --git a/Assets/Scripts/ServerShared/StatResolver.cs b/Assets/Scripts/ServerShared/StatResolver.cs index 879d40598..bbd3ea542 100644 --- a/Assets/Scripts/ServerShared/StatResolver.cs +++ b/Assets/Scripts/ServerShared/StatResolver.cs @@ -62,9 +62,14 @@ public class StatResolver return 0; } - // Called by an owner when one of its own sources moves: EquippedItem.UpdatePerformance every tick for Heat - // and Durability, ConsumableItemEffect.Update every tick for ConsumableProgress. Invalidates every cached - // value of this owner's that declared a term reading this source -- and only those. + // Called unconditionally, every tick, by EquippedItem.UpdatePerformance for Heat and Durability and by + // ConsumableItemEffect.Update for ConsumableProgress -- not only when the source's value actually moved. So + // the true invariant is not "recomputes only when a term's source changes": a Heat- or Durability-termed stat + // recomputes at most once per tick per (item, stat), every tick, regardless of whether the value moved. + // Quality is the one source this scheme still defends against a real no-op tick (F6: nothing invalidates it + // today, since EquippedItem.Lot is set once in the constructor -- harmless until an upgrade needs a lot swap + // to invalidate it, which §0b designs for but this cut does not yet wire up). Invalidates every cached value + // of this owner's that declared a term reading this source -- and only those. public void InvalidateSource(object owner, StatSource source) { if (!_generations.TryGetValue(owner, out var perSource)) @@ -139,4 +144,34 @@ public class StatResolver } _cache.Remove(entry); } + + // Cut 2 gap (docs/stats-and-power-cut.md §0b: a resolver entry is "destroyed at unequip / entity teardown"): + // nothing ever dropped an owner from _cache, _modifiers or _generations, so every EquippedItem that was ever + // unequipped and every ConsumableItemEffect that ever expired stayed reachable from these three dictionaries + // for the life of the process -- the same shape of leak §0.3 named, one level down. Called from the unequip + // path (Entity.cs TryUnequip) and from consumable expiry (Entity.cs Update); an owner with nothing recorded is + // a harmless no-op. + public void Forget(object owner) + { + _generations.Remove(owner); + RemoveEntriesForOwner(_cache, owner); + RemoveEntriesForOwner(_modifiers, owner); + } + + private static void RemoveEntriesForOwner(Dictionary dict, object owner) + { + List stale = null; + foreach (var entry in dict.Keys) + if (ReferenceEquals(entry.Owner, owner)) + (stale ??= new List()).Add(entry); + if (stale == null) return; + foreach (var entry in stale) + dict.Remove(entry); + } + + // Test/diagnostic surface only, never a hot path: the retained-set sizes a mutation test pins against a + // baseline after equip/evaluate/unequip and activate/evaluate/expire cycles (Cut 2 Gate 1). + public int GenerationOwnerCount => _generations.Count; + public int CacheEntryCount => _cache.Count; + public int ModifierEntryCount => _modifiers.Count; }