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>
295 lines
9.7 KiB
JavaScript
295 lines
9.7 KiB
JavaScript
/**
|
|
* 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" }
|
|
);
|
|
}
|