Gate 2: Initialize owns detach/reattach; delete dead StatOwner (F8)

StatModifier.Initialize recomputed _targets fresh every (re)activation but
never reset _applied, so after deactivate -> unequip the target -> equip a
replacement -> activate, Update saw `_executed && _applied` (both still true
from the stale attachment) and neither branch of its latch fired again --
the modifier silently stopped reaching anything. Differential evidence: the
same scenario resolved 6 pre-cut and 1 at Cut 2's HEAD.

Chose the ownership fix over the one-line flag reset: Initialize is the
entity's own re-activation hook, called exactly once per (re)activation for
every StatModifier on it, so it is the one place that already knows a
transition is happening. Detaching whatever this modifier was attached to
before recomputing _targets removes the split authority between Initialize
(which decides targets) and Update (which was silently also deciding
attachment state) instead of leaving Update to infer a transition it was
never told about.

F8: Behaviors.cs's `StatOwner` property had no production caller -- it was
added only to make one mutation test's mutant expressible. Deleted; the
mutation test now uses `Entity` (a real symbol) as its wrong-owner stand-in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-18 20:41:54 +02:00
commit 5581911fb3
2 changed files with 12 additions and 4 deletions

View file

@ -21,10 +21,6 @@ public abstract class Behavior
protected Entity Entity => Item?.Entity ?? Consumable.Entity;
public float Temperature => Item?.Temperature ?? Consumable.Entity.MaxTemp;
// The StatResolver key for whichever of Item/Consumable this behaviour has -- the two owners a resolved stat
// value or an attached modifier can belong to (StatResolver.cs, docs/stats-and-power-cut.md Cut 2).
protected object StatOwner => (object) Item ?? Consumable;
public float3 Direction
{
get

View file

@ -69,8 +69,20 @@ public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwa
// fresh and silently at every equip, now goes through StatValidation.ResolveStatField -- resolved once,
// memoized, and refused loudly (at catalog load, or here if a caller built one without going through the
// catalog) instead of leaving `_stats` null for ApplyModifier to throw an opaque NullReferenceException on.
//
// Gate 2 fix (Soul pass over Cut 2): Initialize is the entity's own re-activation hook, called exactly once
// per (re)activation for every StatModifier on it -- it is the one place that already knows "this behaviour's
// targets are about to change." Detaching an old attachment, if any, belongs here, not in Update's _executed/
// _applied latch: that latch only tracks per-tick execution and was never told a re-Initialize had happened,
// so after deactivate -> unequip the target -> equip a replacement -> activate, _applied stayed true from the
// stale attachment and Update's `_executed && !_applied` guard never fired again -- the modifier silently
// stopped reaching anything. Making Initialize own the detach/reattach transition removes the split
// authority instead of just resetting the flag: whatever this modifier was attached to before is explicitly
// let go before new targets are computed, so the next Execute+Update reliably reapplies against them.
public void Initialize()
{
if (_applied)
RemoveModifier();
_targets = TargetsOf(Entity, _data);
ValidateNoCycle(Entity, _data, _targets);
}