Files
fvtt-lethal-fantasy/AGENTS.md
T
uberwald 2570bf707e fix: prevent duplicate cross-client defense dialog, clear bleed on heal
- Only send attackBoosted socket when attackerHandledBonus || attackerHasNonGMOwner
  (GM→player: hook handles it, no socket needed; PC→PC: socket needed)
- Clear bleeding wounds when HP restored via token HUD heal buttons
2026-06-12 17:23:39 +02:00

5.9 KiB

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.mjsTENEBRIS.Label.jetLETHALFANTASY.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._idtoken.id
  • Fire-and-forget Promises: All actor.update(), ChatMessage.create(), prepareRoll(), prepareMonsterRoll(), socket handler calls now awaited
  • Misnamed class: LethalFantasySkillLethalFantasyWeapon; 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:

if (defenderOwner && defenderOwner.id !== game.user.id) {
  game.socket.emit(`system.${SYSTEM.id}`, { type: "attackBoosted", ... })
  return
}

After:

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.mjsprepareRoll() entry point
  • module/documents/roll.mjs — Roll resolution pipeline