Compare commits

...
3 Commits
Author SHA1 Message Date
uberwald fc5d38deff fix: dead combatants reappearing as targets via scene token fallback
Release Creation / build (release) Successful in 54s
The combat loop correctly skipped defeated combatants, but their token
IDs were absent from existingTokenIds, so the scene token loop re-added
them. Now building a defeatedTokenIds set from defeated combatants and
checking it in the scene token loop. Also switched from
game.actors.get() to token.actor for the monster HP check so unlinked
monster tokens (synthetic actors) are covered.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-08-18 18:38:10 +02:00
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
3 changed files with 27 additions and 28 deletions
+9 -2
View File
@@ -161,11 +161,15 @@ export default class LethalFantasyRoll extends Roll {
let isAttack = this.type === "weapon-attack" || this.type === "monster-attack" || this.type === "spell-attack" || this.type === "miracle-attack" let isAttack = this.type === "weapon-attack" || this.type === "monster-attack" || this.type === "spell-attack" || this.type === "miracle-attack"
if (this.rollData?.isDamage || isAttack) { if (this.rollData?.isDamage || isAttack) {
// D'abord, ajouter les combattants du combat actif // D'abord, ajouter les combattants du combat actif
const defeatedTokenIds = new Set()
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 })
} }
if (c.isDefeated && c.token?.id) {
defeatedTokenIds.add(c.token.id)
}
} }
} }
@@ -173,7 +177,10 @@ export default class LethalFantasyRoll extends Roll {
if (canvas?.scene?.tokens) { if (canvas?.scene?.tokens) {
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) && !defeatedTokenIds.has(token.id)) {
// Skip dead monsters (0 HP). Use token.actor for unlinked tokens.
const tokenActor = token.actor ?? 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()
+18
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