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;
}
}