using AcDream.Runtime.Gameplay; namespace AcDream.App.Net; /// /// C3c-F1 (2026-08-02): the App half of the movement-stats application /// seam. Every server stat recompute (skills, burden, stamina, PK status — /// the character-bindings OnSkillsUpdated/OnMovementStatsUpdated /// callbacks) routes through /// ; /// App holds no controller reference and performs no direct configuration /// mutation. A recompute displaced past session teardown (the post-logout /// inbound-Create ingest chain that crashed the connected lifecycle gate) /// observes a typed dropped outcome and is logged under the existing /// player diagnostics instead of faulting the session. /// /// /// Stuck-cast fix (2026-07-30): retail fires /// CPhysicsObj::report_exhaustion from exactly ONE site — /// CommandInterpreter::HandleExhaustion (0x006b3c70), a /// notification-handler vtable slot invoked on the stamina-exhaustion /// EVENT — not on every vitals refresh. The P1 wiring called /// ReportExhaustion() on EVERY movement-stats application /// (every stamina regen/drain tick), and each call re-dispatches the /// current movement state through the animation sink — truncating any /// in-flight action animation (cast gestures wedged mid-play; the /// diagnostic session showed 490 spurious stance re-queues). The /// re-apply fires only when the exhausted state (stamina == 0) /// actually TRANSITIONS, matching retail's event semantics. Skill/ /// burden changes still reach PlayerWeenie immediately through /// the owner seam — the next natural dispatch picks up the new rates, /// exactly as retail. The edge is observed for dormant applications too /// (the event fired; a player not yet in world has no movement to /// re-dispatch, and activation starts movement from the already-current /// stamina gate), but dispatched only on a live controller. /// internal sealed class LiveMovementStatsApplier( RuntimeLocalPlayerMovementState movement, RuntimeMovementSkillState skills, Action log) { private readonly RuntimeLocalPlayerMovementState _movement = movement ?? throw new ArgumentNullException(nameof(movement)); private readonly RuntimeMovementSkillState _skills = skills ?? throw new ArgumentNullException(nameof(skills)); private readonly Action _log = log ?? throw new ArgumentNullException(nameof(log)); private readonly StaminaExhaustionEdgeTracker _staminaExhaustion = new(); /// /// Forgets the retiring character/session exhaustion baseline; the next /// generation's first sample must not synthesize an edge. /// public void Reset() => _staminaExhaustion.Reset(); public RuntimeMovementStatsApplication Apply(string reason) { RuntimeMovementStatsApplication outcome = _movement.ApplyCharacterMovementStats(_skills); switch (outcome) { case RuntimeMovementStatsApplication.DroppedNoController: case RuntimeMovementStatsApplication.DroppedIncompleteSnapshot: // Byte-identical to the pre-F1 ApplyTo=false silent skip. return outcome; case RuntimeMovementStatsApplication.DroppedDisplacedController: _log( $"player: dropped displaced movement {reason} — the " + "Runtime movement controller is terminal"); return outcome; } RuntimeMovementSkillSnapshot snapshot = _skills.Snapshot; if (_staminaExhaustion.Observe(snapshot.CurrentStamina) && outcome is RuntimeMovementStatsApplication.AppliedLive) { _movement.ReportExhaustion(); } _log( $"player: applied server movement {reason} " + $"run={snapshot.RunSkill} jump={snapshot.JumpSkill} " + $"burden={snapshot.Burden:F2} stamina={snapshot.CurrentStamina}"); return outcome; } }