From e0fca22b86ebd41086ba726e489132ce0a323243 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Mon, 16 Dec 2024 11:27:14 -0800 Subject: [PATCH] Adds a new "Resist" button beneath saving throws that have been failed by an NPC so long as that NPC has remaining legendary resistances. If a save has been resisted, it is displayed as a successful roll and an indicator is displayed beneath. Closes #4863 --- lang/en.json | 47 +++++++++++------- less/v2/chat.less | 4 ++ .../activity/activity-usage-dialog.mjs | 2 +- module/applications/actor/npc-sheet-2.mjs | 6 +-- module/data/actor/npc.mjs | 28 ++++++++--- module/documents/activity/mixin.mjs | 2 +- module/documents/chat-message.mjs | 48 ++++++++++++++++++- templates/actors/npc-sheet-2.hbs | 8 ++-- templates/actors/npc-sheet.hbs | 8 ++-- 9 files changed, 116 insertions(+), 37 deletions(-) diff --git a/lang/en.json b/lang/en.json index 1512f7d36d..c4605451d5 100644 --- a/lang/en.json +++ b/lang/en.json @@ -2358,21 +2358,35 @@ "DND5E.LanguagesTerran": "Terran", "DND5E.LanguagesThievesCant": "Thieves' Cant", "DND5E.LanguagesUndercommon": "Undercommon", -"DND5E.LegAct": "Legendary Actions", -"DND5E.LegActN.one": "1st Legendary Action", -"DND5E.LegActN.two": "2nd Legendary Action", -"DND5E.LegActN.few": "3rd Legendary Action", -"DND5E.LegActN.other": "{n}th Legendary Action", -"DND5E.LegActMax": "Maximum Legendary Actions", -"DND5E.LegActRemaining": "Remaining Legendary Actions", -"DND5E.LegendaryActionLabel": "Legendary Action", -"DND5E.LegRes": "Legendary Resistance", -"DND5E.LegResN.one": "1st Legendary Resistance", -"DND5E.LegResN.two": "2nd Legendary Resistance", -"DND5E.LegResN.few": "3rd Legendary Resistance", -"DND5E.LegResN.other": "{n}th Legendary Resistance", -"DND5E.LegResMax": "Maximum Legendary Resistances", -"DND5E.LegResRemaining": "Remaining Legendary Resistances", + +"DND5E.LegendaryAction": { + "Label": "Legendary Action", + "Max": "Maximum Legendary Actions", + "Ordinal": { + "one": "{n}st Legendary Action", + "two": "{n}nd Legendary Action", + "few": "{n}rd Legendary Action", + "other": "{n}th Legendary Action" + }, + "Remaining": "Remaining Legendary Actions" +}, + +"DND5E.LegendaryResistance": { + "Action": { + "Resist": "Resist" + }, + "Label": "Legendary Resistance", + "Max": "Maximum Legendary Resistances", + "Ordinal": { + "one": "{n}st Legendary Resistance", + "two": "{n}nd Legendary Resistance", + "few": "{n}rd Legendary Resistance", + "other": "{n}th Legendary Resistance" + }, + "Remaining": "Remaining Legendary Resistances", + "Resisted": "Resisted" +}, + "DND5E.Level": "Level", "DND5E.LevelPl": "Levels", "DND5E.LevelActionDecrease": "Level Down", @@ -2605,7 +2619,8 @@ "Maximum": "Maximum Roll", "Minimum": "Minimum Roll" }, - "Section": "{label} Rolls" + "Section": "{label} Rolls", + "Status": "Status" }, "DND5E.Roll": "Roll", diff --git a/less/v2/chat.less b/less/v2/chat.less index af78c12e30..69271bdb59 100644 --- a/less/v2/chat.less +++ b/less/v2/chat.less @@ -446,6 +446,10 @@ } } +.dice-roll + .chat-card { + margin-block-start: .375rem; +} + .chat-sidebar:not([data-gm-user]) .card-header[data-concealed] { .summary { cursor: inherit; } .fa-chevron-down, .details { display: none; } diff --git a/module/applications/activity/activity-usage-dialog.mjs b/module/applications/activity/activity-usage-dialog.mjs index bffa9ec050..ea8b0326f6 100644 --- a/module/applications/activity/activity-usage-dialog.mjs +++ b/module/applications/activity/activity-usage-dialog.mjs @@ -249,7 +249,7 @@ export default class ActivityUsageDialog extends Dialog5e { context.fields.push({ field: new BooleanField({ label: game.i18n.format("DND5E.CONSUMPTION.Type.Action.Prompt", { - type: game.i18n.localize("DND5E.LegAct") + type: game.i18n.localize("DND5E.LegendaryAction.Label") }), hint: game.i18n.format("DND5E.CONSUMPTION.Type.Action.PromptHint", { available: game.i18n.format( diff --git a/module/applications/actor/npc-sheet-2.mjs b/module/applications/actor/npc-sheet-2.mjs index 4144ad5b3d..b92fa6f28d 100644 --- a/module/applications/actor/npc-sheet-2.mjs +++ b/module/applications/actor/npc-sheet-2.mjs @@ -118,14 +118,14 @@ export default class ActorSheet5eNPC2 extends ActorSheetV2Mixin(ActorSheet5eNPC) ["legact", "legres"].forEach(res => { const { max, value } = resources[res]; context[res] = Array.fromRange(max, 1).map(n => { - const i18n = res === "legact" ? "LegAct" : "LegRes"; + const i18n = res === "legact" ? "LegendaryAction" : "LegendaryResistance"; const filled = value >= n; const classes = ["pip"]; if ( filled ) classes.push("filled"); return { n, filled, - tooltip: `DND5E.${i18n}`, - label: game.i18n.format(`DND5E.${i18n}N.${plurals.select(n)}`, { n }), + tooltip: `DND5E.${i18n}.Label`, + label: game.i18n.format(`DND5E.${i18n}.Ordinal.${plurals.select(n)}`, { n }), classes: classes.join(" ") }; }); diff --git a/module/data/actor/npc.mjs b/module/data/actor/npc.mjs index 901d621312..24cd45890d 100644 --- a/module/data/actor/npc.mjs +++ b/module/data/actor/npc.mjs @@ -127,20 +127,22 @@ export default class NPCData extends CreatureTemplate { resources: new SchemaField({ legact: new SchemaField({ value: new NumberField({ - required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegActRemaining" + required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegendaryAction.Remaining" }), max: new NumberField({ - required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegActMax" + required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegendaryAction.Max" }) - }, {label: "DND5E.LegAct"}), + }, {label: "DND5E.LegendaryAction.Label"}), legres: new SchemaField({ value: new NumberField({ - required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegResRemaining" + required: true, nullable: false, integer: true, min: 0, initial: 0, + label: "DND5E.LegendaryResistance.Remaining" }), max: new NumberField({ - required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegResMax" + required: true, nullable: false, integer: true, min: 0, initial: 0, + label: "DND5E.LegendaryResistance.Max" }) - }, {label: "DND5E.LegRes"}), + }, {label: "DND5E.LegendaryResistance.Label"}), lair: new SchemaField({ value: new BooleanField({required: true, label: "DND5E.LairAct"}), initiative: new NumberField({ @@ -417,6 +419,20 @@ export default class NPCData extends CreatureTemplate { /* -------------------------------------------- */ + /** + * Spent a legendary resistance to change a failed saving throw into a success. + * @param {ChatMessage5e} message The chat message containing the failed save. + */ + async resistSave(message) { + if ( this.resources.legres.value === 0 ) throw new Error("No legendary resistances remaining."); + if ( message.flags.dnd5e?.roll?.type !== "save" ) throw new Error("Chat message must contain a save roll."); + if ( message.flags.dnd5e?.roll?.forceSuccess ) throw new Error("Save has already been resisted."); + await this.parent.update({ "system.resources.legres.value": this.resources.legres.value - 1 }); + await message.setFlag("dnd5e", "roll.forceSuccess", true); + } + + /* -------------------------------------------- */ + /** @override */ async toEmbed(config, options={}) { for ( const value of config.values ) { diff --git a/module/documents/activity/mixin.mjs b/module/documents/activity/mixin.mjs index 6f9795494f..14aa6e4e78 100644 --- a/module/documents/activity/mixin.mjs +++ b/module/documents/activity/mixin.mjs @@ -741,7 +741,7 @@ export default function ActivityMixin(Base) { else if ( count > legendary.value ) message = "DND5E.ACTIVATION.Warning.NotEnoughActions"; if ( message ) { const err = new ConsumptionError(game.i18n.format(message, { - type: game.i18n.localize("DND5E.LegAct"), + type: game.i18n.localize("DND5E.LegendaryAction.Label"), required: formatNumber(count), available: formatNumber(legendary.value) })); diff --git a/module/documents/chat-message.mjs b/module/documents/chat-message.mjs index 7bacd06af3..365a98f481 100644 --- a/module/documents/chat-message.mjs +++ b/module/documents/chat-message.mjs @@ -187,6 +187,7 @@ export default class ChatMessage5e extends ChatMessage { const originatingMessage = this.getOriginatingMessage(); const displayChallenge = originatingMessage?.shouldDisplayChallenge; const displayAttackResult = game.user.isGM || (game.settings.get("dnd5e", "attackRollVisibility") !== "none"); + const forceSuccess = this.flags.dnd5e?.roll?.forceSuccess === true; /** * Create an icon to indicate success or failure. @@ -220,11 +221,11 @@ export default class ChatMessage5e extends ChatMessage { const isAttack = this.getFlag("dnd5e", "roll.type") === "attack"; const showResult = isAttack ? displayAttackResult : displayChallenge; if ( d.options.target && showResult ) { - if ( d20Roll.total >= d.options.target ) total.classList.add("success"); + if ( d20Roll.isSuccess || forceSuccess ) total.classList.add("success"); else total.classList.add("failure"); } if ( canCrit && d20Roll.isCritical ) total.classList.add("critical"); - if ( canCrit && d20Roll.isFumble ) total.classList.add("fumble"); + if ( canCrit && d20Roll.isFumble && !forceSuccess ) total.classList.add("fumble"); const icons = document.createElement("div"); icons.classList.add("icons"); @@ -342,6 +343,7 @@ export default class ChatMessage5e extends ChatMessage { if ( !(roll instanceof DamageRoll) && this.rolls[i] ) this._enrichRollTooltip(this.rolls[i], el); }); this._enrichDamageTooltip(this.rolls.filter(r => r instanceof DamageRoll), html); + this._enrichSaveTooltip(html); this._enrichEnchantmentTooltip(html); html.querySelectorAll(".dice-roll").forEach(el => el.addEventListener("click", this._onClickDiceRoll.bind(this))); } else { @@ -595,6 +597,48 @@ export default class ChatMessage5e extends ChatMessage { /* -------------------------------------------- */ + /** + * Display option to resist a failed save using a legendary resistance. + * @param {HTMLLIElement} html The chat card. + * @protected + */ + _enrichSaveTooltip(html) { + const actor = this.getAssociatedActor(); + const roll = this.getFlag("dnd5e", "roll"); + if ( (actor?.type !== "npc") || (roll?.type !== "save") || this.rolls.some(r => r.isSuccess) ) return; + + const content = document.createElement("div"); + content.classList.add("dnd5e2", "chat-card"); + + // If message has the `forceSuccess` flag, mark it as resisted + if ( roll.forceSuccess ) content.insertAdjacentHTML("beforeend", ` +

+ ${game.i18n.localize("DND5E.ROLL.Status")} + ${game.i18n.localize("DND5E.LegendaryResistance.Resisted")} +

+ `); + + // Otherwise if actor has legendary resistances remaining, display resist button + else if ( (actor.system.resources.legres.value > 0) && actor.isOwner ) { + content.insertAdjacentHTML("beforeend", ` +
+ +
+ `); + const button = content.querySelector("button"); + button.addEventListener("click", () => actor.system.resistSave(this)); + } + + else return; + + html.querySelector(".message-content").append(content); + } + + /* -------------------------------------------- */ + /** * Display the effects tray with effects the user can apply. * @param {HTMLLiElement} html The chat card. diff --git a/templates/actors/npc-sheet-2.hbs b/templates/actors/npc-sheet-2.hbs index de9fdf1e0f..30ca0bd7f1 100644 --- a/templates/actors/npc-sheet-2.hbs +++ b/templates/actors/npc-sheet-2.hbs @@ -315,11 +315,11 @@
{{#if editable}}