# Handoff Report: Milestone 2 — Character Animation & Visceral Combat Feel Engine

**Date**: 2026-10-01T03:06:00Z  
**Author**: `challenger_m2_1_gen3` (Empirical Challenger)  
**Recipient**: `parent` (ID: `77cd448f-be37-473e-809c-59db9f78e386`) / Orchestrator  
**Milestone**: Milestone 2 (Character Animation & Visceral Combat Feel Engine)  
**Verdict**: **REQUEST_CHANGES** (Rejection with reproducible test suite `tests/unit/test_challenger_m2_adversarial.py`)

---

## 1. Observation

### 1.1 Baseline Test Runs
Executed the three requested baseline commands:
1. `pytest tests/unit/test_m2_animation_and_combat_feel.py -v`:
   - Result: **30 passed in 0.15s**.
   - Note: Existing unit tests passed because they asserted source string occurrences (e.g. `assert "combatFeelEngine.getShakeOffset" in self.code` on `canvas_renderer.js`) rather than executing runtime function behavior.
2. `pytest tests/e2e/test_poe2_ui_animation_vfx_e2e.py -v`:
   - Result: **33 passed, 9 xpassed, 0 failed in 0.37s**.
3. `python tools/lint/check_code_and_doc_hygiene.py --strict`:
   - Result: **Exit code 0, 0 Hard Cap violations** across 530 files.

### 1.2 Empirical Adversarial Stress Suite Execution
Authored and executed an empirical stress harness in `tests/unit/test_challenger_m2_adversarial.py` (325 lines, fully compliant with line hygiene caps):
Command: `pytest tests/unit/test_challenger_m2_adversarial.py -v`
Result: **9 failed, 80 passed in 10.57s**.

Direct verbatim error traces from test execution:

#### Finding 1 (HIGH): Missing `CombatFeelEngine.prototype.getShakeOffset` Method
- In `client/webapp/js/engine/canvas_renderer.js:276`:
  ```javascript
  const feelOffset = (typeof window.combatFeelEngine !== 'undefined') ? window.combatFeelEngine.getShakeOffset?.() : null;
  if (feelOffset && (feelOffset.x !== 0 || feelOffset.y !== 0)) {
    ctx.translate(feelOffset.x, feelOffset.y);
  } else if (screenShake.timer > 0) { ... }
  ```
- In `client/webapp/js/engine/combat_feel_engine.js:187-208`:
  `CombatFeelEngine.prototype.update(dt)` calculates `this.shakeOffsetX` and `this.shakeOffsetY` along `shakeAngle` with orthogonal jitter.
- Node inspection and test execution result:
  ```
  FAILED tests/unit/test_challenger_m2_adversarial.py::TestAdversarialCombatFeelEngine::test_missing_get_shake_offset_defect
  AssertionError: CombatFeelEngine.prototype.getShakeOffset is missing! Directional screen shake is dead code in canvas_renderer.js
  assert False is True
  ```
- Runtime observation: `typeof combatFeelEngine.getShakeOffset === 'undefined'`. `feelOffset` is permanently `null`. The directional shake matrix computed by `CombatFeelEngine` is completely orphaned and never applied to the camera.

#### Finding 2 (HIGH): 1-Tick Kinetic Phase Lag & Skipped Recovery Phase
- In `client/webapp/js/engine/animation_engine.js:176-216`:
  Line 176 computes `kineticPhase` using `animState.frameIndex` *before* line 213 advances `animState.frameTime` and `animState.frameIndex`.
  Then line 237 resets `animState.kineticPhase = 'idle'` upon action completion.
- Node inspection at 60 FPS (`dt = 0.01667`) for `attack_slash` (`fps = 15, hit_frame = 2`):
  ```
  Tick 7: frame=1 phase=wind_up
  Tick 8: frame=2 phase=wind_up  <-- frameIndex is 2 (hit_frame), but kineticPhase is still wind_up!
  Tick 9: frame=2 phase=impact
  ...
  Tick 12: frame=3 phase=impact  <-- frameIndex is 3 (recovery), but kineticPhase is still impact!
  Tick 13: frame=3 phase=recovery
  ```
- Test execution at 15 FPS / high attack speed (1.5x-2.0x):
  ```
  FAILED tests/unit/test_challenger_m2_adversarial.py::TestAdversarialKineticStateMachine::test_kinetic_transition_phases_progression[attack_slam-1.5-15-0.06666666666666667]
  FAILED tests/unit/test_challenger_m2_adversarial.py::TestAdversarialKineticStateMachine::test_kinetic_transition_phases_progression[attack_slam-2.0-15-0.06666666666666667]
  FAILED tests/unit/test_challenger_m2_adversarial.py::TestAdversarialKineticStateMachine::test_kinetic_transition_phases_progression[attack_slash-2.0-15-0.06666666666666667]
  FAILED tests/unit/test_challenger_m2_adversarial.py::TestAdversarialKineticStateMachine::test_kinetic_transition_phases_progression[attack_thrust-2.0-15-0.06666666666666667]
  FAILED tests/unit/test_challenger_m2_adversarial.py::TestAdversarialKineticStateMachine::test_kinetic_transition_phases_progression[attack_bolt-2.0-15-0.06666666666666667]
  AssertionError: Recovery phase never reached for attack_slam at 15 FPS, speed 2.0
  ```
- Verbatim tick trace for `attack_slam` at 15 FPS:
  ```
  Tick 0: 0 wind_up
  Tick 1: clip=attack_slam frame=1 phase=wind_up
  Tick 2: clip=attack_slam frame=3 phase=wind_up
  Tick 3: clip=attack_slam frame=4 phase=impact
  Tick 4: clip=idle frame=0 phase=idle
  ```
  Phase `recovery` was never observed once by external systems.

#### Finding 3 (MEDIUM): `cancelAction` Fails to Reset `currentClip` and Counters
- In `client/webapp/js/engine/animation_engine.js:142-149`:
  ```javascript
  function cancelAction(animState) {
    if (!animState) return;
    animState.isActionLocked = false;
    animState.hitTriggered = false;
    animState.onHitCallback = null;
    animState.onCompleteCallback = null;
    animState.kineticPhase = 'idle';
  }
  ```
- Test execution result:
  ```
  FAILED tests/unit/test_challenger_m2_adversarial.py::TestAdversarialCombatFeelEngine::test_cancel_action_resets_current_clip_defect
  AssertionError: cancelAction left currentClip as 'attack_slash', expected 'idle'
  assert 'attack_slash' == 'idle'
  ```
- Subsequent tick trace:
  `updateAnimation(state, 0.0, 0.016)` sees `currentClip === 'attack_slash'`, re-evaluates `kineticPhase` back to `'wind_up'`, and continues advancing the cancelled attack clip.

#### Finding 4 (MEDIUM): `CombatFeelEngine.hitStopTimer` Never Decayed in `update(dt)`
- In `client/webapp/js/engine/combat_feel_engine.js:187-215`:
  `update(dt)` updates `screenShakeTimer` and `damagePool`, but does not decrement `this.hitStopTimer`.
- Test execution result:
  ```
  FAILED tests/unit/test_challenger_m2_adversarial.py::TestAdversarialCombatFeelEngine::test_hit_stop_timer_decays_in_update_loop_defect
  AssertionError: hitStopTimer failed to decay in update(dt)! Still 0.05 after 0.1s
  assert 0.05 == 0
  ```
- Observation: `hitStopTimer` remains permanently positive on the singleton after `triggerHitStop` unless an external caller invokes `isHitStopped(dt)`. `canvas_renderer.js` only calls `update(dt)`.

---

## 2. Logic Chain

1. **Camera Shake Defect**:
   - `canvas_renderer.js` line 276 invokes `window.combatFeelEngine.getShakeOffset?.()`.
   - `combat_feel_engine.js` computes `shakeOffsetX` and `shakeOffsetY` inside `CombatFeelEngine.prototype.update(dt)`.
   - Because `CombatFeelEngine.prototype.getShakeOffset` was never declared, `feelOffset` is permanently `undefined` / `null`.
   - The directional screen shake feature is entirely bypassed at runtime, falling back to legacy isotropic shake.

2. **Kinetic Machine De-synchronization**:
   - `updateAnimation` in `animation_engine.js` calculates `kineticPhase` at lines 175-191 from `animState.frameIndex` before advancing `animState.frameTime` and `frameIndex` at lines 213-215.
   - This introduces a 1-tick latency: when `frameIndex` reaches `hit_frame` (2), `kineticPhase` is still `'wind_up'`. When `frameIndex` moves past `hit_frame` into recovery, `kineticPhase` is `'impact'`.
   - When action completion triggers (`frameIndex >= clip.frames.length`), line 253 immediately overwrites `kineticPhase = 'idle'`.
   - For fast attack speeds or lower frame rates (15 FPS), the single tick where `recovery` was assigned at line 183 is wiped out by line 253 within the exact same function call. Thus, `recovery` is never observable externally.
   - Moving the `kineticPhase` determination to after frame advancement eliminates the 1-tick delay and properly exhibits the recovery phase.

3. **Action Cancellation Incompleteness**:
   - `cancelAction` in `animation_engine.js` only sets `animState.isActionLocked = false` and `animState.kineticPhase = 'idle'`.
   - It leaves `animState.currentClip = 'attack_slash'`.
   - In the next animation update tick, `updateAnimation` reads `currentClip` and recomputes `kineticPhase` back to `'wind_up'`.
   - To truly cancel an action, `currentClip` must be reset to `'idle'` (or `'run'` if moving) and `frameIndex` / `frameTime` reset to 0.

4. **Hit-Stop Timer Decoupling**:
   - `triggerHitStop` sets `this.hitStopTimer = duration`.
   - `update(dt)` in `combat_feel_engine.js` is called every frame by `canvas_renderer.js:213`, but it never decrements `this.hitStopTimer`.
   - As a result, `hitStopTimer` on the engine singleton remains permanently positive.

---

## 3. Caveats

- **No Caveats**. All 4 defects were empirically verified using real Node.js script execution against the actual codebase files, without mocks or synthetic facades.
- Object pooling (`DamageNumberPool`) successfully verified: strict 64 slots, zero allocations across 256+ spawns, pop-in bounce scale curve ($1.6 \rightarrow 1.0$ and $2.0 \rightarrow 1.2$) works correctly.
- Hit-stop micro-pause timing formula ($0.016 \times \text{frames}$ vs duration) and entity freeze ($33-66\text{ms}$) works correctly on `attacker.anim` and `target.anim`.

---

## 4. Conclusion

**Verdict: REQUEST_CHANGES**.

Milestone 2 cannot be approved in its current state because directional screen shake is non-functional due to a missing API method (`getShakeOffset`), the kinetic state machine exhibits a 1-tick phase lag causing skipped recovery animations, and action cancellation does not reset animation clip state.

### Actionable Remediation Items for Worker:

1. **Fix `combat_feel_engine.js`**:
   - Add `getShakeOffset()` to `CombatFeelEngine`:
     ```javascript
     getShakeOffset() {
       return { x: this.shakeOffsetX, y: this.shakeOffsetY };
     }
     ```
   - In `CombatFeelEngine.prototype.update(dt)`, decrement `this.hitStopTimer`:
     ```javascript
     if (this.hitStopTimer > 0) {
       this.hitStopTimer -= dt;
       if (this.hitStopTimer < 0) this.hitStopTimer = 0;
     }
     ```
   - Make `isHitStopped()` pure:
     ```javascript
     isHitStopped() {
       return this.hitStopTimer > 0;
     }
     ```

2. **Fix `animation_engine.js`**:
   - In `cancelAction(animState)`:
     ```javascript
     animState.currentClip = 'idle';
     animState.frameIndex = 0;
     animState.frameTime = 0;
     animState.kineticPhase = 'idle';
     ```
   - In `updateAnimation`: compute `animState.kineticPhase` **after** frame advancement and action completion/looping, so `kineticPhase` is synchronized with the current `frameIndex`.
   - In `updateAnimation`: add `!isNaN(dt)` check to `actualDt`.

---

## 5. Verification Method

1. **Run the Empirical Adversarial Challenge Suite**:
   ```bash
   pytest tests/unit/test_challenger_m2_adversarial.py -v
   ```
   *Expected outcome before fix*: 9 failed, 80 passed.  
   *Expected outcome after fix*: 89 passed, 0 failed.

2. **Run Existing Milestone 2 Unit Test Suite**:
   ```bash
   pytest tests/unit/test_m2_animation_and_combat_feel.py -v
   ```
   *Expected outcome*: 30 passed in ~0.15s.

3. **Run E2E PoE2 Test Suite**:
   ```bash
   pytest tests/e2e/test_poe2_ui_animation_vfx_e2e.py -v
   ```
   *Expected outcome*: 33 passed, 9 xpassed, 0 failed.

4. **Verify Strict Hygiene**:
   ```bash
   python tools/lint/check_code_and_doc_hygiene.py --strict
   ```
   *Expected outcome*: Exit code 0, 0 Hard Cap violations.
