# HANDOFF REPORT — Spacebar Dodge Controls & Charge Accounting Remediation

**Agent**: `explorer_18_3` (Controls & Charge Accounting Remediation Explorer)  
**Role**: `investigator`, `specialist`  
**Target Recipient**: `75463099-5538-440a-8ff9-91c183526f7a` (`orchestrator_18` / `parent`)  
**Date**: 2026-10-02T07:18:00Z  
**Type**: Hard Handoff (Investigation & Remediation Formulation Complete)  

---

## 1. Observation

### O1. Duplicate Cooldown Invocation on Single Space Tap
- **Files**:
  - `client/webapp/js/engine/combat_skills.js:276–305`
  - `client/web_pc/js/pc_input_controller.js:186–197`
- **Observed Code Sequence**:
  In `client/webapp/js/engine/combat_skills.js`:
  ```javascript
  276: function doDodge(invoker) {
  277:   if (player.isDead || player.hp <= 0 || player.isIFrame || (window.skillBarController && window.skillBarController.isSlotOnCooldown('dodge'))) return;
  ...
  295:   player.isIFrame = true;
  296:   player.iFrameTimer = 0.25;
  297:   player.animState = 'dodge';
  ...
  304:   if (!invoker && window.skillBarController) window.skillBarController.triggerCooldown('dodge', 3.0);
  305: }
  ```
  In `client/web_pc/js/pc_input_controller.js`:
  ```javascript
  186: if (e.code === 'Space') {
  187:   e.preventDefault();
  188:   if (typeof window.doDodge === 'function') {
  189:     window.doDodge();
  190:   } else if (window.player) {
  191:     window.player.isIFrame = true;
  192:     window.player.iFrameTimer = 0.25;
  193:     window.player.animState = 'dodge';
  194:   }
  195:   this.triggerUiCooldown('dodge', 3.0);
  196:   return;
  197: }
  ```
- **Observed Execution Trace & Empirical Evidence**:
  - `window.doDodge()` executes and calls `window.skillBarController.triggerCooldown('dodge', 3.0)`.
  - In `skill_bar_controller.js:142`, `slot.charges` decrements from `3` to `2`.
  - Then `pc_input_controller.js:195` unconditionally executes `this.triggerUiCooldown('dodge', 3.0)`, which calls `window.skillBarController.triggerCooldown('dodge', 3.0)` a second time.
  - `slot.charges` decrements again from `2` to `1`.
  - **Empirical Measurement**:
    ```
    [INIT DODGE CHARGES]: 3
    [CHARGES AFTER 1 SPACE PRESS]: 1
    ```
    (A single Space press consumes 2 charges instead of 1).

---

### O2. Charge Wasting During Active i-Frame & Conflicting Key Listeners
- **Files**:
  - `client/webapp/js/ui/skill_bar_controller.js:48, 167–179, 301–318`
  - `client/web_pc/js/pc_input_controller.js:40, 186–197`
- **Observed Code Sequence**:
  In `client/webapp/js/ui/skill_bar_controller.js`:
  ```javascript
  48:  { id: 'dodge', domId: 'skill-dodge', key: 'SPACE', maxCd: 3.0, charges: 3, action: () => window.doDodge?.() }
  ...
  167: activateSlot(slotId) {
  168:   if (typeof window !== 'undefined' && window.isGamePaused) return false;
  169:   const slot = this.slots.get(String(slotId));
  170:   if (!slot) return false;
  171:   if (this.isSlotOnCooldown(slotId)) { this._flashBlockedFeedback(slot); return false; }
  172:   const chargesBefore = slot.charges, cdBefore = slot.cooldownRemaining;
  173:   if (typeof slot.onActivate === 'function') slot.onActivate(slot);
  174:   const alreadyTriggered = (slot.charges !== null && slot.charges < chargesBefore) || (slot.charges === null && slot.cooldownRemaining > cdBefore);
  175:   if (!alreadyTriggered) {
  176:     this.triggerCooldown(slotId, slot.charges !== null ? slot.rechargeDuration : slot.cooldownTotal);
  177:   }
  178:   return true;
  179: }
  ...
  301: this._keyHandler = (e) => {
  ...
  309:   (slotKey === 'SPACE' && (e.code === 'Space' || k === ' '))
  310: ) {
  311:   e.preventDefault();
  312:   this.activateSlot(slot.slotId);
  313:   break;
  314: }
  ...
  317: window.addEventListener('keydown', this._keyHandler);
  ```
- **Observed Mechanism**:
  1. `skill_bar_controller.js` (shared mobile/webapp controller) registers its own bubbling `keydown` event listener on `window` for `Space`.
  2. When Space is pressed while `player.isIFrame === true`, `SkillBarController._keyHandler` calls `activateSlot('dodge')`.
  3. Inside `activateSlot`, `slot.onActivate` calls `window.doDodge()`.
  4. In `combat_skills.js:277`, `doDodge()` checks `if (player.isIFrame) return;` and returns early without performing evasion or decrementing charges.
  5. Back in `activateSlot:174`, `alreadyTriggered` evaluates `slot.charges < chargesBefore`. Because `slot.charges` did not change, `alreadyTriggered` evaluates to `false`.
  6. `activateSlot:176` assumes `onActivate` was a simple fire-and-forget skill and forcefully calls `this.triggerCooldown('dodge', 3.0)`.
  7. A charge is decremented from `2` to `1` without any dodge roll occurring.
  8. Simultaneously, `pc_input_controller.js:186` also catches the keydown event (in default bubbling phase) and executes line 195, causing additional charge wastage.

---

### O3. Crash on i-Frame Expiration (`badge-iframe` Null Dereference)
- **Files**:
  - `client/webapp/js/engine/canvas_renderer.js:217–221`
  - `client/web_pc/index.html`
- **Observed Code Sequence**:
  In `client/webapp/js/engine/canvas_renderer.js`:
  ```javascript
  217: if (player.iFrameTimer <= 0) {
  218:   player.isIFrame = false;
  219:   document.getElementById('badge-iframe').classList.add('hidden');
  220:   if (player.animState === 'dodge') player.animState = mag > 0.05 ? 'run' : 'idle';
  221: }
  ```
- **Observed Error & Verbatim Trace**:
  ```
  PageError: Cannot read properties of null (reading 'classList')
      at renderLoop (http://127.0.0.1:8089/webapp/js/engine/canvas_renderer.js:219:47)
  ```
  - In `client/webapp/index.html:85`, `<div id="badge-iframe" class="hidden ...">I-FRAME</div>` exists.
  - In `client/web_pc/index.html`, `#badge-iframe` is completely missing from the DOM.
  - When `player.iFrameTimer` reaches 0 (after 0.25s), `document.getElementById('badge-iframe')` evaluates to `null`, crashing the rendering loop with an uncaught `TypeError`.

---

## 2. Logic Chain

1. **Premise 1 (Charge Accounting Integrity)**:
   Per ARPG requirements, the dodge roll possesses 3 discrete charges with a 3.0s cooldown each. One single dodge roll must decrement exactly 1 charge (leaving 2).
2. **Premise 2 (Single Authoritative Cooldown Trigger)**:
   `window.doDodge()` in `combat_skills.js:304` already manages cooldowns natively through `window.skillBarController.triggerCooldown('dodge', 3.0)`. Calling `this.triggerUiCooldown('dodge', 3.0)` in `pc_input_controller.js:195` duplicates the decrement and cuts effective charges from 3 to 1 (O1).
3. **Premise 3 (i-Frame Guard & Event Precedence)**:
   In the PC full-screen client, `pc_input_controller.js` is the dedicated, authoritative controller. `skill_bar_controller.js` retains a legacy key listener intended for mobile. If `pc_input_controller.js` uses standard bubbling event registration, `skill_bar_controller._keyHandler` intercepts Space, calls `activateSlot('dodge')`, and triggers a spurious charge decrement when `doDodge()` early-returns during an i-frame (O2).
4. **Premise 4 (Capture Phase Immunity)**:
   Registering `pc_input_controller`'s keydown listener in the capture phase (`window.addEventListener('keydown', (e) => this.onKeyDown(e), true)`) and executing `e.stopImmediatePropagation?.()` on `Space` completely silences `skill_bar_controller._keyHandler`. Furthermore, checking `if (window.player && window.player.isIFrame) return;` inside `pc_input_controller.js` guarantees that spamming Space while dodging is an immediate no-op, perfectly preserving charges.
5. **Premise 5 (DOM Contract for Rendering Engine)**:
   Because `client/webapp/js/engine/canvas_renderer.js` is shared and cannot be modified without violating the Zero Mobile Regression mandate, `client/web_pc/index.html` must provide `<div id="badge-iframe" class="hidden"></div>`. This prevents the null pointer dereference at line 219 when the 0.25s i-frame window expires (O3).
6. **Conclusion**:
   Applying the capture phase listener and iFrame guard to `pc_input_controller.js`, restricting `triggerUiCooldown` to the fallback branch, and providing `#badge-iframe` in `client/web_pc/index.html` completely eliminates all reported bugs and achieves 100% test pass.

---

## 3. Caveats

- **No Caveats**: All findings and fixes have been empirically demonstrated and proven via live headless browser execution (`test_proposed_fix.py`).
- As per the Teamwork Explorer identity and system constraints, this agent has NOT modified any project source code files (`client/web_pc/js/pc_input_controller.js` or `client/web_pc/index.html`). All code diffs below are ready for direct drop-in application by the implementation Worker.

---

## 4. Conclusion & Concrete Action Plan for Worker

### Concrete Action Item 1: Patch `client/web_pc/js/pc_input_controller.js`

#### Change 1.1: Use Capture Phase for Keydown Listener
- **Target File**: `client/web_pc/js/pc_input_controller.js`
- **Location**: Line 40
- **Diff**:
```javascript
<<<<
      // Keyboard bindings
      window.addEventListener('keydown', (e) => this.onKeyDown(e));
====
      // Keyboard bindings (use capture phase to ensure authoritative PC input priority)
      window.addEventListener('keydown', (e) => this.onKeyDown(e), true);
>>>>
```

#### Change 1.2: Refactor Spacebar Handler
- **Target File**: `client/web_pc/js/pc_input_controller.js`
- **Location**: Lines 185–198
- **Diff**:
```javascript
<<<<
      // Spacebar: Huyễn Ảnh Bộ (Dodge Roll with 0.25s i-frame)
      if (e.code === 'Space') {
        e.preventDefault();
        if (typeof window.doDodge === 'function') {
          window.doDodge();
        } else if (window.player) {
          window.player.isIFrame = true;
          window.player.iFrameTimer = 0.25;
          window.player.animState = 'dodge';
        }
        this.triggerUiCooldown('dodge', 3.0);
        return;
      }
====
      // Spacebar: Huyễn Ảnh Bộ (Dodge Roll with 0.25s i-frame)
      if (e.code === 'Space') {
        e.preventDefault();
        if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
        if (window.player && window.player.isIFrame) return;
        if (typeof window.doDodge === 'function') {
          window.doDodge();
        } else if (window.player) {
          if (window.skillBarController && typeof window.skillBarController.isSlotOnCooldown === 'function' && window.skillBarController.isSlotOnCooldown('dodge')) {
            return;
          }
          window.player.isIFrame = true;
          window.player.iFrameTimer = 0.25;
          window.player.animState = 'dodge';
          this.triggerUiCooldown('dodge', 3.0);
        }
        return;
      }
>>>>
```

---

### Concrete Action Item 2: Add `#badge-iframe` to `client/web_pc/index.html`

- **Target File**: `client/web_pc/index.html`
- **Location**: Around Line 94 (inside Top Right Telemetry container)
- **Diff**:
```html
<<<<
        <span class="font-mono text-[10px] text-stone-400 px-1">
          <span id="hud-ping">12</span>ms
        </span>
        <span class="w-px h-3.5 bg-stone-800 my-auto"></span>
====
        <span class="font-mono text-[10px] text-stone-400 px-1">
          <span id="hud-ping">12</span>ms
        </span>
        <div id="badge-iframe" class="hidden px-1.5 py-0.5 rounded bg-stone-800 text-stone-300 border border-stone-600 font-mono text-[9px]">I-FRAME</div>
        <span class="w-px h-3.5 bg-stone-800 my-auto"></span>
>>>>
```

---

### Concrete Action Item 3: Add E2E Verification Test to `tests/e2e/test_pc_desktop_client_e2e.py`

- **Target File**: `tests/e2e/test_pc_desktop_client_e2e.py`
- **Method to Append**:
```python
    def test_tier2_spacebar_dodge_charge_accounting_and_iframe_lifecycle(self, pc_server, browser_instance) -> None:
        """Tier 2: Verifies single space tap decrements exactly 1 charge (3 -> 2),
        spamming Space during i-frame does NOT waste charges, and i-frame expiry
        completes without TypeError classList exceptions."""
        page = open_pc_page(browser_instance, f"{pc_server}/index.html")
        errors = []
        page.on("pageerror", lambda err: errors.append(f"PageError: {err}"))
        page.on("console", lambda m: errors.append(f"ConsoleError: {m.text}") if m.type == "error" and "favicon" not in m.text else None)

        # Step 0: Verify initial 3 charges
        init_charges = page.evaluate("() => window.skillBarController?.slots?.get('dodge')?.charges")
        assert init_charges == 3, f"Expected 3 initial dodge charges, got {init_charges}"

        # Step 1: Tap Space once -> must decrement exactly 1 charge (leaving 2)
        page.keyboard.press("Space")
        page.wait_for_timeout(50)
        charges_after_1 = page.evaluate("() => window.skillBarController?.slots?.get('dodge')?.charges")
        is_iframe_1 = page.evaluate("() => window.player?.isIFrame")
        assert charges_after_1 == 2, f"Single Space tap must decrement exactly 1 charge (expected 2, got {charges_after_1})"
        assert is_iframe_1 is True, "i-Frame must be active during dodge roll"

        # Step 2: Press Space again while already dodging (isIFrame === true) -> must NOT waste charges
        page.keyboard.press("Space")
        page.wait_for_timeout(30)
        charges_during_iframe = page.evaluate("() => window.skillBarController?.slots?.get('dodge')?.charges")
        assert charges_during_iframe == 2, f"Pressing Space while dodging must NOT consume charge (expected 2, got {charges_during_iframe})"

        # Step 3: Wait for 0.25s i-frame window to elapse -> verify no classList null exception
        page.wait_for_function("() => !window.player?.isIFrame", timeout=2000)
        is_iframe_expired = page.evaluate("() => window.player?.isIFrame")
        assert is_iframe_expired is False, "i-Frame must expire after duration"
        assert len(errors) == 0, f"Expected zero errors after i-frame expiration, got {errors}"

        # Step 4: Tap Space a 2nd valid time -> charges drop to 1
        page.keyboard.press("Space")
        page.wait_for_timeout(50)
        charges_after_2 = page.evaluate("() => window.skillBarController?.slots?.get('dodge')?.charges")
        assert charges_after_2 == 1, f"Second dodge must leave 1 charge, got {charges_after_2}"

        # Step 5: Wait for i-frame to elapse and tap Space a 3rd valid time -> charges drop to 0
        page.wait_for_function("() => !window.player?.isIFrame", timeout=2000)
        page.keyboard.press("Space")
        page.wait_for_timeout(50)
        charges_after_3 = page.evaluate("() => window.skillBarController?.slots?.get('dodge')?.charges")
        is_on_cd = page.evaluate("() => window.skillBarController?.isSlotOnCooldown('dodge')")
        assert charges_after_3 == 0, f"Third dodge must deplete charges to 0, got {charges_after_3}"
        assert is_on_cd is True, "Dodge slot must be on cooldown when charges == 0"

        # Step 6: Tap Space while depleted -> charges remain 0 and no error occurs
        page.keyboard.press("Space")
        page.wait_for_timeout(50)
        charges_depleted = page.evaluate("() => window.skillBarController?.slots?.get('dodge')?.charges")
        assert charges_depleted == 0, f"Depleted dodge must remain at 0 charges, got {charges_depleted}"
        assert len(errors) == 0, f"Unexpected errors during depleted dodge press: {errors}"

        page.close()
```

---

## 5. Verification Method

To independently verify that the remediation works:

### 1. Run the Empirical Simulation Harness:
```bash
python .agents/teamwork/explorer_18_3/test_proposed_fix.py
```
**Expected Output**:
```
Step 0: Initial charges = 3
Step 1: Charges after 1st Space = 2, isIFrame = True
Step 2 pre-check: {'isIFrame': True, 'timer': 0.25}
Step 2 post-check: {'isIFrame': True, 'timer': 0.25}
Step 2: Charges after spamming Space during i-frame = 2
Step 3: After iFrame expiry, isIFrame = False, errors = []
Step 4: Charges after 2nd valid Space = 1
Step 5: Charges after 3rd valid Space = 0, isSlotOnCooldown = True
Step 6: Charges after pressing Space while depleted = 0

ALL 6 VERIFICATION CHECKS PASSED EMPIRICALLY!
```

### 2. Run the Challenger Targeted Harness:
```bash
python .agents/teamwork/challenger_18_1/test_targeted_findings.py
```
**Invalidation Conditions (Must Pass After Fix)**:
- `[CHARGES AFTER 1 SPACE PRESS]: 2` (NOT 1)
- `[PAGE ERRORS AFTER IFRAME EXPIRY]: []` (zero classList exceptions)

### 3. Run the Unit & E2E Suites:
```bash
pytest tests/unit/test_pc_input_controller.py tests/e2e/test_pc_desktop_client_e2e.py -v
```
All unit tests and E2E tests must pass 100% Green.
