vagabond-rpg-foundryvtt/module/tests/actor.test.mjs
Cal Corum 69475fca55 Add resource management method tests
Tests for VagabondActor resource methods:
- modifyResource() bounds checking
- applyDamage() with armor reduction and ignoreArmor option
- applyHealing() clamped to max
- spendMana() / spendLuck() with success/failure returns
- addFatigue() with max 5 cap
- takeBreather() / takeFullRest() recovery mechanics
- isDead getter for HP=0 and fatigue=5 conditions

Task 2.10: Resource management was already implemented, tests now
document and verify the existing functionality.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 20:44:00 -06:00

746 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Actor Data Model Tests
*
* Tests for VagabondActor class and character/NPC data models.
* These tests verify derived value calculations, resource tracking,
* and data integrity.
*/
/**
* Register actor tests with Quench
* @param {Quench} quenchRunner - The Quench test runner instance
*/
export function registerActorTests(quenchRunner) {
quenchRunner.registerBatch(
"vagabond.actors.character",
(context) => {
const { describe, it, expect, beforeEach, afterEach } = context;
let testActor = null;
beforeEach(async () => {
// Create a fresh test actor before each test
testActor = await Actor.create({
name: "Test Character",
type: "character",
system: {
stats: {
might: { value: 5 },
dexterity: { value: 4 },
awareness: { value: 3 },
reason: { value: 4 },
presence: { value: 3 },
luck: { value: 2 },
},
level: 1,
},
});
});
afterEach(async () => {
// Clean up test actor after each test
if (testActor) {
await testActor.delete();
testActor = null;
}
});
describe("Derived Values", () => {
it("calculates Max HP as Might × Level", async () => {
expect(testActor.system.resources.hp.max).to.equal(5); // 5 × 1
await testActor.update({ "system.level": 3 });
expect(testActor.system.resources.hp.max).to.equal(15); // 5 × 3
});
it("calculates walking Speed based on Dexterity", async () => {
/**
* Walking speed is derived from Dexterity stat per the speedByDex lookup table.
* CharacterData uses speed.walk (not speed.value like NPCs) to support
* multiple movement types (walk, fly, swim, climb, burrow).
*
* Speed by DEX: 2-3 = 25ft, 4-5 = 30ft, 6-7 = 35ft
*/
// DEX 4 = 30 ft speed
expect(testActor.system.speed.walk).to.equal(30);
await testActor.update({ "system.stats.dexterity.value": 6 });
expect(testActor.system.speed.walk).to.equal(35);
await testActor.update({ "system.stats.dexterity.value": 2 });
expect(testActor.system.speed.walk).to.equal(25);
});
it("applies speed bonus to walking speed", async () => {
/**
* Speed bonuses from effects (Fleet of Foot, Haste, etc.) are added
* to the base walking speed. Formula: speedByDex[DEX] + bonus
*/
expect(testActor.system.speed.walk).to.equal(30); // Base DEX 4
await testActor.update({ "system.speed.bonus": 10 });
expect(testActor.system.speed.walk).to.equal(40); // 30 + 10
});
it("calculates Item Slots as 8 + Might - Fatigue + bonus", async () => {
/**
* Item slot formula: baseItemSlots (8) + Might - Fatigue + bonus
* At creation: fatigue = 0, bonus = 0, so max = 8 + Might
*/
expect(testActor.system.itemSlots.max).to.equal(13); // 8 + 5 - 0 + 0
});
it("tracks overburdened status when used slots exceed max", async () => {
/**
* Characters become overburdened when itemSlots.used > itemSlots.max.
* This status is auto-calculated from actual items in prepareDerivedData().
* With max = 13 (8 + Might 5), we need items totaling > 13 slots.
*/
expect(testActor.system.itemSlots.overburdened).to.equal(false);
// Add equipment items that exceed capacity (max is 13 slots)
// Each item takes 5 slots, so 3 items = 15 slots > 13 max
await testActor.createEmbeddedDocuments("Item", [
{ name: "Heavy Pack 1", type: "equipment", "system.slots": 5 },
{ name: "Heavy Pack 2", type: "equipment", "system.slots": 5 },
{ name: "Heavy Pack 3", type: "equipment", "system.slots": 5 },
]);
expect(testActor.system.itemSlots.used).to.equal(15);
expect(testActor.system.itemSlots.overburdened).to.equal(true);
});
it("sums bonus sources for item slot calculation", async () => {
/**
* Item slot bonuses come from various sources (Orc Hulking trait, Pack Mule perk).
* The bonuses array is summed and added to the max calculation.
*/
expect(testActor.system.itemSlots.max).to.equal(13); // Base
await testActor.update({
"system.itemSlots.bonuses": [
{ source: "Orc Hulking", value: 2 },
{ source: "Pack Mule", value: 2 },
],
});
expect(testActor.system.itemSlots.bonus).to.equal(4);
expect(testActor.system.itemSlots.max).to.equal(17); // 8 + 5 - 0 + 4
});
it("calculates Save difficulties correctly", async () => {
// Reflex = DEX + AWR = 4 + 3 = 7, Difficulty = 20 - 7 = 13
expect(testActor.system.saves.reflex.difficulty).to.equal(13);
// Endure = MIT + MIT = 5 + 5 = 10, Difficulty = 20 - 10 = 10
expect(testActor.system.saves.endure.difficulty).to.equal(10);
// Will = RSN + PRS = 4 + 3 = 7, Difficulty = 20 - 7 = 13
expect(testActor.system.saves.will.difficulty).to.equal(13);
});
it("calculates Skill difficulties based on training", async () => {
// Untrained: 20 - stat
// Trained: 20 - (stat × 2)
// Arcana (RSN 4), untrained: 20 - 4 = 16
expect(testActor.system.skills.arcana.difficulty).to.equal(16);
// Set trained
await testActor.update({ "system.skills.arcana.trained": true });
// Trained: 20 - (4 × 2) = 12
expect(testActor.system.skills.arcana.difficulty).to.equal(12);
});
});
describe("Resource Tracking", () => {
it("tracks Fatigue from 0 to 5 and reduces item slots", async () => {
/**
* Fatigue is a resource that accumulates from 0 to 5 (death at 5).
* Each point of fatigue reduces available item slots by 1.
* Formula: itemSlots.max = 8 + Might - Fatigue + bonus
*/
expect(testActor.system.resources.fatigue.value).to.equal(0);
expect(testActor.system.itemSlots.max).to.equal(13); // 8 + 5 - 0
await testActor.update({ "system.resources.fatigue.value": 3 });
expect(testActor.system.resources.fatigue.value).to.equal(3);
// Fatigue reduces item slots
expect(testActor.system.itemSlots.max).to.equal(10); // 8 + 5 - 3
});
it("sets Luck pool max equal to Luck stat", async () => {
/**
* Maximum Luck points equals the character's Luck stat.
* Luck refreshes on rest and can be spent for rerolls or luck-based perks.
*/
expect(testActor.system.resources.luck.max).to.equal(2);
await testActor.update({ "system.stats.luck.value": 5 });
expect(testActor.system.resources.luck.max).to.equal(5);
});
it("tracks HP with bonus modifier", async () => {
/**
* HP max = Might × Level + bonus
* Bonus can come from perks like Tough or ancestry traits.
*/
expect(testActor.system.resources.hp.max).to.equal(5); // 5 × 1 + 0
await testActor.update({ "system.resources.hp.bonus": 3 });
expect(testActor.system.resources.hp.max).to.equal(8); // 5 × 1 + 3
});
it("tracks Studied Dice pool for Scholar class", async () => {
/**
* Studied Dice are a Scholar class resource - d8s that can replace d20 rolls.
* The pool has current value and max (typically from class level).
*/
expect(testActor.system.resources.studiedDice.value).to.equal(0);
expect(testActor.system.resources.studiedDice.max).to.equal(0);
await testActor.update({
"system.resources.studiedDice.value": 2,
"system.resources.studiedDice.max": 3,
});
expect(testActor.system.resources.studiedDice.value).to.equal(2);
expect(testActor.system.resources.studiedDice.max).to.equal(3);
});
});
describe("Custom Resources", () => {
it("supports flexible custom resource tracking", async () => {
/**
* Custom resources allow class-specific tracking (Alchemist Formulae,
* Hunter's Mark, Gunslinger consecutive hits, etc.).
* Each resource has: name, value, max, type, subtype, resetOn, data
*/
await testActor.update({
"system.customResources": [
{
name: "Prepared Formulae",
value: 3,
max: 5,
type: "list",
subtype: "formulae",
resetOn: "rest",
data: { formulaeIds: ["heal", "firebomb", "smoke"] },
},
],
});
expect(testActor.system.customResources.length).to.equal(1);
expect(testActor.system.customResources[0].name).to.equal("Prepared Formulae");
expect(testActor.system.customResources[0].type).to.equal("list");
});
});
describe("Status Effects with Countdown Dice", () => {
it("tracks status effects with countdown die duration", async () => {
/**
* Status effects use Countdown Dice for duration tracking.
* Countdown Dice: d6 → d4 → ends (roll at start of turn, effect ends on 1-2).
*/
await testActor.update({
"system.statusEffects": [
{
name: "Burning",
description: "Take 1d6 fire damage at start of turn",
source: "Dragon Breath",
beneficial: false,
durationType: "countdown",
countdownDie: 6, // Starts as d6
changes: [],
},
],
});
expect(testActor.system.statusEffects.length).to.equal(1);
expect(testActor.system.statusEffects[0].countdownDie).to.equal(6);
});
});
describe("Favor/Hinder System", () => {
it("detects favor from Active Effect flags", async () => {
/**
* Favor/Hinder is now tracked via Active Effect flags instead of data schema.
* Flag convention: flags.vagabond.favor.skills.<skillId>
* The getNetFavorHinder method checks these flags.
*/
// Set a flag directly (simulating what an Active Effect would do)
await testActor.setFlag("vagabond", "favor.skills.performance", true);
const result = testActor.getNetFavorHinder({ skillId: "performance" });
expect(result.net).to.equal(1);
expect(result.favorSources.length).to.equal(1);
// Clean up
await testActor.unsetFlag("vagabond", "favor.skills.performance");
});
it("detects hinder from Active Effect flags", async () => {
/**
* Hinder flags work the same way as favor flags.
* Flag convention: flags.vagabond.hinder.skills.<skillId>
*/
await testActor.setFlag("vagabond", "hinder.skills.sneak", true);
const result = testActor.getNetFavorHinder({ skillId: "sneak" });
expect(result.net).to.equal(-1);
expect(result.hinderSources.length).to.equal(1);
// Clean up
await testActor.unsetFlag("vagabond", "hinder.skills.sneak");
});
it("cancels favor and hinder 1-for-1", async () => {
/**
* When both favor and hinder apply to the same roll, they cancel out.
* Net result is clamped to -1, 0, or +1.
*/
await testActor.setFlag("vagabond", "favor.skills.arcana", true);
await testActor.setFlag("vagabond", "hinder.skills.arcana", true);
const result = testActor.getNetFavorHinder({ skillId: "arcana" });
expect(result.net).to.equal(0);
// Clean up
await testActor.unsetFlag("vagabond", "favor.skills.arcana");
await testActor.unsetFlag("vagabond", "hinder.skills.arcana");
});
it("detects favor/hinder for attack rolls", async () => {
/**
* Attack rolls check flags.vagabond.favor.attacks and hinder.attacks.
*/
await testActor.setFlag("vagabond", "favor.attacks", true);
const result = testActor.getNetFavorHinder({ isAttack: true });
expect(result.net).to.equal(1);
// Clean up
await testActor.unsetFlag("vagabond", "favor.attacks");
});
it("detects favor/hinder for save rolls", async () => {
/**
* Save rolls check flags.vagabond.favor.saves.<saveType>.
*/
await testActor.setFlag("vagabond", "hinder.saves.reflex", true);
const result = testActor.getNetFavorHinder({ saveType: "reflex" });
expect(result.net).to.equal(-1);
// Clean up
await testActor.unsetFlag("vagabond", "hinder.saves.reflex");
});
});
describe("Focus Tracking", () => {
it("tracks maintained spell focus", async () => {
/**
* Focus duration spells require concentration. Character can maintain
* up to maxConcurrent focus effects (usually 1, Ancient Growth = 2).
*/
expect(testActor.system.focus.maxConcurrent).to.equal(1);
await testActor.update({
"system.focus.active": [
{
spellName: "Telekinesis",
target: "Heavy Boulder",
manaCostPerRound: 0,
requiresSaveCheck: false,
canBeBroken: true,
},
],
});
expect(testActor.system.focus.active.length).to.equal(1);
expect(testActor.system.focus.active[0].spellName).to.equal("Telekinesis");
});
});
describe("Resource Management Methods", () => {
it("modifyResource changes HP within bounds", async () => {
/**
* modifyResource() adjusts any resource by a delta value.
* Values are clamped between 0 and max.
*/
await testActor.update({ "system.resources.hp.value": 3 });
// Increase HP
await testActor.modifyResource("hp", 2);
expect(testActor.system.resources.hp.value).to.equal(5); // max is 5
// Try to exceed max
await testActor.modifyResource("hp", 10);
expect(testActor.system.resources.hp.value).to.equal(5); // clamped to max
// Decrease HP
await testActor.modifyResource("hp", -2);
expect(testActor.system.resources.hp.value).to.equal(3);
// Try to go below 0
await testActor.modifyResource("hp", -10);
expect(testActor.system.resources.hp.value).to.equal(0); // clamped to 0
});
it("applyDamage reduces HP accounting for armor", async () => {
/**
* applyDamage() subtracts damage from HP after applying armor reduction.
* Armor is calculated from equipped armor items in prepareDerivedData.
*/
await testActor.update({ "system.resources.hp.value": 5 });
// Create equipped armor to get armor value
await testActor.createEmbeddedDocuments("Item", [
{
name: "Leather Armor",
type: "armor",
"system.armorValue": 2,
"system.equipped": true,
},
]);
// 4 damage - 2 armor = 2 actual damage
await testActor.applyDamage(4);
expect(testActor.system.resources.hp.value).to.equal(3);
});
it("applyDamage can ignore armor when specified", async () => {
/**
* Some effects bypass armor (piercing, magic, etc.).
* Pass ignoreArmor: true to deal full damage.
*/
await testActor.update({ "system.resources.hp.value": 5 });
// Create equipped armor
await testActor.createEmbeddedDocuments("Item", [
{
name: "Leather Armor",
type: "armor",
"system.armorValue": 2,
"system.equipped": true,
},
]);
await testActor.applyDamage(3, { ignoreArmor: true });
expect(testActor.system.resources.hp.value).to.equal(2); // full 3 damage, armor ignored
});
it("applyHealing restores HP up to max", async () => {
/**
* applyHealing() adds HP, clamped to max.
*/
await testActor.update({ "system.resources.hp.value": 1 });
await testActor.applyHealing(2);
expect(testActor.system.resources.hp.value).to.equal(3);
// Can't exceed max
await testActor.applyHealing(100);
expect(testActor.system.resources.hp.value).to.equal(5); // max
});
it("spendMana reduces mana and returns success status", async () => {
/**
* spendMana() attempts to spend mana for spellcasting.
* Returns true if successful, false if insufficient mana.
*/
await testActor.update({
"system.resources.mana.value": 5,
"system.resources.mana.max": 10,
});
const success1 = await testActor.spendMana(3);
expect(success1).to.equal(true);
expect(testActor.system.resources.mana.value).to.equal(2);
// Try to spend more than available
const success2 = await testActor.spendMana(5);
expect(success2).to.equal(false);
expect(testActor.system.resources.mana.value).to.equal(2); // unchanged
});
it("spendLuck reduces luck pool and returns success status", async () => {
/**
* spendLuck() spends one luck point for rerolls or luck-based abilities.
* Returns true if successful, false if no luck remaining.
*/
await testActor.update({ "system.resources.luck.value": 2 });
const success1 = await testActor.spendLuck();
expect(success1).to.equal(true);
expect(testActor.system.resources.luck.value).to.equal(1);
const success2 = await testActor.spendLuck();
expect(success2).to.equal(true);
expect(testActor.system.resources.luck.value).to.equal(0);
// No luck remaining
const success3 = await testActor.spendLuck();
expect(success3).to.equal(false);
expect(testActor.system.resources.luck.value).to.equal(0);
});
it("addFatigue increases fatigue up to maximum of 5", async () => {
/**
* addFatigue() accumulates fatigue (max 5 = death).
* Each point also reduces available item slots.
*/
expect(testActor.system.resources.fatigue.value).to.equal(0);
await testActor.addFatigue(2);
expect(testActor.system.resources.fatigue.value).to.equal(2);
await testActor.addFatigue(1);
expect(testActor.system.resources.fatigue.value).to.equal(3);
// Can't exceed 5
await testActor.addFatigue(10);
expect(testActor.system.resources.fatigue.value).to.equal(5);
});
it("takeBreather recovers Might HP and tracks breathers taken", async () => {
/**
* Short rest (breather) recovers HP equal to Might stat.
* Tracks number of breathers taken for potential limits.
*/
await testActor.update({ "system.resources.hp.value": 1 });
const result = await testActor.takeBreather();
// Might is 5, so recover up to 5 HP (but max HP is 5, current was 1)
expect(result.recovered).to.equal(4); // 5 - 1 = 4 recovered
expect(testActor.system.resources.hp.value).to.equal(5);
expect(result.breathersTaken).to.equal(1);
// Take another breather (but already at max HP)
const result2 = await testActor.takeBreather();
expect(result2.recovered).to.equal(0);
expect(result2.breathersTaken).to.equal(2);
});
it("takeFullRest restores all resources and reduces fatigue", async () => {
/**
* Full rest restores HP, Mana, Luck to max and reduces Fatigue by 1.
* Resets breather counter.
*/
await testActor.update({
"system.resources.hp.value": 1,
"system.resources.mana.value": 0,
"system.resources.mana.max": 10,
"system.resources.luck.value": 0,
"system.resources.fatigue.value": 3,
"system.restTracking.breathersTaken": 5,
});
const result = await testActor.takeFullRest();
expect(testActor.system.resources.hp.value).to.equal(5); // max
expect(testActor.system.resources.mana.value).to.equal(10); // max
expect(testActor.system.resources.luck.value).to.equal(2); // max = luck stat
expect(testActor.system.resources.fatigue.value).to.equal(2); // reduced by 1
expect(testActor.system.restTracking.breathersTaken).to.equal(0);
expect(result.fatigueReduced).to.equal(1);
});
it("isDead returns true when HP is 0", async () => {
/**
* Characters die when HP reaches 0 or fatigue reaches 5.
*/
await testActor.update({ "system.resources.hp.value": 1 });
expect(testActor.isDead).to.equal(false);
await testActor.update({ "system.resources.hp.value": 0 });
expect(testActor.isDead).to.equal(true);
});
it("isDead returns true when fatigue reaches 5", async () => {
/**
* Death by exhaustion occurs at 5 fatigue.
* Must have HP > 0 to isolate the fatigue check.
*/
await testActor.update({
"system.resources.hp.value": 5,
"system.resources.fatigue.value": 4,
});
expect(testActor.isDead).to.equal(false);
await testActor.update({ "system.resources.fatigue.value": 5 });
expect(testActor.isDead).to.equal(true);
});
});
},
{ displayName: "Vagabond: Character Actors" }
);
quenchRunner.registerBatch(
"vagabond.actors.npc",
(context) => {
const { describe, it, expect, beforeEach, afterEach } = context;
let testNPC = null;
beforeEach(async () => {
testNPC = await Actor.create({
name: "Test Goblin",
type: "npc",
system: {
hd: 1,
hp: { value: 4, max: 4 },
tl: 0.8,
zone: "frontline",
morale: 6,
armor: 0,
},
});
});
afterEach(async () => {
if (testNPC) {
await testNPC.delete();
testNPC = null;
}
});
describe("NPC Stats", () => {
it("stores HD and HP independently", async () => {
/**
* Hit Dice (HD) represents combat prowess, while HP is actual hit points.
* These are separate to allow flexible monster design.
*/
expect(testNPC.system.hd).to.equal(1);
expect(testNPC.system.hp.max).to.equal(4);
});
it("stores Threat Level (TL)", async () => {
/**
* Threat Level is used for encounter balancing.
* 0.1 = minion, 1.0 = standard, 2.0+ = elite/boss
*/
expect(testNPC.system.tl).to.equal(0.8);
});
it("stores Zone behavior for AI hints", async () => {
/**
* Zone indicates preferred combat positioning:
* frontline = melee engager, midline = support/ranged, backline = caster/sniper
*/
expect(testNPC.system.zone).to.equal("frontline");
});
it("stores Morale score for flee checks", async () => {
/**
* Morale check: 2d6 vs Morale score.
* Triggered when first ally dies, NPC at half HP, or leader dies.
*/
expect(testNPC.system.morale).to.equal(6);
});
});
describe("Morale Status Tracking", () => {
it("tracks morale check state during combat", async () => {
/**
* MoraleStatus tracks whether a check has been made this combat,
* what triggered it, and if the NPC is broken (fleeing/surrendered).
*/
expect(testNPC.system.moraleStatus.checkedThisCombat).to.equal(false);
expect(testNPC.system.moraleStatus.broken).to.equal(false);
await testNPC.update({
"system.moraleStatus.checkedThisCombat": true,
"system.moraleStatus.lastTrigger": "half-hp",
"system.moraleStatus.lastResult": "failed-retreat",
"system.moraleStatus.broken": true,
});
expect(testNPC.system.moraleStatus.checkedThisCombat).to.equal(true);
expect(testNPC.system.moraleStatus.lastTrigger).to.equal("half-hp");
expect(testNPC.system.moraleStatus.broken).to.equal(true);
});
});
describe("NPC Senses", () => {
it("tracks vision types for NPCs", async () => {
/**
* Senses determine what an NPC can perceive:
* darksight = see in darkness, blindsight/tremorsense = range in feet
*/
expect(testNPC.system.senses.darksight).to.equal(false);
expect(testNPC.system.senses.blindsight).to.equal(0);
await testNPC.update({
"system.senses.darksight": true,
"system.senses.blindsight": 30,
});
expect(testNPC.system.senses.darksight).to.equal(true);
expect(testNPC.system.senses.blindsight).to.equal(30);
});
});
describe("NPC Actions and Abilities", () => {
it("stores attack actions array", async () => {
/**
* NPC actions define their attack options with name, damage, and type.
*/
await testNPC.update({
"system.actions": [
{
name: "Rusty Dagger",
attackType: "melee",
damage: "1d4",
damageType: "pierce",
properties: ["finesse"],
},
],
});
expect(testNPC.system.actions.length).to.equal(1);
expect(testNPC.system.actions[0].name).to.equal("Rusty Dagger");
expect(testNPC.system.actions[0].damage).to.equal("1d4");
});
it("stores special abilities array", async () => {
/**
* NPC abilities are special traits (passive or active).
*/
await testNPC.update({
"system.abilities": [
{
name: "Pack Tactics",
description: "Gain Favor on attacks when ally is adjacent to target.",
passive: true,
},
],
});
expect(testNPC.system.abilities.length).to.equal(1);
expect(testNPC.system.abilities[0].name).to.equal("Pack Tactics");
expect(testNPC.system.abilities[0].passive).to.equal(true);
});
});
describe("Damage Resistances", () => {
it("tracks immunities, weaknesses, and resistances", async () => {
/**
* NPCs can have damage type immunities (no damage), weaknesses (+damage),
* and resistances (-damage).
*/
await testNPC.update({
"system.immunities": ["poison", "psychic"],
"system.weaknesses": ["fire"],
"system.resistances": ["blunt"],
});
expect(testNPC.system.immunities).to.include("poison");
expect(testNPC.system.weaknesses).to.include("fire");
expect(testNPC.system.resistances).to.include("blunt");
});
});
},
{ displayName: "Vagabond: NPC Actors" }
);
}