fix(combat): blessures appliquées au seul acteur du token ciblé

Résout par uuid avant l'id d'acteur : pour un token non lié (PNJ mineur),
token.actor.id expose encore l'id de l'acteur monde dont il est issu, et
résoudre par id blessait alors tous les tokens de la fiche. Le uuid d'un
acteur synthétique est résolu en extrayant directement la scène et le
token, car fromUuid échoue sur les documents non indexés quand la
résolution est asynchrone.
This commit is contained in:
2026-08-09 09:14:09 +02:00
parent 3a84ef09f6
commit 7382723e75
3 changed files with 69 additions and 26 deletions
+13
View File
@@ -61,3 +61,16 @@ make build / watch / lint # Makefile wrappers for above
- **No tests**: `npm test` is a stub (exits 1). No test framework installed. - **No tests**: `npm test` is a stub (exits 1). No test framework installed.
- **No CI**: `.github/` is gitignored; no workflows configured. - **No CI**: `.github/` is gitignored; no workflows configured.
- **HotReload**: `system.json` flags hotReload for css, scss, hbs, json extensions. - **HotReload**: `system.json` flags hotReload for css, scss, hbs, json extensions.
## Constraints and remarks
Foundryv14 only : foundryvtt.com/api/
## Tests
With chrome-devtools on port 9222
World is called "Vermine", thru https://localhost:31000, with Gamemaster user logged in (no password needed)
Always try to re-use existing chrome-devtools session.
+4 -3
View File
@@ -119,9 +119,10 @@ export default class CombatDialog extends HandlebarsApplicationMixin(foundry.app
const creatureLocked = !isCreature && creatureTargets.length && creatureTargets.length === targets.length; const creatureLocked = !isCreature && creatureTargets.length && creatureTargets.length === targets.length;
if (creatureLocked) { if (creatureLocked) {
const ref = creatureTargets[0]; const ref = creatureTargets[0];
const creature = ref.id ? game.actors.get(ref.id) : null; // uuid d'abord : un token non lié ne doit pas résoudre vers l'acteur
const actor = creature ?? (ref.uuid ? await fromUuid(ref.uuid) : null); // monde dont il est issu (même principe que VermineExchange).
const d = parseInt(actor?.system?.combatStatus?.difficulty, 10) || 7; const creature = await VermineExchange.resolveDefender({ id: ref.id, uuid: ref.uuid });
const d = parseInt(creature?.system?.combatStatus?.difficulty, 10) || 7;
lockedDifficulty = d; lockedDifficulty = d;
difficultyOptions = [{ difficulty: d, label: "", locked: true }]; difficultyOptions = [{ difficulty: d, label: "", locked: true }];
defaultDifficulty = d; defaultDifficulty = d;
+52 -23
View File
@@ -258,17 +258,19 @@ export class VermineExchange {
* Résout l'échange pour une cible donnée (défense la plus récente liée * Résout l'échange pour une cible donnée (défense la plus récente liée
* à cette cible, ou abstention si aucune défense). * à cette cible, ou abstention si aucune défense).
* @param {ChatMessage} attackMessage * @param {ChatMessage} attackMessage
* @param {string|null} targetId id d'acteur de la cible (défaut : première cible) * @param {Object|string|null} targetRef cible de l'attaque (ou son uuid),
* défaut : première cible — le matching se fait par uuid d'abord car des
* PNJ mineurs (tokens non liés) issus de la même fiche partagent le même id
* @returns {Promise<Object|null>} résultat de la résolution * @returns {Promise<Object|null>} résultat de la résolution
*/ */
static async resolve(attackMessage, targetId = null) { static async resolve(attackMessage, targetRef = null) {
const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE) const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
if (!exchange) return null if (!exchange) return null
const target = (targetId && exchange.targets?.find(t => t.id === targetId)) || exchange.targets?.[0] || null const target = this.#matchTarget(exchange.targets || [], targetRef)
if (!target) return null if (!target) return null
const defs = this.#defenseMessagesFor(exchange.id, target.id) const defs = this.#defenseMessagesFor(exchange.id, target)
const defenderMessage = defs[defs.length - 1] const defenderMessage = defs[defs.length - 1]
const defense = defenderMessage ? defenderMessage.getFlag('world', FLAG_DEFENSE) : null const defense = defenderMessage ? defenderMessage.getFlag('world', FLAG_DEFENSE) : null
@@ -281,7 +283,7 @@ export class VermineExchange {
const defenderRef = defense const defenderRef = defense
? { id: defense.actorId ?? null, uuid: defense.actorUuid ?? null, name: defense.actorName ?? '' } ? { id: defense.actorId ?? null, uuid: defense.actorUuid ?? null, name: defense.actorName ?? '' }
: target : target
const defender = await this.#resolveDefender(defenderRef) const defender = await this.resolveDefender(defenderRef)
const damage = (exchange.baseDamage || 0) + attackerSuccesses const damage = (exchange.baseDamage || 0) + attackerSuccesses
const parryLevel = defense?.type === 'parade' ? (defense.parryLevel ?? 0) : null const parryLevel = defense?.type === 'parade' ? (defense.parryLevel ?? 0) : null
@@ -384,7 +386,7 @@ export class VermineExchange {
const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE) const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
if (!exchange) return if (!exchange) return
const type = btn.dataset.type const type = btn.dataset.type
const actor = await this.#resolveDefender({ id: btn.dataset.targetId, uuid: btn.dataset.targetUuid }) const actor = await this.resolveDefender({ id: btn.dataset.targetId, uuid: btn.dataset.targetUuid })
if (!actor) { if (!actor) {
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_actor_selected')) ui.notifications.warn(game.i18n.localize('VERMINE.error_no_actor_selected'))
return return
@@ -474,7 +476,7 @@ export class VermineExchange {
const targets = exchange.targets?.length ? exchange.targets : [null] const targets = exchange.targets?.length ? exchange.targets : [null]
for (const target of targets) { for (const target of targets) {
const result = await this.resolve(attackMessage, target?.id ?? null) const result = await this.resolve(attackMessage, target)
if (!result) continue if (!result) continue
const block = document.createElement('div') const block = document.createElement('div')
block.innerHTML = this.renderResolution(result) block.innerHTML = this.renderResolution(result)
@@ -491,7 +493,7 @@ export class VermineExchange {
ev.preventDefault() ev.preventDefault()
ev.stopPropagation() ev.stopPropagation()
const btn = ev.currentTarget const btn = ev.currentTarget
const actor = await this.#resolveDefender({ id: btn.dataset.targetId, uuid: btn.dataset.targetUuid }) const actor = await this.resolveDefender({ id: btn.dataset.targetId, uuid: btn.dataset.targetUuid })
if (!actor) { if (!actor) {
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_actor_selected')) ui.notifications.warn(game.i18n.localize('VERMINE.error_no_actor_selected'))
return return
@@ -541,9 +543,9 @@ export class VermineExchange {
} }
let applied = 0 let applied = 0
for (const target of exchange.targets) { for (const target of exchange.targets) {
const r = await this.resolve(attackMessage, target?.id ?? null) const r = await this.resolve(attackMessage, target)
if (!r?.hit || !r.woundCategory) continue if (!r?.hit || !r.woundCategory) continue
const defender = await this.#resolveDefender({ id: r.defenderId, uuid: r.defenderUuid }) const defender = await this.resolveDefender({ id: r.defenderId, uuid: r.defenderUuid })
if (!defender || !this.canApplyWound(defender)) continue if (!defender || !this.canApplyWound(defender)) continue
const result = await this.applyWound(defender, r.woundCategory) const result = await this.applyWound(defender, r.woundCategory)
if (result && result !== false) applied += 1 if (result && result !== false) applied += 1
@@ -559,7 +561,7 @@ export class VermineExchange {
static async #onApply(ev, attackMessage) { static async #onApply(ev, attackMessage) {
ev.preventDefault() ev.preventDefault()
const btn = ev.currentTarget const btn = ev.currentTarget
const defender = await this.#resolveDefender({ id: btn.dataset.defenderId, uuid: btn.dataset.defenderUuid }) const defender = await this.resolveDefender({ id: btn.dataset.defenderId, uuid: btn.dataset.defenderUuid })
const wound = btn.dataset.wound const wound = btn.dataset.wound
if (!defender || !wound) return if (!defender || !wound) return
if (!this.canApplyWound(defender)) { if (!this.canApplyWound(defender)) {
@@ -588,31 +590,58 @@ export class VermineExchange {
// ── Helpers internes ──────────────────────────────────────────────── // ── Helpers internes ────────────────────────────────────────────────
/** /**
* Résout un acteur depuis son id d'acteur monde (acteurs liés) ou son * Résout l'acteur d'une cible depuis sa référence (id d'acteur monde ou
* uuid (tokens non liés via fromUuid). * uuid). Le uuid est privilégié : pour un token non lié, `token.actor.id`
* expose encore l'id de l'acteur monde dont le token est issu, et résoudre
* par id blesserait alors tous les tokens liés à cette fiche (PNJ mineurs).
* Le uuid d'un acteur synthétique ("Scene.x.Token.y.Actor.z") n'est pas
* résolvable de façon fiable par fromUuid (document non indexé quand la
* résolution est asynchrone) : on extrait donc directement le token.
* @param {{id: string|null, uuid: string|null}} ref * @param {{id: string|null, uuid: string|null}} ref
* @returns {Promise<Actor|null>} * @returns {Promise<Actor|null>}
*/ */
static async #resolveDefender({ id = null, uuid = null }) { static async resolveDefender({ id = null, uuid = null }) {
if (uuid) {
const parts = uuid.split('.')
if (parts[0] === 'Scene' && parts[2] === 'Token') {
const token = game.scenes.get(parts[1])?.tokens.get(parts[3])
return token?.actor ?? null
}
if (parts[0] === 'Actor') {
return game.actors.get(parts[1]) ?? null
}
}
if (id) { if (id) {
const actor = game.actors.get(id) const actor = game.actors.get(id)
if (actor) return actor if (actor) return actor
} }
if (uuid) {
const doc = await fromUuid(uuid)
if (!doc) return null
if (doc.documentType === 'Actor') return doc
if (doc.documentType === 'Token') return doc.actor ?? null
}
return null return null
} }
static #defenseMessagesFor(exchangeId, targetId = null) { /**
* Retrouve la cible d'un échange par uuid d'abord, puis par id. Le uuid est
* fiable pour distinguer des PNJ mineurs (tokens non liés) issus de la même
* fiche, qui partagent le même id d'acteur monde.
* @param {Object[]} targets cibles du flag d'échange
* @param {Object|string|null} ref référence de la cible (ou son uuid)
* @returns {Object|null}
*/
static #matchTarget(targets, ref) {
if (!ref) return targets[0] ?? null
const uuid = typeof ref === 'string' ? ref : (ref.uuid ?? null)
const id = typeof ref === 'string' ? null : (ref.id ?? null)
if (uuid) return targets.find(t => t.uuid === uuid) ?? null
if (id) return targets.find(t => t.id === id) ?? null
return targets[0] ?? null
}
static #defenseMessagesFor(exchangeId, target = null) {
return game.messages.contents.filter(m => { return game.messages.contents.filter(m => {
const f = m.getFlag('world', FLAG_DEFENSE) const f = m.getFlag('world', FLAG_DEFENSE)
if (!f || f.exchangeId !== exchangeId) return false if (!f || f.exchangeId !== exchangeId) return false
if (targetId) return f.actorId === targetId if (!target) return true
return true if (target.uuid && f.actorUuid === target.uuid) return true
return f.actorId === target.id
}) })
} }