- 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
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, orphanedworldKeysetting deleted from system.json - globalThis side effects:
globalThis.SYSTEM,globalThis.pendingDefensesmoved from top-level toinithook - console.log → log(): All runtime console.log replaced with
log()helper guarded bylethalFantasy.debugsetting - Stale Tenebris refs:
macros.mjs—TENEBRIS.Label.jet→LETHALFANTASY.Label.jet,TENEBRIS.Manager.*→LETHALFANTASY.Label.*,tenebris.macroflag →lethalFantasy.macro
Pass 2 — V1/V2 Mixing, Fire-and-Forget
- V1 sheet registrations removed:
foundry.appv1.sheets.*in system.json - V1
activateListeners/jQuery: removed deaddefaultOptions, V1 tab code fromcombat.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 missingWEAPON_TYPEimport; fixedweaponCategory
Pass 3 — Code Review Fixes
- Duplicated dialogs: Per-element
.rollable/.wound-databindings moved to_onRender(V2 destroys/recreates DOM each render);_activateListenersreverted - renderChatMessage reverted: V2 hook
renderChatMessagepasses jQuery html,querySelectorAllfails; keptrenderChatMessageHTML - Roll actions broken: Fixed
asyncbase-actor-sheet methods;_onRenderbindings for rollable elements restored - Token HUD guard:
html.querySelector()→html.find().length(html is jQuery object) - All review awaits confirmed:
showDefenseRequest/sockethandlers all awaited
Defense Dialog Investigation — Status
Symptom (user process)
- Monster (GM) attacks player — hits
- Player uses Grit/Luck to boost defense
- Defense now beats attack — reports new result
- Dialog stays open — Grit/Luck/bonus dice options still visible
- 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 calledpromptCombatBonusDie(utils.mjs:975) is correct — DialogV2 resolves to callback return value, notaction- Cross-client
handleAttackBoosted(utils.mjs:291) still useselse ifchain withoutcontinue— 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 reactionsmodule/utils.mjs— Cross-client defense flow, bonus dialogs, compareAttackDefensemodule/documents/actor.mjs—prepareRoll()entry pointmodule/documents/roll.mjs— Roll resolution pipeline