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,
|
||||
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({
|
||||
|
||||
@ -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<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.
|
||||
* 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 { 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);
|
||||
|
||||
|
||||
@ -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 */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user