Compare commits

..
5 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
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 47 additions and 47 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": {
+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()
+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})})
} }