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:
parent
1224cc9b55
commit
f6948dc7d6
@ -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({
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
294
module/tests/morale.test.mjs
Normal file
294
module/tests/morale.test.mjs
Normal 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" }
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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 */
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user