Compare commits

...
4 Commits
Author SHA1 Message Date
uberwald f47f2906a4 fix: D30 special effects breaking combat comparison, map auto-pan, dead targets
Release Creation / build (release) Successful in 1m35s
- Fix handleAttackBoosted guard: replaced attackerIsCrossClient check
  with defenderIsMine so the socket handler runs whenever the current
  user controls the defender. Previously, when a GM-controlled monster
  attacked with a D30 special result (damage multiplier, bleed, DR
  multiplier, mulligan), the createChatMessage hook was suppressed
  (d30PendingFromGM) but handleAttackBoosted returned early because
  the attacker wasn't a cross-client PC, so nobody created the
  comparison message.
- Remove map auto-pan on target/damage button hover (mouseenter/mouseleave
  handlers on .request-defense-btn and .apply-wounds-btn).
- Filter defeated combatants and dead monster tokens from the target
  selection list. Characters at 0 HP are still selectable (death saves).
- Restore 3D dice animation for damage rolls (lethal-fantasy.mjs,
  character-sheet.mjs).
- Allow negative bonuses on weapon items (damaged weapons).
- Allow negative HP for characters (death saving throws).
- Fix D30Roll.convertToInternalType guard to warn on missing weapon
  regardless of isRanged flag.
- Fix ranged weapon loading comparison (<= to <).
- Add isRangedAttack to rollTarget for consistent ranged detection.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-08-17 20:12:12 +02:00
uberwald 0228f351c7 fix(combat): prevent double dice rolls in handleAttackBoosted
Release Creation / build (release) Successful in 52s
When a GM-owned attacker attacks a player character, both the
createChatMessage hook and handleAttackBoosted ran on the player's
client — rolling D30 bonus dice, grit, luck, shield, and mulligan
dice twice. Now handleAttackBoosted early-returns when the attacker
is not cross-client (GM-owned), leaving the hook as the sole handler.
2026-08-08 20:39:38 +02:00
uberwald 570ca6c953 fix(d30): text cleanup — OR→AND for bonus dice, mulligan gets (players only)
Release Creation / build (release) Successful in 59s
D30=30 and D30=20 bonus dice descriptions now say 'and' instead of 'or'
to reflect auto-roll behavior (no dialog choice). Mulligan D30=8 entries
appended with '(players only)' as GM reminder.
2026-07-15 23:18:44 +02:00
uberwald 4fd997d169 Renable compendiums 2026-07-07 18:44:59 +02:00
4 changed files with 42 additions and 46 deletions
+19 -19
View File
@@ -17,7 +17,7 @@
"target": "attack" "target": "attack"
} }
], ],
"description": "Possible Lethal or Vital Strike or Add D20E to Attack" "description": "Possible Lethal or Vital Strike and Add D20E to Attack"
}, },
"ranged_attack": { "ranged_attack": {
"type": "choice", "type": "choice",
@@ -35,7 +35,7 @@
"target": "attack" "target": "attack"
} }
], ],
"description": "Possible Lethal or Vital Strike or Add D20E to Attack" "description": "Possible Lethal or Vital Strike and Add D20E to Attack"
}, },
"melee_defense": { "melee_defense": {
"type": "choice", "type": "choice",
@@ -53,7 +53,7 @@
"target": "defense" "target": "defense"
} }
], ],
"description": "Possible Flawless or Legendary Defense or Add D20E to Defense" "description": "Possible Flawless or Legendary Defense and Add D20E to Defense"
}, },
"arcane_spell_attack": { "arcane_spell_attack": {
"type": "choice", "type": "choice",
@@ -71,7 +71,7 @@
"target": "spell_attack" "target": "spell_attack"
} }
], ],
"description": "Possible Lethal or Vital Magical Strike or Add D20E to Spell Attack" "description": "Possible Lethal or Vital Magical Strike and Add D20E to Spell Attack"
}, },
"skill_rolls": { "skill_rolls": {
"type": "skill_auto_success", "type": "skill_auto_success",
@@ -93,7 +93,7 @@
"target": "defense" "target": "defense"
} }
], ],
"description": "Possible Flawless or Legendary Defense or Add D20E to Defense" "description": "Possible Flawless or Legendary Defense and Add D20E to Defense"
}, },
"arcane_spell_defense": { "arcane_spell_defense": {
"type": "choice", "type": "choice",
@@ -107,7 +107,7 @@
"target": "spell_defense" "target": "spell_defense"
} }
], ],
"description": "Possible Spell Catastrophe or adds D20E to Spell Defense" "description": "Possible Spell Catastrophe and adds D20E to Spell Defense"
} }
}, },
"29": { "29": {
@@ -257,7 +257,7 @@
"target": "attack" "target": "attack"
} }
], ],
"description": "Possible Vicious Strike or Add D12 to attack" "description": "Possible Vicious Strike and Add D12 to attack"
}, },
"ranged_attack": { "ranged_attack": {
"type": "choice", "type": "choice",
@@ -274,7 +274,7 @@
"target": "attack" "target": "attack"
} }
], ],
"description": "Possible Vicious Strike or add D12 to attack" "description": "Possible Vicious Strike and add D12 to attack"
}, },
"melee_defense": { "melee_defense": {
"type": "choice", "type": "choice",
@@ -291,7 +291,7 @@
"target": "defense" "target": "defense"
} }
], ],
"description": "Possible 20/20 defense that avoids Any Attack Except a Lethal Strike or adds D12 to defense" "description": "Possible 20/20 defense that avoids Any Attack Except a Lethal Strike and adds D12 to defense"
}, },
"arcane_spell_attack": { "arcane_spell_attack": {
"type": "choice", "type": "choice",
@@ -308,7 +308,7 @@
"target": "spell_attack" "target": "spell_attack"
} }
], ],
"description": "Possible Vicious Application of a Magical Attack or add D12 to attack" "description": "Possible Vicious Application of a Magical Attack and add D12 to attack"
}, },
"skill_rolls": { "skill_rolls": {
"type": "bonus_flat", "type": "bonus_flat",
@@ -331,7 +331,7 @@
"target": "defense" "target": "defense"
} }
], ],
"description": "Possible 20/20 defense that avoids Any Attack Except a Lethal Strike or adds D12 to defense" "description": "Possible 20/20 defense that avoids Any Attack Except a Lethal Strike and adds D12 to defense"
}, },
"arcane_spell_defense": { "arcane_spell_defense": {
"type": "choice", "type": "choice",
@@ -348,7 +348,7 @@
"target": "spell_defense" "target": "spell_defense"
} }
], ],
"description": "Possible 20/20 Spell defense that Saves Against Any Magical Attack Except a Lethal Magical Strike or add D12 to spell defense" "description": "Possible 20/20 Spell defense that Saves Against Any Magical Attack Except a Lethal Magical Strike and add D12 to spell defense"
} }
}, },
"15": { "15": {
@@ -446,31 +446,31 @@
"8": { "8": {
"melee_attack": { "melee_attack": {
"type": "mulligan", "type": "mulligan",
"description": "Mulligan, Can Choose to Re-roll This Attack" "description": "Mulligan, Can Choose to Re-roll This Attack (players only)"
}, },
"ranged_attack": { "ranged_attack": {
"type": "mulligan", "type": "mulligan",
"description": "Mulligan, Can Choose to Re-Roll This Attack" "description": "Mulligan, Can Choose to Re-Roll This Attack (players only)"
}, },
"melee_defense": { "melee_defense": {
"type": "mulligan", "type": "mulligan",
"description": "Mulligan, Can Choose to Re-Roll This Defense" "description": "Mulligan, Can Choose to Re-Roll This Defense (players only)"
}, },
"arcane_spell_attack": { "arcane_spell_attack": {
"type": "mulligan", "type": "mulligan",
"description": "Mulligan, Can Re-Roll This Spell Attack" "description": "Mulligan, Can Re-Roll This Spell Attack (players only)"
}, },
"skill_rolls": { "skill_rolls": {
"type": "mulligan", "type": "mulligan",
"description": "Mulligan, Can Re-Roll This Skill roll" "description": "Mulligan, Can Re-Roll This Skill roll (players only)"
}, },
"ranged_defense": { "ranged_defense": {
"type": "mulligan", "type": "mulligan",
"description": "Mulligan, Can Choose to Re-Roll This Defense" "description": "Mulligan, Can Choose to Re-Roll This Defense (players only)"
}, },
"arcane_spell_defense": { "arcane_spell_defense": {
"type": "mulligan", "type": "mulligan",
"description": "Mulligan, Can Re-Roll This Spell Defense" "description": "Mulligan, Can Re-Roll This Spell Defense (players only)"
} }
}, },
"7": { "7": {
+4 -1
View File
@@ -163,7 +163,7 @@ export default class LethalFantasyRoll extends Roll {
// D'abord, ajouter les combattants du combat actif // D'abord, ajouter les combattants du combat actif
if (game?.combat?.combatants) { if (game?.combat?.combatants) {
for (let c of game.combat.combatants) { for (let c of game.combat.combatants) {
if (c.actorId !== this.actorId) { if (c.actorId !== this.actorId && !c.isDefeated) {
combatants.push({ id: c.id, name: c.name, tokenId: c.token.id }) combatants.push({ id: c.id, name: c.name, tokenId: c.token.id })
} }
} }
@@ -174,6 +174,9 @@ export default class LethalFantasyRoll extends Roll {
const existingTokenIds = new Set(combatants.map(c => c.tokenId)) const existingTokenIds = new Set(combatants.map(c => c.tokenId))
for (let token of canvas.scene.tokens) { for (let token of canvas.scene.tokens) {
if (token.actorId !== this.actorId && !existingTokenIds.has(token.id)) { if (token.actorId !== this.actorId && !existingTokenIds.has(token.id)) {
// Skip dead tokens: monsters at 0 HP are dead; characters use the defeated flag
const tokenActor = game.actors.get(token.actorId)
if (tokenActor?.type === "monster" && (Number(tokenActor?.system?.hp?.value) || 0) <= 0) continue
combatants.push({ combatants.push({
id: token.id, id: token.id,
name: token.name, name: token.name,
-26
View File
@@ -33,38 +33,12 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => {
} }
for (const btn of html.querySelectorAll(".apply-wounds-btn")) { for (const btn of html.querySelectorAll(".apply-wounds-btn")) {
btn.addEventListener("mouseenter", () => {
const combatantId = btn.dataset.combatantId
if (combatantId && game.combat) {
const combatant = game.combat.combatants.get(combatantId)
if (combatant?.token) {
const token = canvas.tokens.get(combatant.token.id)
if (token) {
token.control({ releaseOthers: true })
canvas.animatePan(token.center)
}
}
}
})
btn.addEventListener("mouseleave", () => canvas.tokens.releaseAll())
btn.addEventListener("click", event => LethalFantasyUtils.applyDamage(message, event)) btn.addEventListener("click", event => LethalFantasyUtils.applyDamage(message, event))
} }
} }
// Gestion du survol et du clic sur les boutons de défense // Gestion du survol et du clic sur les boutons de défense
for (const btn of html.querySelectorAll(".request-defense-btn")) { for (const btn of html.querySelectorAll(".request-defense-btn")) {
btn.addEventListener("mouseenter", () => {
const tokenId = btn.dataset.tokenId
if (tokenId) {
const token = canvas.tokens.get(tokenId)
if (token) {
token.control({ releaseOthers: true })
canvas.animatePan(token.center)
}
}
})
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", async event => { btn.addEventListener("click", async event => {
event.preventDefault() event.preventDefault()
+19
View File
@@ -105,8 +105,26 @@ export async function handleAttackBoosted(msg) {
} = msg } = msg
const defender = game.actors.get(defenderId) const defender = game.actors.get(defenderId)
const attacker = game.actors.get(attackerId)
if (!defender) return if (!defender) return
// The createChatMessage hook is suppressed on this client in two cases:
// 1. Attacker is a PC on another client — the hook's defense reaction loop
// and shouldCreateMessage are both skipped.
// 2. Attacker is GM-controlled with D30 — the hook skips everything and
// waits for this socket handler.
// In both cases, this handler must run to show the defense dialog and create
// the comparison message. The common condition: the current user is the
// defender's primary controller.
const _isPrimaryController = actor => {
if (!actor) return false
const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER"))
if (activePlayerOwners.length > 0) return activePlayerOwners[0].id === game.user.id
return game.user.isGM
}
const defenderIsMine = _isPrimaryController(defender)
if (!defenderIsMine) return
let updatedDefenseRoll = defenseRoll let updatedDefenseRoll = defenseRoll
let shieldBlocked = false let shieldBlocked = false
let shieldReaction = null let shieldReaction = null
@@ -237,6 +255,7 @@ export async function handleAttackBoosted(msg) {
await ChatMessage.create({content: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})}) await ChatMessage.create({content: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
} }
if (d30Result.specialEffect === "drMultiplier") { if (d30Result.specialEffect === "drMultiplier") {
defenseDrMultiplier = d30Result.multiplier
const rmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30DRMultiplier", actorName:defenderName, value:d30Result.multiplier}) const rmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30DRMultiplier", actorName:defenderName, value:d30Result.multiplier})
await ChatMessage.create({content: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})}) await ChatMessage.create({content: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
} }