diff --git a/module/data/actor/npc.mjs b/module/data/actor/npc.mjs index 8180c6a..d0bdba6 100644 --- a/module/data/actor/npc.mjs +++ b/module/data/actor/npc.mjs @@ -83,7 +83,7 @@ export default class NPCData extends VagabondActorBase { nullable: true, blank: false, initial: null, - choices: ["first-death", "half-hp", "half-incapacitated", "leader-death"], + choices: ["first-death", "half-hp", "half-incapacitated", "leader-death", "manual"], }), // Result of the last morale check lastResult: new fields.StringField({ diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs index 0a3d094..8abe1bb 100644 --- a/module/documents/actor.mjs +++ b/module/documents/actor.mjs @@ -520,6 +520,211 @@ export default class VagabondActor extends Actor { return this.system.shouldCheckMorale?.() || false; } + /* -------------------------------------------- */ + /* Morale System (NPC) */ + /* -------------------------------------------- */ + + /** + * Roll a morale check for this NPC. + * Morale fails if 2d6 > Morale score. + * + * @param {Object} options - Roll options + * @param {string} [options.trigger] - What triggered this check (first-death, half-hp, etc.) + * @param {boolean} [options.skipMessage=false] - If true, don't post chat message + * @returns {Promise} Result with roll, passed, and morale data + */ + async rollMorale({ trigger = null, skipMessage = false } = {}) { + if (this.type !== "npc") { + ui.notifications.warn("Morale checks are only for NPCs"); + return null; + } + + const morale = this.system.morale; + + // Roll 2d6 + const roll = await new Roll("2d6").evaluate(); + + // Morale fails if roll > morale score + const passed = roll.total <= morale; + + // Update morale status + const updates = { + "system.moraleStatus.checkedThisCombat": true, + "system.moraleStatus.lastTrigger": trigger, + "system.moraleStatus.lastResult": passed ? "passed" : "failed-retreat", + }; + + // If failed, mark as broken + if (!passed) { + updates["system.moraleStatus.broken"] = true; + } + + await this.update(updates); + + // Post chat message + if (!skipMessage) { + await this._postMoraleMessage(roll, morale, passed, trigger); + } + + return { roll, passed, morale, trigger }; + } + + /** + * Post a morale check result to chat. + * + * @param {Roll} roll - The 2d6 roll + * @param {number} morale - The morale score + * @param {boolean} passed - Whether the check passed + * @param {string} trigger - What triggered the check + * @private + */ + async _postMoraleMessage(roll, morale, passed, trigger) { + const triggerLabels = { + "first-death": "first ally death", + "half-hp": "reaching half HP", + "half-incapacitated": "half of group incapacitated", + "leader-death": "leader death", + manual: "GM request", + }; + + const triggerText = triggerLabels[trigger] || trigger || "unknown trigger"; + const resultText = passed + ? `HOLDS! (rolled ${roll.total} ≤ ${morale})` + : `BREAKS! (rolled ${roll.total} > ${morale})`; + + const content = ` +
+

Morale Check

+

Trigger: ${triggerText}

+

Morale Score: ${morale}

+

Result: ${resultText}

+ ${!passed ? "

The enemy retreats or surrenders!

" : ""} +
+ `; + + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor: this }), + content, + rolls: [roll], + sound: CONFIG.sounds.dice, + }); + } + + /** + * Roll morale for multiple selected NPCs as a group. + * Uses the lowest morale score among the group. + * + * @param {Object} options - Roll options + * @param {string} [options.trigger] - What triggered this check + * @returns {Promise} Result with roll, passed, and affected actors + */ + static async rollGroupMorale({ trigger = "manual" } = {}) { + // Get selected NPC tokens + const tokens = canvas.tokens.controlled.filter((t) => t.actor?.type === "npc"); + + if (tokens.length === 0) { + ui.notifications.warn("Select one or more NPC tokens to roll morale"); + return null; + } + + // Find the lowest morale score (weakest link) + const actors = tokens.map((t) => t.actor); + const lowestMorale = Math.min(...actors.map((a) => a.system.morale)); + const groupName = + tokens.length === 1 + ? tokens[0].name + : `${tokens.length} enemies (lowest morale: ${lowestMorale})`; + + // Roll 2d6 + const roll = await new Roll("2d6").evaluate(); + const passed = roll.total <= lowestMorale; + + // Update all affected actors + for (const actor of actors) { + await actor.update({ + "system.moraleStatus.checkedThisCombat": true, + "system.moraleStatus.lastTrigger": trigger, + "system.moraleStatus.lastResult": passed ? "passed" : "failed-retreat", + "system.moraleStatus.broken": !passed, + }); + } + + // Post group result to chat + const triggerLabels = { + "first-death": "first ally death", + "half-hp": "reaching half HP", + "half-incapacitated": "half of group incapacitated", + "leader-death": "leader death", + manual: "GM request", + }; + + const triggerText = triggerLabels[trigger] || trigger || "unknown trigger"; + const resultText = passed + ? `HOLDS! (rolled ${roll.total} ≤ ${lowestMorale})` + : `BREAKS! (rolled ${roll.total} > ${lowestMorale})`; + + const actorNames = actors.map((a) => a.name).join(", "); + + const content = ` +
+

Group Morale Check

+

Group: ${actorNames}

+

Trigger: ${triggerText}

+

Morale Score: ${lowestMorale} (lowest in group)

+

Result: ${resultText}

+ ${!passed ? "

The enemies retreat or surrender!

" : ""} +
+ `; + + await ChatMessage.create({ + speaker: { alias: "Morale Check" }, + content, + rolls: [roll], + sound: CONFIG.sounds.dice, + }); + + return { roll, passed, morale: lowestMorale, actors, trigger }; + } + + /** + * Post a morale prompt to chat when a trigger condition is met. + * Includes a clickable button to roll morale. + * + * @param {string} trigger - What triggered the prompt + */ + async promptMoraleCheck(trigger) { + if (this.type !== "npc") return; + + // Don't prompt if already broken + if (this.system.moraleStatus?.broken) return; + + const triggerLabels = { + "first-death": "first ally death", + "half-hp": "reaching half HP", + "half-incapacitated": "half of group incapacitated", + "leader-death": "leader death", + }; + + const triggerText = triggerLabels[trigger] || trigger; + + const content = ` +
+

Morale Check Triggered

+

${this.name} has reached a morale trigger: ${triggerText}

+

Morale Score: ${this.system.morale}

+ +
+ `; + + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor: this }), + content, + whisper: game.users.filter((u) => u.isGM).map((u) => u.id), + }); + } + /** * Get the net favor/hinder for a specific roll type. * Checks Active Effect flags for persistent favor/hinder sources. diff --git a/module/tests/morale.test.mjs b/module/tests/morale.test.mjs new file mode 100644 index 0000000..928a6b1 --- /dev/null +++ b/module/tests/morale.test.mjs @@ -0,0 +1,294 @@ +/** + * NPC Morale System Tests + * + * Tests the morale check system for NPCs including: + * - Individual morale rolls (2d6 vs morale score) + * - Group morale rolls (using lowest morale in group) + * - Morale status tracking (broken, checkedThisCombat) + * - Morale prompt creation + * + * Morale System Rules: + * - Roll 2d6 against morale score (2-12, default 7) + * - If 2d6 > morale, the check fails and the NPC breaks + * - Triggers: first death, half HP, half group incapacitated, leader death + * + * @module tests/morale + */ + +/** + * Register morale tests with Quench + * @param {Quench} quenchRunner - The Quench test runner + */ +export function registerMoraleTests(quenchRunner) { + quenchRunner.registerBatch( + "vagabond.morale", + (context) => { + const { describe, it, expect, beforeEach, afterEach } = context; + + describe("NPC Morale Checks", () => { + let npc; + + beforeEach(async () => { + // Create a test NPC with known morale value + npc = await Actor.create({ + name: "Test Goblin", + type: "npc", + system: { + hp: { value: 10, max: 10 }, + morale: 7, + }, + }); + }); + + afterEach(async () => { + await npc?.delete(); + }); + + it("should roll 2d6 for morale check", async () => { + /** + * Unit test: Verifies rollMorale() returns a 2d6 roll result + * with the correct structure. + */ + const result = await npc.rollMorale({ skipMessage: true }); + + expect(result).to.exist; + expect(result.roll).to.exist; + expect(result.roll.total).to.be.at.least(2); + expect(result.roll.total).to.be.at.most(12); + expect(result.morale).to.equal(7); + }); + + it("should pass morale when roll <= morale score", async () => { + /** + * Unit test: Verifies morale check passes when 2d6 roll is + * less than or equal to the morale score. + * + * We test this by setting a very high morale (12) which guarantees + * a pass since max 2d6 is 12. + */ + await npc.update({ "system.morale": 12 }); + const result = await npc.rollMorale({ skipMessage: true }); + + // With morale 12, any roll of 2-12 passes + expect(result.passed).to.equal(true); + expect(npc.system.moraleStatus.broken).to.equal(false); + }); + + it("should fail morale when roll > morale score", async () => { + /** + * Unit test: Verifies morale check fails when 2d6 roll exceeds + * the morale score, marking the NPC as broken. + * + * We test this by setting morale to 1 (below minimum 2d6 of 2). + */ + await npc.update({ "system.morale": 1 }); + const result = await npc.rollMorale({ skipMessage: true }); + + // With morale 1, any roll of 2-12 fails + expect(result.passed).to.equal(false); + expect(npc.system.moraleStatus.broken).to.equal(true); + }); + + it("should track morale check status", async () => { + /** + * Unit test: Verifies rollMorale() updates the moraleStatus + * tracking fields correctly. + */ + expect(npc.system.moraleStatus.checkedThisCombat).to.equal(false); + + await npc.rollMorale({ trigger: "half-hp", skipMessage: true }); + + expect(npc.system.moraleStatus.checkedThisCombat).to.equal(true); + expect(npc.system.moraleStatus.lastTrigger).to.equal("half-hp"); + expect(npc.system.moraleStatus.lastResult).to.be.oneOf(["passed", "failed-retreat"]); + }); + + it("should record trigger in morale status", async () => { + /** + * Unit test: Verifies the trigger reason is stored in moraleStatus + * for reference and potential UI display. + */ + await npc.rollMorale({ trigger: "first-death", skipMessage: true }); + + expect(npc.system.moraleStatus.lastTrigger).to.equal("first-death"); + }); + + it("should only work for NPCs", async () => { + /** + * Unit test: Verifies rollMorale() returns null and shows + * a warning when called on non-NPC actors. + */ + const character = await Actor.create({ + name: "Test Hero", + type: "character", + system: { level: 1 }, + }); + + const result = await character.rollMorale({ skipMessage: true }); + expect(result).to.be.null; + + await character.delete(); + }); + }); + + describe("Group Morale Checks", () => { + it("should return null when no NPC tokens selected", async () => { + /** + * Unit test: Verifies rollGroupMorale() returns null and shows + * a warning when no NPC tokens are selected. + * + * Note: We deselect all tokens first to ensure a clean state. + */ + // Deselect all tokens to ensure clean state + canvas.tokens.releaseAll(); + + const VagabondActor = CONFIG.Actor.documentClass; + const result = await VagabondActor.rollGroupMorale({ trigger: "manual" }); + + // Without selected tokens, should return null with warning + expect(result).to.be.null; + }); + }); + + describe("Morale Prompts", () => { + let npc; + + beforeEach(async () => { + npc = await Actor.create({ + name: "Test Orc", + type: "npc", + system: { + hp: { value: 10, max: 10 }, + morale: 8, + }, + }); + }); + + afterEach(async () => { + await npc?.delete(); + }); + + it("should not prompt if already broken", async () => { + /** + * Unit test: Verifies promptMoraleCheck() does nothing when + * the NPC is already broken (has failed a previous morale check). + */ + await npc.update({ "system.moraleStatus.broken": true }); + + // Get initial chat message count + const initialCount = game.messages.size; + + await npc.promptMoraleCheck("half-hp"); + + // No new message should be created + expect(game.messages.size).to.equal(initialCount); + }); + + it("should not prompt for non-NPCs", async () => { + /** + * Unit test: Verifies promptMoraleCheck() does nothing when + * called on a character actor. + */ + const character = await Actor.create({ + name: "Test Hero", + type: "character", + system: { level: 1 }, + }); + + const initialCount = game.messages.size; + await character.promptMoraleCheck("half-hp"); + + // No new message should be created + expect(game.messages.size).to.equal(initialCount); + + await character.delete(); + }); + + it("should create a whispered chat message for GM", async () => { + /** + * Integration test: Verifies promptMoraleCheck() creates a + * chat message that is whispered to GMs only and contains + * a clickable button to roll morale. + */ + const initialCount = game.messages.size; + + await npc.promptMoraleCheck("half-hp"); + + // A new message should be created + expect(game.messages.size).to.equal(initialCount + 1); + + // Get the most recent message + const message = Array.from(game.messages).pop(); + + // Should be whispered (has whisper recipients) + expect(message.whisper.length).to.be.greaterThan(0); + + // Should contain the morale roll button + expect(message.content).to.include("morale-roll-btn"); + expect(message.content).to.include(npc.id); + expect(message.content).to.include("half-hp"); + }); + }); + + describe("Morale Status Data Model", () => { + let npc; + + beforeEach(async () => { + npc = await Actor.create({ + name: "Test Skeleton", + type: "npc", + system: { + hp: { value: 8, max: 8 }, + morale: 6, + }, + }); + }); + + afterEach(async () => { + await npc?.delete(); + }); + + it("should have default morale status values", () => { + /** + * Unit test: Verifies new NPCs have correct default values + * for moraleStatus fields. + */ + expect(npc.system.moraleStatus.checkedThisCombat).to.equal(false); + expect(npc.system.moraleStatus.broken).to.equal(false); + expect(npc.system.moraleStatus.lastTrigger).to.equal(null); + expect(npc.system.moraleStatus.lastResult).to.equal(null); + }); + + it("should have default morale score of 7", () => { + /** + * Unit test: Verifies NPCs default to morale 7 if not specified. + */ + // Create NPC without explicit morale + Actor.create({ + name: "Default Morale NPC", + type: "npc", + system: { hp: { value: 5, max: 5 } }, + }).then(async (defaultNpc) => { + expect(defaultNpc.system.morale).to.equal(7); + await defaultNpc.delete(); + }); + }); + + it("should clamp morale to valid range (2-12)", async () => { + /** + * Unit test: Verifies morale values are clamped to the valid + * range of 2d6 results (2-12). + */ + // Try to set morale below minimum + await npc.update({ "system.morale": 0 }); + expect(npc.system.morale).to.be.at.least(2); + + // Try to set morale above maximum + await npc.update({ "system.morale": 15 }); + expect(npc.system.morale).to.be.at.most(12); + }); + }); + }, + { displayName: "Vagabond: NPC Morale System" } + ); +} diff --git a/module/tests/quench-init.mjs b/module/tests/quench-init.mjs index 14b0607..2b072a0 100644 --- a/module/tests/quench-init.mjs +++ b/module/tests/quench-init.mjs @@ -13,6 +13,7 @@ import { registerDiceTests } from "./dice.test.mjs"; import { registerSpellTests } from "./spell.test.mjs"; import { registerCritThresholdTests } from "./crit-threshold.test.mjs"; import { registerClassTests } from "./class.test.mjs"; +import { registerMoraleTests } from "./morale.test.mjs"; // import { registerItemTests } from "./item.test.mjs"; // import { registerEffectTests } from "./effects.test.mjs"; @@ -72,6 +73,7 @@ export function registerQuenchTests(quenchRunner) { registerSpellTests(quenchRunner); registerCritThresholdTests(quenchRunner); registerClassTests(quenchRunner); + registerMoraleTests(quenchRunner); // registerItemTests(quenchRunner); // registerEffectTests(quenchRunner); diff --git a/module/vagabond.mjs b/module/vagabond.mjs index 9ef5f5f..47a0d07 100644 --- a/module/vagabond.mjs +++ b/module/vagabond.mjs @@ -245,6 +245,25 @@ if (!actor) { // eslint-disable-next-line no-console console.log("Vagabond RPG | Created Cast Spell macro"); } + + // Morale Check macro + const moraleMacroName = "Morale Check"; + const existingMoraleMacro = game.macros.find((m) => m.name === moraleMacroName); + + if (!existingMoraleMacro) { + await Macro.create({ + name: moraleMacroName, + type: "script", + img: "icons/svg/skull.svg", + command: `// Roll morale for selected NPC tokens +// Uses lowest morale score if multiple NPCs selected (group check) +const VagabondActor = CONFIG.Actor.documentClass; +VagabondActor.rollGroupMorale({ trigger: "manual" });`, + flags: { vagabond: { systemMacro: true } }, + }); + // eslint-disable-next-line no-console + console.log("Vagabond RPG | Created Morale Check macro"); + } } /* -------------------------------------------- */ @@ -332,6 +351,68 @@ Hooks.on("updateActor", async (actor, changed, _options, userId) => { } }); +/* -------------------------------------------- */ +/* NPC Morale System */ +/* -------------------------------------------- */ + +/** + * When an NPC's HP drops to half or below, prompt a morale check. + * Only triggers once per combat (until morale status is reset). + */ +Hooks.on("updateActor", async (actor, changed, options, userId) => { + // Only process for the updating user (GM typically) + if (game.user.id !== userId) return; + + // Only process NPC HP changes + if (actor.type !== "npc") return; + if (!foundry.utils.hasProperty(changed, "system.hp.value")) return; + + // Skip if already broken or already prompted this combat + const moraleStatus = actor.system.moraleStatus; + if (moraleStatus?.broken || moraleStatus?.checkedThisCombat) return; + + // Check if HP dropped to half or below + const newHP = changed.system.hp.value; + const maxHP = actor.system.hp.max; + const halfHP = Math.floor(maxHP / 2); + + // Only trigger if crossing the half-HP threshold + // (options._previousHP is set by _preUpdate if we had it, but we'll use current logic) + if (newHP <= halfHP && newHP > 0) { + // Prompt the GM with a morale check button + await actor.promptMoraleCheck("half-hp"); + } +}); + +/** + * Handle clicks on morale roll buttons in chat messages. + * The button is created by VagabondActor.promptMoraleCheck(). + */ +Hooks.on("renderChatMessage", (message, html) => { + // Find morale roll buttons in this message + html.find(".morale-roll-btn").on("click", async (event) => { + event.preventDefault(); + + const button = event.currentTarget; + const actorId = button.dataset.actorId; + const trigger = button.dataset.trigger; + + // Get the actor + const actor = game.actors.get(actorId); + if (!actor) { + ui.notifications.error("Could not find actor for morale check"); + return; + } + + // Roll morale + await actor.rollMorale({ trigger }); + + // Disable the button after rolling + button.disabled = true; + button.textContent = "Morale Checked"; + }); +}); + /* -------------------------------------------- */ /* Quench Test Registration */ /* -------------------------------------------- */