diff --git a/AGENTS.md b/AGENTS.md index 5c183c7..7addc1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/module/system/dialogs/combatDialog.mjs b/module/system/dialogs/combatDialog.mjs index f9ffd93..57fce3a 100644 --- a/module/system/dialogs/combatDialog.mjs +++ b/module/system/dialogs/combatDialog.mjs @@ -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; diff --git a/module/system/exchange.mjs b/module/system/exchange.mjs index 328454a..044f17e 100644 --- a/module/system/exchange.mjs +++ b/module/system/exchange.mjs @@ -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} 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} */ - 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 }) }