# Lethal Fantasy FoundryVTT System — Session Context ## Current Goal Fix Grit/Luck defense reaction dialog UX (stacking dialogs, multiple clicks, revert on close) and cross-client sync of defense bonuses. ## Accomplished ### Pass 1 — Critical Issues - **Telemetry removed**: `ClassCounter`, `registerWorldCount`, orphaned `worldKey` setting deleted from system.json - **globalThis side effects**: `globalThis.SYSTEM`, `globalThis.pendingDefenses` moved from top-level to `init` hook - **console.log → log()**: All runtime console.log replaced with `log()` helper guarded by `lethalFantasy.debug` setting - **Stale Tenebris refs**: `macros.mjs` — `TENEBRIS.Label.jet` → `LETHALFANTASY.Label.jet`, `TENEBRIS.Manager.*` → `LETHALFANTASY.Label.*`, `tenebris.macro` flag → `lethalFantasy.macro` ### Pass 2 — V1/V2 Mixing, Fire-and-Forget - **V1 sheet registrations removed**: `foundry.appv1.sheets.*` in system.json - **V1 `activateListeners`/jQuery**: removed dead `defaultOptions`, V1 tab code from `combat.mjs` - **V2 API paths**: `FilePicker` → V2, `TextEditor.getDragEventData` → V2, `item.sheet.render(true)` → `render({force:true})`, `super._onRender()` → `super._onRender(context, options)`, `token._id` → `token.id` - **Fire-and-forget Promises**: All `actor.update()`, `ChatMessage.create()`, `prepareRoll()`, `prepareMonsterRoll()`, socket handler calls now awaited - **Misnamed class**: `LethalFantasySkill` → `LethalFantasyWeapon`; added missing `WEAPON_TYPE` import; fixed `weaponCategory` ### Pass 3 — Code Review Fixes - **Duplicated dialogs**: Per-element `.rollable`/`.wound-data` bindings moved to `_onRender` (V2 destroys/recreates DOM each render); `_activateListeners` reverted - **renderChatMessage reverted**: V2 hook `renderChatMessage` passes jQuery html, `querySelectorAll` fails; kept `renderChatMessageHTML` - **Roll actions broken**: Fixed `async` base-actor-sheet methods; `_onRender` bindings for rollable elements restored - **Token HUD guard**: `html.querySelector()` → `html.find().length` (html is jQuery object) - **All review awaits confirmed**: `showDefenseRequest`/`socket` handlers all awaited ## Defense Dialog Investigation — Status ### Symptom (user process) 1. Monster (GM) attacks player — hits 2. Player uses Grit/Luck to boost defense 3. Defense now beats attack — reports new result 4. Dialog **stays open** — Grit/Luck/bonus dice options still visible 5. Closing dialog (Continue or X) causes "rolls vanish" — reverts to original result ### Root Cause Found — Duplicate cross-client processing (FIXED) When monster (GM) attacks player, the `createChatMessage` hook fires on **both** clients: ``` Player's client: GM's client: defense msg created defense msg synced ↓ ↓ hook fires (line 557) hook fires (line 557) isPrimaryController(defender)=true isPrimaryController(defender)=false ↓ ↓ Defense dialog A shows Defense dialog skipped Player spends Grit Cross-client code (line 1009): defenseRoll=10→16 isPrimaryController(attacker)=true While loop exits defenderOwner=player (≠GM) Comparison: "miss" ↓ **Sends attackBoosted with ORIGINAL defenseRoll=10 (stale!)** ↓ Player receives socket → handleAttackBoosted → Defense dialog B shows with OLD values → When closed, comparison: "hit" (overwrites!) ``` Player sees **two** dialogs (A then B). Dialog B uses unboosted values, so closing/ignoring it produces a stale "hit" result that overwrites the correct "miss." ### Fix `lethal-fantasy.mjs:1016` — only send `attackBoosted` socket when `attackerHandledBonus || attackerHasNonGMOwner`. Guards against stale-socket overwrite for GM→player combat (where hook-based processing works without socket), while preserving socket delegation for PC→PC cross-client (where `attackerIsCrossClient` suppresses the hook-based processing on the defender's client). Before: ```js if (defenderOwner && defenderOwner.id !== game.user.id) { game.socket.emit(`system.${SYSTEM.id}`, { type: "attackBoosted", ... }) return } ``` After: ```js if (defenderOwner && defenderOwner.id !== game.user.id) { if (attackerHandledBonus || attackerHasNonGMOwner) { game.socket.emit(`system.${SYSTEM.id}`, { type: "attackBoosted", ... }) } return } ``` ### Same-Client Path Code pattern is identical between attack and defense dialogs — both use `await DialogV2.wait({rejectClose:false})` in a while loop. Same-client defense works correctly because no duplicate socket messages arrive. ### Other Findings - `offerGritLuckBonus` (`utils.mjs:1121`) is dead code — never called - `promptCombatBonusDie` (`utils.mjs:975`) is correct — DialogV2 resolves to callback return value, not `action` - Cross-client `handleAttackBoosted` (`utils.mjs:291`) still uses `else if` chain without `continue` — functionally correct but differs from same-client pattern ### Code Paths | Flow | File | Line | |------|------|------| | Same-client attack | `lethal-fantasy.mjs` | 918-1004 | | Same-client defense | `lethal-fantasy.mjs` | 697-870 | | Cross-client defense | `module/utils.mjs` | 291-445 | | Cross-client socket guard | `lethal-fantasy.mjs` | 1006-1037 | | Attack Grit offer | `module/utils.mjs` | 1210-1290 | ### Key Files - `lethal-fantasy.mjs` — Main system hooks, same-client attack/defense reactions - `module/utils.mjs` — Cross-client defense flow, bonus dialogs, compareAttackDefense - `module/documents/actor.mjs` — `prepareRoll()` entry point - `module/documents/roll.mjs` — Roll resolution pipeline