Compare commits

...

7 Commits

Author SHA1 Message Date
uberwald 90fde23ebc docs: update AGENTS.md with partial registration and try/catch fixes
Release Creation / build (release) Successful in 45s
2026-07-05 21:12:20 +02:00
uberwald d17c9cba49 fix: wrap do-while loop body in try/catch to protect against exceptions
Any exception from createReactionMessage or renderTemplate inside the
defense/attack reaction loops would silently abort the handler, preventing
combat result creation. The try/catch logs the error and continues safely.
2026-07-05 20:55:29 +02:00
uberwald bfbce9557b fix: wrap mulligan message creation in try/catch to prevent handler abort
Even with the partial correctly registered, a template rendering failure
should not abort the entire reroll handler. Without try/catch, the
exception propagates out of the do-while loop and prevents combat result
creation. The reroll value was applied but nothing else happened.
2026-07-05 20:51:26 +02:00
uberwald 881cecf5b7 chore: remove DevTools session artifacts 2026-07-05 20:45:29 +02:00
uberwald de31c397fa fix: use object key for partial ID to match template reference
loadTemplates with an array registers the partial using the full file
path as its Handlebars ID (e.g.
'systems/fvtt-lethal-fantasy/templates/chat/_dice-breakdown.hbs'), but
the template references it by the short name 'chat/dice-breakdown'.
Using an object with explicit keys ensures the partial ID matches.
2026-07-05 20:45:24 +02:00
uberwald fe346f46bf fix: register _dice-breakdown.hbs partial, await ready hook promises
Release Creation / build (release) Successful in 56s
Mulligan reroll reaction message creation was failing silently because
the chat/dice-breakdown Handlebars partial was never registered via
loadTemplates. This caused an unhandled promise rejection that aborted
the entire reroll handler chain — no mulligan message, no combat result.
2026-07-04 09:22:38 +02:00
uberwald 96e5bd5b4d fix: pre-process D30 attack bonus at source for cross-client agreement
Release Creation / build (release) Successful in 48s
D30 bonus dice now rolled at defense request time, not in defense handler.
Sends boosted attackRoll + d30AttackEffects in socket/nextDefenseData
so both clients agree on the value. Adds stale flag for mulligan rerolls
to bypass stale pre-computed effects and process the new D30 fresh.
2026-07-03 22:39:10 +02:00
4 changed files with 112 additions and 43 deletions
+8
View File
@@ -50,6 +50,14 @@ Fix Grit/Luck defense reaction dialog UX (stacking dialogs, multiple clicks, rev
- **Import `hasD30Reroll` added** to `utils/combat.mjs` - **Import `hasD30Reroll` added** to `utils/combat.mjs`
- **`bleed` top-level type handler added** to `processD30BonusDice` in `d30.mjs:79-81` — returns `specialEffect: "bleed"` same as combo path, so ranged attack bleed (values 5,10,15) creates reaction message and sets damage button bleed flag. - **`bleed` top-level type handler added** to `processD30BonusDice` in `d30.mjs:79-81` — returns `specialEffect: "bleed"` same as combo path, so ranged attack bleed (values 5,10,15) creates reaction message and sets damage button bleed flag.
### Pass 7 — Cross-Client Attack D30 Pre-Processing
- **BUG FIX: D30 attack bonus processed at source (defense request time) instead of in defense handler** — `chat-reaction.mjs:136-148` now calls `processD30BonusDice` in the defense request button click handler, sends the boosted `attackRoll` and full `d30AttackEffects` result in the defense request. The `createChatMessage` handler at `chat-reaction.mjs:622-626` uses the pre-computed values instead of re-processing (avoids double dice roll and ensures both clients agree on attack value).
- **Fix ensures cross-client agreement**: Player and GM clients see the same boosted attack roll because it's computed once at attack time and sent via socket, not separately per client in the defense handler.
- **BUG FIX: `d30AttackEffects` not propagated through `_storeNextDefenseData`** — added `d30AttackEffects` to `nextDefenseData` in `combat.mjs:353-355` so the `createChatMessage` handler finds pre-computed values for same-client path (was silently falling back to legacy dice-roll, causing double boost).
- **BUG FIX: `d30AttackPrecomputedStale` flag for mulligan rerolls** — added `chat-reaction.mjs:464` flag and check at `chat-reaction.mjs:632-634`. After a mulligan reroll updates `attackD30message`, the stale pre-computed effects are bypassed and the new D30 message is processed fresh via `processD30BonusDice`.
- **BUG FIX: `_dice-breakdown.hbs` partial never registered** — `loadTemplates([path])` registers partials with the full file path as the Handlebars ID, but the template references it by the short name `chat/dice-breakdown`. Fixed by passing an object `{"chat/dice-breakdown": "path"}` which explicitly sets the partial ID to match the template reference.
- **SAFETY: do-while loop body wrapped in try/catch** — any exception from `createReactionMessage` calls now logs the error and continues safely instead of silently aborting the handler.
## Key Decisions ## Key Decisions
- **Auto-roll bonus dice without dialog** — matches existing D30=27 (d6E) flow - **Auto-roll bonus dice without dialog** — matches existing D30=27 (d6E) flow
- **`buildDefenseReactionButtons` extracts only button-building** — defense while-loop structures differ between same-client and cross-client; merging loops risks behavioral divergence - **`buildDefenseReactionButtons` extracts only button-building** — defense while-loop structures differ between same-client and cross-client; merging loops risks behavioral divergence
+10 -2
View File
@@ -108,11 +108,19 @@ function preLocalizeConfig() {
} }
} }
Hooks.once("ready", function () { Hooks.once("ready", async function () {
console.info("LETHAL FANTASY | Ready") console.info("LETHAL FANTASY | Ready")
// Initialiser la table des résultats D30 // Initialiser la table des résultats D30
documents.D30Roll.initialize() await documents.D30Roll.initialize()
// Register Handlebars partials used by chat templates.
// The object key is the partial ID referenced in templates (e.g., {{> chat/dice-breakdown}}),
// the value is the file path. Using an array would register with the full path as the ID,
// which does NOT match the template reference.
await foundry.applications.handlebars.loadTemplates({
"chat/dice-breakdown": "systems/fvtt-lethal-fantasy/templates/chat/_dice-breakdown.hbs"
})
// Saignement piloté par le combat tracker // Saignement piloté par le combat tracker
_registerBleedingHooks() _registerBleedingHooks()
+56 -9
View File
@@ -66,7 +66,7 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => {
btn.addEventListener("mouseleave", () => canvas.tokens.releaseAll()) btn.addEventListener("mouseleave", () => canvas.tokens.releaseAll())
// Gestionnaire pour les boutons de demande de défense // Gestionnaire pour les boutons de demande de défense
btn.addEventListener("click", event => { btn.addEventListener("click", async event => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -140,13 +140,24 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => {
|| (attackerWeapon?.system?.weaponType === "ranged") || (attackerWeapon?.system?.weaponType === "ranged")
|| (rollTargetOptions?.isRangedAttack === true) || (rollTargetOptions?.isRangedAttack === true)
// Process D30 attack bonus at source so both clients get the same boosted value.
// The d30BonusRoll chat message is created here (by _rollD30BonusDie inside processD30BonusDice).
let d30AttackEffects = null
let boostedAttackRoll = attackRoll
if (attackD30message && attacker) {
d30AttackEffects = await LethalFantasyUtils.processD30BonusDice(attackD30message, "attack", attackNaturalRoll, attacker, true)
if (d30AttackEffects.modifier) {
boostedAttackRoll = attackRoll + d30AttackEffects.modifier
}
}
const defenseMsg = { const defenseMsg = {
type: "requestDefense", type: "requestDefense",
attackerName, attackerName,
attackerId, attackerId,
defenderName, defenderName,
weaponName, weaponName,
attackRoll, attackRoll: boostedAttackRoll,
attackWeaponId, attackWeaponId,
attackRollType, attackRollType,
attackRollKey, attackRollKey,
@@ -157,7 +168,8 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => {
damageTier, damageTier,
combatantId, combatantId,
tokenId, tokenId,
isRanged: isRangedAttack isRanged: isRangedAttack,
d30AttackEffects
} }
// Envoyer le message socket à l'utilisateur contrôlant le combatant // Envoyer le message socket à l'utilisateur contrôlant le combatant
@@ -448,8 +460,13 @@ Hooks.on("createChatMessage", async (message) => {
let d30Bleed = false let d30Bleed = false
let d30DamageMultiplier = 1 let d30DamageMultiplier = 1
let d30DrMultiplier = 1 let d30DrMultiplier = 1
// Tracks whether the pre-computed D30 effects are stale (invalidated by a mulligan reroll
// that produced a new D30 message). When a reroll updates attackD30message, the original
// d30AttackEffects from the defense request no longer apply — the new D30 must be processed.
let d30AttackPrecomputedStale = false
do { do {
try {
mulliganRestart = false mulliganRestart = false
defenderHandledBonus = false defenderHandledBonus = false
attackerHandledBonus = false attackerHandledBonus = false
@@ -535,6 +552,7 @@ Hooks.on("createChatMessage", async (message) => {
canRerollDefense = false canRerollDefense = false
if (!reroll) continue if (!reroll) continue
defenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll defenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll
try {
await createReactionMessage(defender, { await createReactionMessage(defender, {
type: "mulligan", type: "mulligan",
actorName: defenderName, actorName: defenderName,
@@ -545,6 +563,9 @@ Hooks.on("createChatMessage", async (message) => {
D30result: reroll.options?.D30result, D30result: reroll.options?.D30result,
D30message: reroll.options?.D30message D30message: reroll.options?.D30message
}) })
} catch(e) {
console.error("Mulligan message creation failed (non-fatal):", e)
}
// Apply new D30 result on the restart // Apply new D30 result on the restart
if (reroll.options?.D30message) { if (reroll.options?.D30message) {
defenseD30message = reroll.options.D30message defenseD30message = reroll.options.D30message
@@ -605,26 +626,42 @@ Hooks.on("createChatMessage", async (message) => {
// ── D30 bonus dice (attack) — resolved before grit/luck ──────────────── // ── D30 bonus dice (attack) — resolved before grit/luck ────────────────
if (attackD30message && !attackD30Processed) { if (attackD30message && !attackD30Processed) {
const preD30AttackRoll = attackRollFinal const preD30AttackRoll = attackRollFinal
const canDialog = isPrimaryController(attacker) const canShow = isPrimaryController(attacker)
const d30Result = await LethalFantasyUtils.processD30BonusDice(attackD30message, "attack", attackNaturalRoll, attacker, canDialog)
// Use pre-computed D30 effects (processed at attack time in the defense
// request handler) to avoid re-rolling dice on the defense handler.
// This ensures both clients agree on the boosted attack roll.
// When d30AttackPrecomputedStale is true (invalidated by a mulligan reroll),
// skip the pre-computed effects and process the new D30 message fresh.
const usePrecomputed = !!attackData.d30AttackEffects && !d30AttackPrecomputedStale
const d30Result = usePrecomputed
? attackData.d30AttackEffects
: await LethalFantasyUtils.processD30BonusDice(attackD30message, "attack", attackNaturalRoll, attacker, canShow)
d30AttackPrecomputedStale = false
if (d30Result.modifier) { if (d30Result.modifier) {
if (!usePrecomputed) {
// Legacy or post-reroll path: bonus was just rolled, apply now
attackRollFinal += d30Result.modifier attackRollFinal += d30Result.modifier
if (d30Result.modifier > 0 && canDialog) { }
// d30BonusRoll message was created at attack time (pre-processed)
// or inside _rollD30BonusDie (legacy). Either way, show the application.
if (d30Result.modifier > 0 && canShow) {
await createReactionMessage(attacker, {type:"d30Bonus", actorName:attackerName, value:d30Result.modifier, side:"attack"}) await createReactionMessage(attacker, {type:"d30Bonus", actorName:attackerName, value:d30Result.modifier, side:"attack"})
} }
} }
if (d30Result.specialEffect === "flag" && canDialog) { if (d30Result.specialEffect === "flag" && canShow) {
await createReactionMessage(attacker, {type:"d30Flag", actorName:attackerName, specialName:d30Result.specialName||"Special Effect"}) await createReactionMessage(attacker, {type:"d30Flag", actorName:attackerName, specialName:d30Result.specialName||"Special Effect"})
} }
if (d30Result.specialEffect === "bleed") { if (d30Result.specialEffect === "bleed") {
d30Bleed = true d30Bleed = true
if (canDialog) { if (canShow) {
await createReactionMessage(attacker, {type:"d30Bleed", actorName:attackerName}) await createReactionMessage(attacker, {type:"d30Bleed", actorName:attackerName})
} }
} }
if (d30Result.specialEffect === "damageMultiplier") { if (d30Result.specialEffect === "damageMultiplier") {
d30DamageMultiplier = d30Result.multiplier d30DamageMultiplier = d30Result.multiplier
if (canDialog) { if (canShow) {
await createReactionMessage(attacker, {type:"d30DamageMultiplier", actorName:attackerName, value:d30Result.multiplier}) await createReactionMessage(attacker, {type:"d30DamageMultiplier", actorName:attackerName, value:d30Result.multiplier})
} }
} }
@@ -721,6 +758,7 @@ Hooks.on("createChatMessage", async (message) => {
canRerollAttack = false canRerollAttack = false
if (!reroll) continue if (!reroll) continue
attackRollFinal = reroll.options?.rollTotal || reroll.total || oldAttackRoll attackRollFinal = reroll.options?.rollTotal || reroll.total || oldAttackRoll
try {
await createReactionMessage(attacker, { await createReactionMessage(attacker, {
type: "mulligan", type: "mulligan",
actorName: attackerName, actorName: attackerName,
@@ -731,10 +769,15 @@ Hooks.on("createChatMessage", async (message) => {
D30result: reroll.options?.D30result, D30result: reroll.options?.D30result,
D30message: reroll.options?.D30message D30message: reroll.options?.D30message
}) })
} catch(e) {
console.error("Mulligan message creation failed (non-fatal):", e)
}
// Apply new D30 result on the restart // Apply new D30 result on the restart
if (reroll.options?.D30message) { if (reroll.options?.D30message) {
attackD30message = reroll.options.D30message attackD30message = reroll.options.D30message
attackD30Processed = false attackD30Processed = false
// Invalidate pre-computed effects — the new D30 message must be processed fresh
d30AttackPrecomputedStale = true
} }
// Restart the full comparison so both sides can react to the new roll // Restart the full comparison so both sides can react to the new roll
mulliganRestart = true mulliganRestart = true
@@ -787,6 +830,10 @@ Hooks.on("createChatMessage", async (message) => {
mulliganRestart = true mulliganRestart = true
} }
} }
} catch(e) {
console.error("Defense handler loop error (non-fatal):", e)
mulliganRestart = false
}
} while (mulliganRestart) } while (mulliganRestart)
const shieldDamageReduction = shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0 const shieldDamageReduction = shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0
+6
View File
@@ -206,6 +206,7 @@ export async function handleAttackBoosted(msg) {
if (!reroll) continue if (!reroll) continue
updatedDefenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll updatedDefenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll
let newD30message = reroll.options?.D30message || null let newD30message = reroll.options?.D30message || null
try {
const mulliganContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", { const mulliganContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {
type: "mulligan", type: "mulligan",
actorName: defenderName, actorName: defenderName,
@@ -217,6 +218,9 @@ export async function handleAttackBoosted(msg) {
D30message: newD30message D30message: newD30message
}) })
await ChatMessage.create({content: mulliganContent, speaker: ChatMessage.getSpeaker({actor: defender})}) await ChatMessage.create({content: mulliganContent, speaker: ChatMessage.getSpeaker({actor: defender})})
} catch(e) {
console.error("Mulligan message creation failed (non-fatal):", e)
}
// Process new D30 bonus dice from the reroll // Process new D30 bonus dice from the reroll
if (newD30message) { if (newD30message) {
defenseD30message = newD30message defenseD30message = newD30message
@@ -302,6 +306,7 @@ export async function showDefenseRequest(msg) {
const attackD30result = msg.attackD30result const attackD30result = msg.attackD30result
const attackD30message = msg.attackD30message const attackD30message = msg.attackD30message
const attackRerollContext = msg.attackRerollContext const attackRerollContext = msg.attackRerollContext
const d30AttackEffects = msg.d30AttackEffects
const combatantId = msg.combatantId const combatantId = msg.combatantId
const tokenId = msg.tokenId const tokenId = msg.tokenId
@@ -351,6 +356,7 @@ export async function showDefenseRequest(msg) {
damageTier: msg.damageTier, damageTier: msg.damageTier,
defenderId: defender.id, defenderTokenId, defenderId: defender.id, defenderTokenId,
...(msg.attackNaturalRoll !== undefined && { attackNaturalRoll: msg.attackNaturalRoll }), ...(msg.attackNaturalRoll !== undefined && { attackNaturalRoll: msg.attackNaturalRoll }),
...(d30AttackEffects !== undefined && { d30AttackEffects }),
...(opts.isRanged !== undefined && { isRanged: opts.isRanged }) ...(opts.isRanged !== undefined && { isRanged: opts.isRanged })
} }
} }