diff --git a/lethal-fantasy.mjs b/lethal-fantasy.mjs index 6c0268b..1d4a145 100644 --- a/lethal-fantasy.mjs +++ b/lethal-fantasy.mjs @@ -526,281 +526,294 @@ Hooks.on("createChatMessage", async (message) => { }) } - // Si le défenseur est un personnage qui perd, proposer Grit/Luck (seulement s'il a des points) - // Seulement si l'utilisateur actuel est le propriétaire du défenseur + // Reaction phase — both sides may use grit/luck/shield/mulligan before the outcome is resolved. + // After a mulligan reroll (either side), the comparison restarts so both sides can react to the new numbers. let defenderHandledBonus = false + let attackerHandledBonus = false let shieldReaction = null let shieldBlocked = false const isSpellOrMiracle = attackRollType === "spell-attack" || attackRollType === "miracle-attack" - if (defender && defenseRoll < attackRoll && isPrimaryController(defender) && !isSpellOrMiracle) { - const shieldData = LethalFantasyUtils.getShieldReactionData(defender) - let canRerollDefense = LethalFantasyUtils.hasD30Reroll(defenseD30message) - let canShieldReact = !!shieldData - let canAdHocShield = !shieldData - - while (defenseRoll < attackRoll) { - const currentGrit = Number(defender.system?.grit?.current) || 0 - const currentLuck = Number(defender.system?.luck?.current) || 0 - const buttons = [] - - if (currentGrit > 0) { - buttons.push({ - action: "grit", - label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, - icon: "fa-solid fa-fist-raised", - callback: () => "grit" - }) - } - - if (currentLuck > 0) { - buttons.push({ - action: "luck", - label: `Spend 1 Luck (+1D6) [${currentLuck} left]`, - icon: "fa-solid fa-clover", - callback: () => "luck" - }) - } - - buttons.push({ - action: "bonusDie", - label: "Add bonus die", - icon: "fa-solid fa-dice", - callback: () => "bonusDie" - }) - - if (canRerollDefense) { - buttons.push({ - action: "rerollDefense", - label: "Re-roll defense", - icon: "fa-solid fa-rotate-right", - callback: () => "rerollDefense" - }) - } - - if (canShieldReact) { - buttons.push({ - action: "shieldReact", - label: `Roll shield (${shieldData.label})`, - icon: "fa-solid fa-shield", - callback: () => "shieldReact" - }) - } else if (canAdHocShield) { - // No pre-configured shield — offer ad-hoc shield option (useful for monsters) - buttons.push({ - action: "adHocShield", - label: "Roll ad-hoc shield (choose dice + DR)", - icon: "fa-solid fa-shield-halved", - callback: () => "adHocShield" - }) - } - - buttons.push({ - action: "continue", - label: "Continue (no defense bonus)", - icon: "fa-solid fa-forward", - callback: () => "continue" - }) - - const choice = await foundry.applications.api.DialogV2.wait({ - window: { title: "Defense reactions" }, - classes: ["lethalfantasy"], - content: ` -
${attackerName} rolled ${attackRoll}
-${defenderName} currently has ${defenseRoll}
- ${defenseD30message ? `D30 special: ${defenseD30message.description}
` : ""} -Choose how to improve the defense before resolving the hit.
-${defenderName} spends 1 Grit and rolls ${total} for defense.
`) - defenseRoll += bonusRoll - await defender.update({ "system.grit.current": currentGrit - 1 }) - continue - } - - if (choice === "luck") { - const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, (total) => `${defenderName} spends 1 Luck and rolls ${total} for defense.
`) - defenseRoll += bonusRoll - await defender.update({ "system.luck.current": currentLuck - 1 }) - continue - } - - if (choice === "bonusDie") { - const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", defenseRoll, attackRoll) - if (!bonusDie) continue - const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender, (total, formula) => `${defenderName} adds ${formula.toUpperCase()} and rolls ${total} for defense.
`) - defenseRoll += bonusRoll - continue - } - - if (choice === "rerollDefense" && canRerollDefense) { - const oldDefenseRoll = defenseRoll - const reroll = await LethalFantasyUtils.rerollConfiguredRoll(defenseRerollContext) - canRerollDefense = false - if (!reroll) continue - defenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll - await createReactionMessage(defender, `${defenderName} uses Mulligan and re-rolls defense: ${oldDefenseRoll} → ${defenseRoll}.
`) - continue - } - - if (choice === "shieldReact" && canShieldReact) { - const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldData.formula, defender) - const newDefenseTotal = defenseRoll + shieldBonus - defenseRoll = newDefenseTotal - canShieldReact = false - - if (newDefenseTotal >= attackRoll) { - shieldBlocked = true - shieldReaction = { - damageReduction: shieldData.damageReduction, - label: shieldData.label, - bonus: shieldBonus - } - await createReactionMessage( - defender, - `${defenderName} rolls ${shieldData.label} and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRoll}). Shield blocked the attack! Both armor DR and shield DR ${shieldData.damageReduction} will apply to damage.
` - ) - } else { - shieldReaction = null - await createReactionMessage( - defender, - `${defenderName} rolls ${shieldData.label} and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRoll}). Shield did not block — normal hit, armor DR only.
` - ) - } - } - - if (choice === "adHocShield") { - const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRoll, defenseRoll) - if (!adHoc) continue - const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender) - const newDefenseTotal = defenseRoll + shieldBonus - defenseRoll = newDefenseTotal - canShieldReact = false - canAdHocShield = false - - if (newDefenseTotal >= attackRoll) { - shieldBlocked = true - shieldReaction = { - damageReduction: adHoc.damageReduction, - label: `${adHoc.formula.toUpperCase()} shield`, - bonus: shieldBonus - } - await createReactionMessage( - defender, - `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRoll}). Shield blocked the attack! Both armor DR and shield DR ${adHoc.damageReduction} will apply to damage.
` - ) - } else { - shieldReaction = null - await createReactionMessage( - defender, - `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRoll}). Shield did not block — normal hit, armor DR only.
` - ) - } - } - } - } + // These persist across mulligan restarts (once used they stay consumed) + const shieldData = LethalFantasyUtils.getShieldReactionData(defender) + let canRerollDefense = LethalFantasyUtils.hasD30Reroll(defenseD30message) + let canShieldReact = !!shieldData + let canAdHocShield = !shieldData let attackRollFinal = attackRoll - let attackerHandledBonus = false + let canRerollAttack = LethalFantasyUtils.hasD30Reroll(attackD30message) + let mulliganRestart = false - // Si l'attaquant est un personnage qui perd et a du Grit - // Seulement si l'utilisateur actuel est le propriétaire de l'attaquant (pas le MJ) - if (!defenderHandledBonus && attacker && attackRollFinal <= defenseRoll && isPrimaryController(attacker)) { - let canRerollAttack = LethalFantasyUtils.hasD30Reroll(attackD30message) + do { + mulliganRestart = false + defenderHandledBonus = false + attackerHandledBonus = false - while (attackRollFinal <= defenseRoll) { - const currentGrit = Number(attacker.system?.grit?.current) || 0 - const buttons = [] + // ── Defense reaction loop ────────────────────────────────────────────── + if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle) { + while (defenseRoll < attackRollFinal) { + const currentGrit = Number(defender.system?.grit?.current) || 0 + const currentLuck = Number(defender.system?.luck?.current) || 0 + const buttons = [] + + if (currentGrit > 0) { + buttons.push({ + action: "grit", + label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, + icon: "fa-solid fa-fist-raised", + callback: () => "grit" + }) + } + + if (currentLuck > 0) { + buttons.push({ + action: "luck", + label: `Spend 1 Luck (+1D6) [${currentLuck} left]`, + icon: "fa-solid fa-clover", + callback: () => "luck" + }) + } - if (currentGrit > 0) { buttons.push({ - action: "grit", - label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, - icon: "fa-solid fa-fist-raised", - callback: () => "grit" + action: "bonusDie", + label: "Add bonus die", + icon: "fa-solid fa-dice", + callback: () => "bonusDie" }) - } - buttons.push({ - action: "bonusDie", - label: "Add bonus die", - icon: "fa-solid fa-dice", - callback: () => "bonusDie" - }) + if (canRerollDefense) { + buttons.push({ + action: "rerollDefense", + label: "Re-roll defense (Mulligan)", + icon: "fa-solid fa-rotate-right", + callback: () => "rerollDefense" + }) + } + + if (canShieldReact) { + buttons.push({ + action: "shieldReact", + label: `Roll shield (${shieldData.label})`, + icon: "fa-solid fa-shield", + callback: () => "shieldReact" + }) + } else if (canAdHocShield) { + buttons.push({ + action: "adHocShield", + label: "Roll ad-hoc shield (choose dice + DR)", + icon: "fa-solid fa-shield-halved", + callback: () => "adHocShield" + }) + } - if (canRerollAttack && attackRerollContext) { buttons.push({ - action: "rerollAttack", - label: "Re-roll attack", - icon: "fa-solid fa-rotate-right", - callback: () => "rerollAttack" + action: "continue", + label: "Continue (no defense bonus)", + icon: "fa-solid fa-forward", + callback: () => "continue" }) - } - buttons.push({ - action: "continue", - label: "Continue (no attack bonus)", - icon: "fa-solid fa-forward", - callback: () => "continue" - }) - - const choice = await foundry.applications.api.DialogV2.wait({ - window: { title: "Attack reactions" }, - classes: ["lethalfantasy"], - content: ` -${attackerName} currently has ${attackRollFinal}
-${defenderName} rolled ${defenseRoll}
- ${attackD30message ? `D30 special: ${attackD30message.description}
` : ""} + const choice = await foundry.applications.api.DialogV2.wait({ + window: { title: "Defense reactions" }, + classes: ["lethalfantasy"], + content: ` +${attackerName} rolled ${attackRollFinal}
+${defenderName} currently has ${defenseRoll}
+ ${defenseD30message ? `D30 special: ${defenseD30message.description}
` : ""} +Choose how to improve the defense before resolving the hit.
Choose how to improve the attack before resolving the combat result.
-${attackerName} spends 1 Grit and rolls ${total} for attack.
`) - attackRollFinal += attackBonus - await attacker.update({ "system.grit.current": currentGrit - 1 }) - continue - } + if (choice === "grit") { + const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, total => `${defenderName} spends 1 Grit and rolls ${total} for defense.
`) + defenseRoll += bonusRoll + await defender.update({ "system.grit.current": currentGrit - 1 }) + continue + } - if (choice === "bonusDie") { - const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(attackerName, "defense", attackRollFinal, defenseRoll) - if (!bonusDie) continue - const attackBonus = await LethalFantasyUtils.rollBonusDie(bonusDie, attacker, (total, formula) => `${attackerName} adds ${formula.toUpperCase()} and rolls ${total} for attack.
`) - attackRollFinal += attackBonus - continue - } + if (choice === "luck") { + const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, total => `${defenderName} spends 1 Luck and rolls ${total} for defense.
`) + defenseRoll += bonusRoll + await defender.update({ "system.luck.current": currentLuck - 1 }) + continue + } - if (choice === "rerollAttack" && canRerollAttack && attackRerollContext) { - const oldAttackRoll = attackRollFinal - const reroll = await LethalFantasyUtils.rerollConfiguredRoll(attackRerollContext) - canRerollAttack = false - if (!reroll) continue - attackRollFinal = reroll.options?.rollTotal || reroll.total || oldAttackRoll - await createReactionMessage(attacker, `${attackerName} uses Mulligan and re-rolls attack: ${oldAttackRoll} → ${attackRollFinal}.
`) + if (choice === "bonusDie") { + const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", defenseRoll, attackRollFinal) + if (!bonusDie) continue + const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender, (total, formula) => `${defenderName} adds ${formula.toUpperCase()} and rolls ${total} for defense.
`) + defenseRoll += bonusRoll + continue + } + + if (choice === "rerollDefense" && canRerollDefense) { + const oldDefenseRoll = defenseRoll + const reroll = await LethalFantasyUtils.rerollConfiguredRoll(defenseRerollContext) + canRerollDefense = false + if (!reroll) continue + defenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll + await createReactionMessage(defender, `${defenderName} uses Mulligan and re-rolls defense: ${oldDefenseRoll} → ${defenseRoll}. Both sides may now react to the new numbers.
`) + // Restart the full comparison so both sides can react to the new roll + mulliganRestart = true + break + } + + if (choice === "shieldReact" && canShieldReact) { + const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldData.formula, defender) + const newDefenseTotal = defenseRoll + shieldBonus + defenseRoll = newDefenseTotal + canShieldReact = false + + if (newDefenseTotal >= attackRollFinal) { + shieldBlocked = true + shieldReaction = { + damageReduction: shieldData.damageReduction, + label: shieldData.label, + bonus: shieldBonus + } + await createReactionMessage( + defender, + `${defenderName} rolls ${shieldData.label} and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRollFinal}). Shield blocked the attack! Both armor DR and shield DR ${shieldData.damageReduction} will apply to damage.
` + ) + } else { + shieldReaction = null + await createReactionMessage( + defender, + `${defenderName} rolls ${shieldData.label} and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.
` + ) + } + } + + if (choice === "adHocShield") { + const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRollFinal, defenseRoll) + if (!adHoc) continue + const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender) + const newDefenseTotal = defenseRoll + shieldBonus + defenseRoll = newDefenseTotal + canShieldReact = false + canAdHocShield = false + + if (newDefenseTotal >= attackRollFinal) { + shieldBlocked = true + shieldReaction = { + damageReduction: adHoc.damageReduction, + label: `${adHoc.formula.toUpperCase()} shield`, + bonus: shieldBonus + } + await createReactionMessage( + defender, + `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRollFinal}). Shield blocked the attack! Both armor DR and shield DR ${adHoc.damageReduction} will apply to damage.
` + ) + } else { + shieldReaction = null + await createReactionMessage( + defender, + `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.
` + ) + } + } } } - } + + if (mulliganRestart) continue + + // ── Attack reaction loop ─────────────────────────────────────────────── + if (!defenderHandledBonus && attacker && attackRollFinal <= defenseRoll && isPrimaryController(attacker)) { + while (attackRollFinal <= defenseRoll) { + const currentGrit = Number(attacker.system?.grit?.current) || 0 + const buttons = [] + + if (currentGrit > 0) { + buttons.push({ + action: "grit", + label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, + icon: "fa-solid fa-fist-raised", + callback: () => "grit" + }) + } + + buttons.push({ + action: "bonusDie", + label: "Add bonus die", + icon: "fa-solid fa-dice", + callback: () => "bonusDie" + }) + + if (canRerollAttack && attackRerollContext) { + buttons.push({ + action: "rerollAttack", + label: "Re-roll attack (Mulligan)", + icon: "fa-solid fa-rotate-right", + callback: () => "rerollAttack" + }) + } + + buttons.push({ + action: "continue", + label: "Continue (no attack bonus)", + icon: "fa-solid fa-forward", + callback: () => "continue" + }) + + const choice = await foundry.applications.api.DialogV2.wait({ + window: { title: "Attack reactions" }, + classes: ["lethalfantasy"], + content: ` +${attackerName} currently has ${attackRollFinal}
+${defenderName} rolled ${defenseRoll}
+ ${attackD30message ? `D30 special: ${attackD30message.description}
` : ""} +Choose how to improve the attack before resolving the combat result.
+${attackerName} spends 1 Grit and rolls ${total} for attack.
`) + attackRollFinal += attackBonus + await attacker.update({ "system.grit.current": currentGrit - 1 }) + continue + } + + if (choice === "bonusDie") { + const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(attackerName, "defense", attackRollFinal, defenseRoll) + if (!bonusDie) continue + const attackBonus = await LethalFantasyUtils.rollBonusDie(bonusDie, attacker, (total, formula) => `${attackerName} adds ${formula.toUpperCase()} and rolls ${total} for attack.
`) + attackRollFinal += attackBonus + continue + } + + if (choice === "rerollAttack" && canRerollAttack && attackRerollContext) { + const oldAttackRoll = attackRollFinal + const reroll = await LethalFantasyUtils.rerollConfiguredRoll(attackRerollContext) + canRerollAttack = false + if (!reroll) continue + attackRollFinal = reroll.options?.rollTotal || reroll.total || oldAttackRoll + await createReactionMessage(attacker, `${attackerName} uses Mulligan and re-rolls attack: ${oldAttackRoll} → ${attackRollFinal}. Both sides may now react to the new numbers.
`) + // Restart the full comparison so both sides can react to the new roll + mulliganRestart = true + break + } + } + } + } while (mulliganRestart) const shieldDamageReduction = shieldBlocked ? shieldReaction.damageReduction : 0 const outcome = shieldBlocked ? "shielded-hit" : (attackRollFinal > defenseRoll ? "hit" : "miss") diff --git a/module/config/d30_results_tables.json b/module/config/d30_results_tables.json index 9434d40..afc93ca 100644 --- a/module/config/d30_results_tables.json +++ b/module/config/d30_results_tables.json @@ -108,6 +108,10 @@ } ], "description": "Possible Flawless or Legendary Defense or Add D20E to Defense" + }, + "saving_throws": { + "type": "save_auto_success", + "description": "Saving Throw Succeeds Regardless of Opposing Roll" } }, "29": { @@ -145,6 +149,11 @@ "type": "gain_grit", "amount": 1, "description": "Gain 1 Grit" + }, + "saving_throws": { + "type": "gain_grit", + "amount": 1, + "description": "Gain 1 Grit" } }, "28": { @@ -199,6 +208,12 @@ "amount": 1, "target": "skill", "description": "Add 1 to Skill Roll" + }, + "saving_throws": { + "type": "bonus_flat", + "amount": 1, + "target": "save", + "description": "Add 1 to Saving Throw" } }, "21": { @@ -239,6 +254,12 @@ "ranged_defense": { "type": "recover_pain", "description": "Defender Recovers or ignores any flash of pain" + }, + "saving_throws": { + "type": "bonus_dice", + "dice": "D6", + "target": "save", + "description": "Granted D6 (1-6) Saving Throw Modifier for this Saving Throw Attempt" } }, "20": { @@ -349,6 +370,12 @@ } ], "description": "Possible 20/20 defense that avoids Any Attack Except a Lethal Strike or adds D12 to defense" + }, + "saving_throws": { + "type": "bonus_flat", + "amount": 20, + "target": "save", + "description": "20 Added to Saving Throw" } }, "15": { @@ -391,6 +418,12 @@ "shield_bash" ], "description": "Kick, Punch or Shield Bash" + }, + "saving_throws": { + "type": "bonus_flat", + "amount": 1, + "target": "save", + "description": "Add 1 to Saving Throw" } }, "13": {}, @@ -445,6 +478,12 @@ "shield_bash" ], "description": "Kick, Punch or Shield Bash" + }, + "saving_throws": { + "type": "bonus_flat", + "amount": 1, + "target": "save", + "description": "Add 1 to Saving Throw" } }, "8": { @@ -475,6 +514,10 @@ "ranged_defense": { "type": "mulligan", "description": "Mulligan, Can Choose to Re-Roll This Defense" + }, + "saving_throws": { + "type": "mulligan", + "description": "Mulligan, Can Re-Roll This Saving Throw" } }, "7": { @@ -528,6 +571,12 @@ "shield_bash" ], "description": "Kick, Punch, or Shield Bash" + }, + "saving_throws": { + "type": "bonus_flat", + "amount": 1, + "target": "save", + "description": "Add 1 to Saving Throw" } }, "3": { diff --git a/module/documents/d30-roll.mjs b/module/documents/d30-roll.mjs index 501dcf0..66dff5a 100644 --- a/module/documents/d30-roll.mjs +++ b/module/documents/d30-roll.mjs @@ -25,7 +25,8 @@ export default class D30Roll { RANGED_DEFENSE: "ranged_defense", ARCANE_SPELL_ATTACK: "arcane_spell_attack", ARCANE_SPELL_DEFENSE: "arcane_spell_defense", - SKILL_ROLLS: "skill_rolls" + SKILL_ROLLS: "skill_rolls", + SAVING_THROWS: "saving_throws" } /** @@ -137,11 +138,15 @@ export default class D30Roll { } // Skill types - if (externalType === "skill" || externalType === "monster-skill" || - externalType === "save" || externalType === "challenge") { + if (externalType === "skill" || externalType === "monster-skill" || externalType === "challenge") { return this.ROLL_TYPES.SKILL_ROLLS } + // Saving throw types + if (externalType === "save") { + return options.isSpellSave ? this.ROLL_TYPES.ARCANE_SPELL_DEFENSE : this.ROLL_TYPES.SAVING_THROWS + } + // If no match, return null console.warn(`D30Roll | Unknown external roll type: ${externalType}`) return null diff --git a/module/documents/roll.mjs b/module/documents/roll.mjs index b2480e9..0d84bf4 100644 --- a/module/documents/roll.mjs +++ b/module/documents/roll.mjs @@ -609,7 +609,7 @@ export default class LethalFantasyRoll extends Roll { rollD30.total, options.rollType, options.rollTarget?.weapon, - { isRanged: isRangedForD30 } + { isRanged: isRangedForD30, isSpellSave: saveSpell } ) options.D30message = d30Message } @@ -787,9 +787,18 @@ export default class LethalFantasyRoll extends Roll { let buttons = [] if (currentAction) { if (currentAction.type === "weapon") { + let weaponLabel = "Roll progression dice" + if (currentAction.rangedMode) { + // Compute loading count from the speed formula (e.g. "3+1d6" → load=3) + const speedStr = currentAction.system?.speed?.[currentAction.rangedMode] ?? "" + const rangedLoad = currentAction.rangedLoad ?? (Number(speedStr.split("+")[0]) || 0) + if (rangedLoad > 0 && !currentAction.weaponLoaded) { + weaponLabel = "Load weapon" + } + } buttons.push({ action: "roll", - label: "Roll progression dice", + label: weaponLabel, callback: (event, button) => { let pos = $('#combat-action-dialog').position() game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", pos) @@ -908,14 +917,15 @@ export default class LethalFantasyRoll extends Roll { if (rollContext === "rollLethargyDice") { if (currentAction.spellStatus === "castingTime") { let time = currentAction.type === "spell" ? currentAction.system.castingTime : currentAction.system.prayerTime - if (currentAction.castingTime <= time) { + if (currentAction.castingTime < time) { let message = `Casting time : ${currentAction.name}, count : ${currentAction.castingTime}/${time}` ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) }) currentAction.castingTime += 1 await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction)) return } else { - let message = `Spell/Miracle ${currentAction.name} ready to be cast on next second !` + // Last counting second — announce ready and transition immediately (no extra second consumed) + let message = `Casting time : ${currentAction.name}, count : ${time}/${time} — ready to cast next second !` ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) }) currentAction.castingTime = 1 currentAction.spellStatus = "toBeCasted"