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 CI**: `.github/` is gitignored; no workflows configured.
- **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;
if (creatureLocked) {
const ref = creatureTargets[0];
const creature = ref.id ? game.actors.get(ref.id) : null;
const actor = creature ?? (ref.uuid ? await fromUuid(ref.uuid) : null);
const d = parseInt(actor?.system?.combatStatus?.difficulty, 10) || 7;
// uuid d'abord : un token non lié ne doit pas résoudre vers l'acteur
// monde dont il est issu (même principe que VermineExchange).
const creature = await VermineExchange.resolveDefender({ id: ref.id, uuid: ref.uuid });
const d = parseInt(creature?.system?.combatStatus?.difficulty, 10) || 7;
lockedDifficulty = d;
difficultyOptions = [{ difficulty: d, label: "", locked: true }];
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
* à cette cible, ou abstention si aucune défense).
* @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
*/
static async resolve(attackMessage, targetId = null) {
static async resolve(attackMessage, targetRef = null) {
const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
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
const defs = this.#defenseMessagesFor(exchange.id, target.id)
const defs = this.#defenseMessagesFor(exchange.id, target)
const defenderMessage = defs[defs.length - 1]
const defense = defenderMessage ? defenderMessage.getFlag('world', FLAG_DEFENSE) : null
@@ -281,7 +283,7 @@ export class VermineExchange {
const defenderRef = defense
? { id: defense.actorId ?? null, uuid: defense.actorUuid ?? null, name: defense.actorName ?? '' }
: target
const defender = await this.#resolveDefender(defenderRef)
const defender = await this.resolveDefender(defenderRef)
const damage = (exchange.baseDamage || 0) + attackerSuccesses
const parryLevel = defense?.type === 'parade' ? (defense.parryLevel ?? 0) : null
@@ -384,7 +386,7 @@ export class VermineExchange {
const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
if (!exchange) return
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) {
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_actor_selected'))
return
@@ -474,7 +476,7 @@ export class VermineExchange {
const targets = exchange.targets?.length ? exchange.targets : [null]
for (const target of targets) {
const result = await this.resolve(attackMessage, target?.id ?? null)
const result = await this.resolve(attackMessage, target)
if (!result) continue
const block = document.createElement('div')
block.innerHTML = this.renderResolution(result)
@@ -491,7 +493,7 @@ export class VermineExchange {
ev.preventDefault()
ev.stopPropagation()
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) {
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_actor_selected'))
return
@@ -541,9 +543,9 @@ export class VermineExchange {
}
let applied = 0
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
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
const result = await this.applyWound(defender, r.woundCategory)
if (result && result !== false) applied += 1
@@ -559,7 +561,7 @@ export class VermineExchange {
static async #onApply(ev, attackMessage) {
ev.preventDefault()
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
if (!defender || !wound) return
if (!this.canApplyWound(defender)) {
@@ -588,31 +590,58 @@ export class VermineExchange {
// ── Helpers internes ────────────────────────────────────────────────
/**
* Résout un acteur depuis son id d'acteur monde (acteurs liés) ou son
* uuid (tokens non liés via fromUuid).
* Résout l'acteur d'une cible depuis sa référence (id d'acteur monde ou
* 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
* @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) {
const actor = game.actors.get(id)
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
}
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 => {
const f = m.getFlag('world', FLAG_DEFENSE)
if (!f || f.exchangeId !== exchangeId) return false
if (targetId) return f.actorId === targetId
return true
if (!target) return true
if (target.uuid && f.actorUuid === target.uuid) return true
return f.actorId === target.id
})
}