Implement NPC morale check system

Add morale roll system for NPCs with group support and automatic triggers:
- rollMorale() method for individual NPC morale checks (2d6 vs morale score)
- rollGroupMorale() static method uses lowest morale in selected tokens
- promptMoraleCheck() posts GM-whispered chat with clickable roll button
- Auto-prompt when NPC HP drops to half or below
- Chat button click handler for morale prompts
- Morale Check macro for quick group rolls
- Comprehensive test suite for morale functionality

Morale fails when 2d6 > morale score, setting moraleStatus.broken = true.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-12-14 00:03:07 -06:00
parent 1224cc9b55
commit f6948dc7d6
5 changed files with 583 additions and 1 deletions

View File

@ -83,7 +83,7 @@ export default class NPCData extends VagabondActorBase {
nullable: true, nullable: true,
blank: false, blank: false,
initial: null, 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 // Result of the last morale check
lastResult: new fields.StringField({ lastResult: new fields.StringField({

View File

@ -520,6 +520,211 @@ export default class VagabondActor extends Actor {
return this.system.shouldCheckMorale?.() || false; 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<Object>} 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
? `<span style="color: green; font-weight: bold;">HOLDS!</span> (rolled ${roll.total}${morale})`
: `<span style="color: red; font-weight: bold;">BREAKS!</span> (rolled ${roll.total} > ${morale})`;
const content = `
<div class="vagabond morale-check">
<h3>Morale Check</h3>
<p><strong>Trigger:</strong> ${triggerText}</p>
<p><strong>Morale Score:</strong> ${morale}</p>
<p><strong>Result:</strong> ${resultText}</p>
${!passed ? "<p><em>The enemy retreats or surrenders!</em></p>" : ""}
</div>
`;
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<Object>} 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
? `<span style="color: green; font-weight: bold;">HOLDS!</span> (rolled ${roll.total}${lowestMorale})`
: `<span style="color: red; font-weight: bold;">BREAKS!</span> (rolled ${roll.total} > ${lowestMorale})`;
const actorNames = actors.map((a) => a.name).join(", ");
const content = `
<div class="vagabond morale-check group">
<h3>Group Morale Check</h3>
<p><strong>Group:</strong> ${actorNames}</p>
<p><strong>Trigger:</strong> ${triggerText}</p>
<p><strong>Morale Score:</strong> ${lowestMorale} (lowest in group)</p>
<p><strong>Result:</strong> ${resultText}</p>
${!passed ? "<p><em>The enemies retreat or surrender!</em></p>" : ""}
</div>
`;
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 = `
<div class="vagabond morale-prompt">
<h3>Morale Check Triggered</h3>
<p><strong>${this.name}</strong> has reached a morale trigger: <em>${triggerText}</em></p>
<p>Morale Score: <strong>${this.system.morale}</strong></p>
<button type="button" class="morale-roll-btn" data-actor-id="${this.id}" data-trigger="${trigger}">
Roll Morale Check
</button>
</div>
`;
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. * Get the net favor/hinder for a specific roll type.
* Checks Active Effect flags for persistent favor/hinder sources. * Checks Active Effect flags for persistent favor/hinder sources.

View File

@ -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" }
);
}

View File

@ -13,6 +13,7 @@ import { registerDiceTests } from "./dice.test.mjs";
import { registerSpellTests } from "./spell.test.mjs"; import { registerSpellTests } from "./spell.test.mjs";
import { registerCritThresholdTests } from "./crit-threshold.test.mjs"; import { registerCritThresholdTests } from "./crit-threshold.test.mjs";
import { registerClassTests } from "./class.test.mjs"; import { registerClassTests } from "./class.test.mjs";
import { registerMoraleTests } from "./morale.test.mjs";
// import { registerItemTests } from "./item.test.mjs"; // import { registerItemTests } from "./item.test.mjs";
// import { registerEffectTests } from "./effects.test.mjs"; // import { registerEffectTests } from "./effects.test.mjs";
@ -72,6 +73,7 @@ export function registerQuenchTests(quenchRunner) {
registerSpellTests(quenchRunner); registerSpellTests(quenchRunner);
registerCritThresholdTests(quenchRunner); registerCritThresholdTests(quenchRunner);
registerClassTests(quenchRunner); registerClassTests(quenchRunner);
registerMoraleTests(quenchRunner);
// registerItemTests(quenchRunner); // registerItemTests(quenchRunner);
// registerEffectTests(quenchRunner); // registerEffectTests(quenchRunner);

View File

@ -245,6 +245,25 @@ if (!actor) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log("Vagabond RPG | Created Cast Spell macro"); 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 */ /* Quench Test Registration */
/* -------------------------------------------- */ /* -------------------------------------------- */