Implements Phase 2 foundational components: - VagabondActor document class with item management, resource tracking, damage/healing, rest mechanics, and combat helpers - VagabondItem document class with chat card generation and item usage - Comprehensive dice rolling module (d20 checks, skill/attack/save rolls, damage with crit doubling, countdown dice, morale checks) - Quench tests for all dice rolling functions Fixes Foundry VTT v13 compatibility issues: - Add documentTypes to system.json declaring valid Actor/Item types - Fix StringField validation errors by using nullable/null pattern instead of blank string choices for optional fields - Update actor tests to use embedded documents for slot calculations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
633 lines
19 KiB
JavaScript
633 lines
19 KiB
JavaScript
/**
|
||
* Dice Rolling Module Tests
|
||
*
|
||
* Tests for the Vagabond RPG dice rolling system.
|
||
* Covers d20 checks, skill/attack/save rolls, damage, and special dice mechanics.
|
||
*/
|
||
|
||
import {
|
||
d20Check,
|
||
skillCheck,
|
||
attackCheck,
|
||
saveRoll,
|
||
damageRoll,
|
||
doubleDice,
|
||
countdownRoll,
|
||
moraleCheck,
|
||
} from "../dice/_module.mjs";
|
||
|
||
/**
|
||
* Register dice tests with Quench
|
||
* @param {Quench} quenchRunner - The Quench test runner instance
|
||
*/
|
||
export function registerDiceTests(quenchRunner) {
|
||
quenchRunner.registerBatch(
|
||
"vagabond.dice.d20check",
|
||
(context) => {
|
||
const { describe, it, expect } = context;
|
||
|
||
describe("d20Check Basic Functionality", () => {
|
||
it("returns a roll result object with expected properties", async () => {
|
||
/**
|
||
* d20Check should return a structured result with roll object,
|
||
* total, success boolean, crit/fumble flags, and details.
|
||
*/
|
||
const result = await d20Check({ difficulty: 10 });
|
||
|
||
expect(result).to.have.property("roll");
|
||
expect(result).to.have.property("total");
|
||
expect(result).to.have.property("success");
|
||
expect(result).to.have.property("isCrit");
|
||
expect(result).to.have.property("isFumble");
|
||
expect(result).to.have.property("d20Result");
|
||
expect(result).to.have.property("difficulty");
|
||
expect(result.difficulty).to.equal(10);
|
||
});
|
||
|
||
it("determines success when total >= difficulty", async () => {
|
||
/**
|
||
* A roll succeeds when the total (d20 + modifiers) meets or
|
||
* exceeds the difficulty number.
|
||
*/
|
||
// Run multiple times to get statistical coverage
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
|
||
for (let i = 0; i < 20; i++) {
|
||
const result = await d20Check({ difficulty: 10 });
|
||
if (result.success) {
|
||
expect(result.total).to.be.at.least(10);
|
||
successCount++;
|
||
} else {
|
||
expect(result.total).to.be.below(10);
|
||
failCount++;
|
||
}
|
||
}
|
||
|
||
// With DC 10, we should see both successes and failures
|
||
// (statistically very likely over 20 rolls)
|
||
expect(successCount + failCount).to.equal(20);
|
||
});
|
||
|
||
it("detects critical hits at or above crit threshold", async () => {
|
||
/**
|
||
* A critical hit occurs when the natural d20 result (before modifiers)
|
||
* meets or exceeds the critThreshold. Default is 20.
|
||
*/
|
||
const result = await d20Check({ difficulty: 10, critThreshold: 20 });
|
||
|
||
// isCrit should be true only if d20Result >= critThreshold
|
||
if (result.isCrit) {
|
||
expect(result.d20Result).to.be.at.least(20);
|
||
} else {
|
||
expect(result.d20Result).to.be.below(20);
|
||
}
|
||
});
|
||
|
||
it("supports lowered crit thresholds", async () => {
|
||
/**
|
||
* Class features like Fighter's Valor can lower the crit threshold.
|
||
* A critThreshold of 18 means crits on 18, 19, or 20.
|
||
*/
|
||
const result = await d20Check({ difficulty: 10, critThreshold: 18 });
|
||
|
||
if (result.isCrit) {
|
||
expect(result.d20Result).to.be.at.least(18);
|
||
}
|
||
});
|
||
|
||
it("detects fumbles on natural 1", async () => {
|
||
/**
|
||
* A fumble occurs when the natural d20 shows a 1.
|
||
* This is independent of success/failure.
|
||
*/
|
||
const result = await d20Check({ difficulty: 10 });
|
||
|
||
if (result.isFumble) {
|
||
expect(result.d20Result).to.equal(1);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("Favor and Hinder Modifiers", () => {
|
||
it("adds +d6 when favorHinder is positive", async () => {
|
||
/**
|
||
* Favor adds a bonus d6 to the roll total.
|
||
* The formula becomes "1d20 + 1d6".
|
||
*/
|
||
const result = await d20Check({ difficulty: 10, favorHinder: 1 });
|
||
|
||
expect(result.details.favorHinder).to.equal(1);
|
||
expect(result.favorDie).to.be.at.least(1);
|
||
expect(result.favorDie).to.be.at.most(6);
|
||
expect(result.details.formula).to.include("+ 1d6");
|
||
});
|
||
|
||
it("subtracts d6 when favorHinder is negative", async () => {
|
||
/**
|
||
* Hinder subtracts a d6 from the roll total.
|
||
* The formula becomes "1d20 - 1d6".
|
||
*/
|
||
const result = await d20Check({ difficulty: 10, favorHinder: -1 });
|
||
|
||
expect(result.details.favorHinder).to.equal(-1);
|
||
expect(result.favorDie).to.be.at.most(-1);
|
||
expect(result.favorDie).to.be.at.least(-6);
|
||
expect(result.details.formula).to.include("- 1d6");
|
||
});
|
||
|
||
it("has no extra die when favorHinder is 0", async () => {
|
||
/**
|
||
* When favor and hinder cancel out (net 0), no d6 is added.
|
||
*/
|
||
const result = await d20Check({ difficulty: 10, favorHinder: 0 });
|
||
|
||
expect(result.favorDie).to.equal(0);
|
||
expect(result.details.formula).to.not.include("d6");
|
||
});
|
||
});
|
||
|
||
describe("Flat Modifiers", () => {
|
||
it("applies positive modifiers to the roll", async () => {
|
||
/**
|
||
* Situational modifiers are added to the roll total.
|
||
*/
|
||
const result = await d20Check({ difficulty: 10, modifier: 5 });
|
||
|
||
expect(result.details.modifier).to.equal(5);
|
||
expect(result.details.formula).to.include("+ 5");
|
||
});
|
||
|
||
it("applies negative modifiers to the roll", async () => {
|
||
/**
|
||
* Negative modifiers subtract from the roll total.
|
||
*/
|
||
const result = await d20Check({ difficulty: 10, modifier: -3 });
|
||
|
||
expect(result.details.modifier).to.equal(-3);
|
||
expect(result.details.formula).to.include("- 3");
|
||
});
|
||
});
|
||
},
|
||
{ displayName: "Vagabond: d20 Check System" }
|
||
);
|
||
|
||
quenchRunner.registerBatch(
|
||
"vagabond.dice.skillcheck",
|
||
(context) => {
|
||
const { describe, it, expect, beforeEach, afterEach } = context;
|
||
|
||
let testActor = null;
|
||
|
||
beforeEach(async () => {
|
||
testActor = await Actor.create({
|
||
name: "Test Skill Roller",
|
||
type: "character",
|
||
system: {
|
||
stats: {
|
||
might: { value: 4 },
|
||
dexterity: { value: 5 },
|
||
awareness: { value: 3 },
|
||
reason: { value: 6 },
|
||
presence: { value: 3 },
|
||
luck: { value: 2 },
|
||
},
|
||
skills: {
|
||
arcana: { trained: true, critThreshold: 20 },
|
||
brawl: { trained: false, critThreshold: 20 },
|
||
sneak: { trained: true, critThreshold: 19 },
|
||
},
|
||
level: 1,
|
||
},
|
||
});
|
||
});
|
||
|
||
afterEach(async () => {
|
||
if (testActor) {
|
||
await testActor.delete();
|
||
testActor = null;
|
||
}
|
||
});
|
||
|
||
describe("Skill Check Rolls", () => {
|
||
it("uses correct difficulty for trained skills", async () => {
|
||
/**
|
||
* Trained skill difficulty = 20 - (stat × 2)
|
||
* Arcana uses Reason (6), so difficulty = 20 - 12 = 8
|
||
*/
|
||
const result = await skillCheck(testActor, "arcana");
|
||
|
||
// Difficulty should be calculated as 20 - (6 × 2) = 8
|
||
expect(result.difficulty).to.equal(8);
|
||
});
|
||
|
||
it("uses correct difficulty for untrained skills", async () => {
|
||
/**
|
||
* Untrained skill difficulty = 20 - stat
|
||
* Brawl (untrained) uses Might (4), so difficulty = 20 - 4 = 16
|
||
*/
|
||
const result = await skillCheck(testActor, "brawl");
|
||
|
||
// Difficulty should be calculated as 20 - 4 = 16
|
||
expect(result.difficulty).to.equal(16);
|
||
});
|
||
|
||
it("uses skill-specific crit threshold", async () => {
|
||
/**
|
||
* Skills can have modified crit thresholds from class features.
|
||
* Sneak has critThreshold: 19 set in test data.
|
||
*/
|
||
const result = await skillCheck(testActor, "sneak");
|
||
|
||
expect(result.critThreshold).to.equal(19);
|
||
});
|
||
|
||
it("throws error for unknown skill", async () => {
|
||
/**
|
||
* Attempting to roll an unknown skill should throw an error.
|
||
*/
|
||
try {
|
||
await skillCheck(testActor, "nonexistent");
|
||
expect.fail("Should have thrown an error");
|
||
} catch (error) {
|
||
expect(error.message).to.include("Unknown skill");
|
||
}
|
||
});
|
||
});
|
||
},
|
||
{ displayName: "Vagabond: Skill Check System" }
|
||
);
|
||
|
||
quenchRunner.registerBatch(
|
||
"vagabond.dice.attackcheck",
|
||
(context) => {
|
||
const { describe, it, expect, beforeEach, afterEach } = context;
|
||
|
||
let testActor = null;
|
||
let testWeapon = null;
|
||
|
||
beforeEach(async () => {
|
||
testActor = await Actor.create({
|
||
name: "Test Attacker",
|
||
type: "character",
|
||
system: {
|
||
stats: {
|
||
might: { value: 5 },
|
||
dexterity: { value: 4 },
|
||
awareness: { value: 3 },
|
||
reason: { value: 2 },
|
||
presence: { value: 2 },
|
||
luck: { value: 2 },
|
||
},
|
||
attacks: {
|
||
melee: { critThreshold: 19 },
|
||
ranged: { critThreshold: 20 },
|
||
finesse: { critThreshold: 20 },
|
||
brawl: { critThreshold: 20 },
|
||
},
|
||
level: 1,
|
||
},
|
||
});
|
||
|
||
testWeapon = await Item.create({
|
||
name: "Test Sword",
|
||
type: "weapon",
|
||
system: {
|
||
damage: "1d8",
|
||
attackSkill: "melee",
|
||
gripType: "1h",
|
||
properties: [],
|
||
},
|
||
});
|
||
});
|
||
|
||
afterEach(async () => {
|
||
if (testActor) await testActor.delete();
|
||
if (testWeapon) await testWeapon.delete();
|
||
testActor = null;
|
||
testWeapon = null;
|
||
});
|
||
|
||
describe("Attack Check Rolls", () => {
|
||
it("calculates difficulty from attack stat", async () => {
|
||
/**
|
||
* Attack difficulty = 20 - (stat × 2) (attacks are always trained)
|
||
* Melee uses Might (5), so difficulty = 20 - 10 = 10
|
||
*/
|
||
const result = await attackCheck(testActor, testWeapon);
|
||
|
||
expect(result.difficulty).to.equal(10);
|
||
});
|
||
|
||
it("uses attack-specific crit threshold", async () => {
|
||
/**
|
||
* Attack types can have modified crit thresholds.
|
||
* Melee attacks have critThreshold: 19 in test data.
|
||
*/
|
||
const result = await attackCheck(testActor, testWeapon);
|
||
|
||
expect(result.critThreshold).to.equal(19);
|
||
});
|
||
});
|
||
},
|
||
{ displayName: "Vagabond: Attack Check System" }
|
||
);
|
||
|
||
quenchRunner.registerBatch(
|
||
"vagabond.dice.saveroll",
|
||
(context) => {
|
||
const { describe, it, expect, beforeEach, afterEach } = context;
|
||
|
||
let testActor = null;
|
||
|
||
beforeEach(async () => {
|
||
testActor = await Actor.create({
|
||
name: "Test Saver",
|
||
type: "character",
|
||
system: {
|
||
stats: {
|
||
might: { value: 4 },
|
||
dexterity: { value: 5 },
|
||
awareness: { value: 3 },
|
||
reason: { value: 4 },
|
||
presence: { value: 3 },
|
||
luck: { value: 2 },
|
||
},
|
||
level: 1,
|
||
},
|
||
});
|
||
});
|
||
|
||
afterEach(async () => {
|
||
if (testActor) await testActor.delete();
|
||
testActor = null;
|
||
});
|
||
|
||
describe("Save Rolls", () => {
|
||
it("rolls against provided difficulty", async () => {
|
||
/**
|
||
* Save rolls use an externally provided difficulty
|
||
* (typically from the attacker's stat).
|
||
*/
|
||
const result = await saveRoll(testActor, "reflex", 12);
|
||
|
||
expect(result.difficulty).to.equal(12);
|
||
});
|
||
|
||
it("saves do not crit (threshold stays 20)", async () => {
|
||
/**
|
||
* Save rolls use the default crit threshold of 20.
|
||
* Unlike attacks, saves cannot have lowered crit thresholds.
|
||
*/
|
||
const result = await saveRoll(testActor, "endure", 10);
|
||
|
||
expect(result.critThreshold).to.equal(20);
|
||
});
|
||
});
|
||
},
|
||
{ displayName: "Vagabond: Save Roll System" }
|
||
);
|
||
|
||
quenchRunner.registerBatch(
|
||
"vagabond.dice.damage",
|
||
(context) => {
|
||
const { describe, it, expect } = context;
|
||
|
||
describe("Damage Rolls", () => {
|
||
it("evaluates damage formula", async () => {
|
||
/**
|
||
* Damage rolls evaluate a dice formula and return the total.
|
||
*/
|
||
const roll = await damageRoll("2d6");
|
||
|
||
expect(roll.total).to.be.at.least(2);
|
||
expect(roll.total).to.be.at.most(12);
|
||
});
|
||
|
||
it("doubles dice on critical hit", async () => {
|
||
/**
|
||
* Critical hits double the number of dice rolled.
|
||
* "2d6" becomes "4d6" on a crit.
|
||
*/
|
||
const roll = await damageRoll("2d6", { isCrit: true });
|
||
|
||
// 4d6 range: 4-24
|
||
expect(roll.total).to.be.at.least(4);
|
||
expect(roll.total).to.be.at.most(24);
|
||
});
|
||
|
||
it("does not double modifiers on crit", async () => {
|
||
/**
|
||
* Only dice are doubled on crit, not flat modifiers.
|
||
* "1d6+3" becomes "2d6+3" (not "2d6+6").
|
||
*/
|
||
const roll = await damageRoll("1d6+3", { isCrit: true });
|
||
|
||
// 2d6+3 range: 5-15
|
||
expect(roll.total).to.be.at.least(5);
|
||
expect(roll.total).to.be.at.most(15);
|
||
});
|
||
});
|
||
|
||
describe("doubleDice Helper", () => {
|
||
it("doubles dice count in formula", () => {
|
||
/**
|
||
* The doubleDice helper doubles the number of each die type.
|
||
*/
|
||
expect(doubleDice("1d6")).to.equal("2d6");
|
||
expect(doubleDice("2d8")).to.equal("4d8");
|
||
expect(doubleDice("3d10")).to.equal("6d10");
|
||
});
|
||
|
||
it("preserves modifiers when doubling dice", () => {
|
||
/**
|
||
* Flat modifiers should remain unchanged.
|
||
*/
|
||
expect(doubleDice("1d6+3")).to.equal("2d6+3");
|
||
expect(doubleDice("2d8-2")).to.equal("4d8-2");
|
||
});
|
||
|
||
it("handles multiple dice types", () => {
|
||
/**
|
||
* Formulas with multiple dice types should double each.
|
||
*/
|
||
expect(doubleDice("1d6+1d4")).to.equal("2d6+2d4");
|
||
});
|
||
});
|
||
},
|
||
{ displayName: "Vagabond: Damage Roll System" }
|
||
);
|
||
|
||
quenchRunner.registerBatch(
|
||
"vagabond.dice.countdown",
|
||
(context) => {
|
||
const { describe, it, expect } = context;
|
||
|
||
describe("Countdown Dice", () => {
|
||
it("rolls the specified die size", async () => {
|
||
/**
|
||
* Countdown dice start as d6 and shrink to d4.
|
||
* The result should be within the die's range.
|
||
*/
|
||
const result = await countdownRoll(6);
|
||
|
||
expect(result.roll).to.not.be.null;
|
||
expect(result.result).to.be.at.least(1);
|
||
expect(result.result).to.be.at.most(6);
|
||
});
|
||
|
||
it("continues on high rolls (3-6 on d6)", async () => {
|
||
/**
|
||
* When rolling 3+ on the countdown die, the effect continues
|
||
* with the same die size.
|
||
*/
|
||
// Run multiple times to test the logic
|
||
for (let i = 0; i < 10; i++) {
|
||
const result = await countdownRoll(6);
|
||
|
||
if (result.result >= 3) {
|
||
expect(result.continues).to.equal(true);
|
||
expect(result.nextDie).to.equal(6);
|
||
expect(result.ended).to.equal(false);
|
||
expect(result.shrunk).to.equal(false);
|
||
}
|
||
}
|
||
});
|
||
|
||
it("shrinks die on low rolls (1-2)", async () => {
|
||
/**
|
||
* Rolling 1-2 on the countdown die causes it to shrink.
|
||
* d6 → d4, d4 → effect ends.
|
||
*/
|
||
for (let i = 0; i < 20; i++) {
|
||
const result = await countdownRoll(6);
|
||
|
||
if (result.result <= 2) {
|
||
expect(result.nextDie).to.equal(4);
|
||
expect(result.shrunk).to.equal(true);
|
||
expect(result.ended).to.equal(false);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
it("ends effect when d4 rolls 1-2", async () => {
|
||
/**
|
||
* When a d4 countdown die rolls 1-2, the effect ends completely.
|
||
*/
|
||
for (let i = 0; i < 20; i++) {
|
||
const result = await countdownRoll(4);
|
||
|
||
if (result.result <= 2) {
|
||
expect(result.nextDie).to.equal(0);
|
||
expect(result.ended).to.equal(true);
|
||
expect(result.continues).to.equal(false);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
it("returns ended state for die size 0", async () => {
|
||
/**
|
||
* If passed a die size of 0, the effect has already ended.
|
||
*/
|
||
const result = await countdownRoll(0);
|
||
|
||
expect(result.roll).to.be.null;
|
||
expect(result.continues).to.equal(false);
|
||
expect(result.ended).to.equal(true);
|
||
});
|
||
});
|
||
},
|
||
{ displayName: "Vagabond: Countdown Dice System" }
|
||
);
|
||
|
||
quenchRunner.registerBatch(
|
||
"vagabond.dice.morale",
|
||
(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,
|
||
morale: 6,
|
||
zone: "frontline",
|
||
},
|
||
});
|
||
});
|
||
|
||
afterEach(async () => {
|
||
if (testNPC) await testNPC.delete();
|
||
testNPC = null;
|
||
});
|
||
|
||
describe("Morale Checks", () => {
|
||
it("rolls 2d6 against morale score", async () => {
|
||
/**
|
||
* Morale check: 2d6 vs Morale score.
|
||
* Pass if roll <= morale, fail if roll > morale.
|
||
*/
|
||
const result = await moraleCheck(testNPC);
|
||
|
||
expect(result.roll).to.not.be.null;
|
||
expect(result.total).to.be.at.least(2);
|
||
expect(result.total).to.be.at.most(12);
|
||
expect(result.morale).to.equal(6);
|
||
});
|
||
|
||
it("passes when roll <= morale", async () => {
|
||
/**
|
||
* NPC holds their ground when 2d6 <= morale.
|
||
*/
|
||
const result = await moraleCheck(testNPC);
|
||
|
||
if (result.total <= 6) {
|
||
expect(result.passed).to.equal(true);
|
||
expect(result.fled).to.equal(false);
|
||
}
|
||
});
|
||
|
||
it("fails when roll > morale", async () => {
|
||
/**
|
||
* NPC flees when 2d6 > morale.
|
||
*/
|
||
const result = await moraleCheck(testNPC);
|
||
|
||
if (result.total > 6) {
|
||
expect(result.passed).to.equal(false);
|
||
expect(result.fled).to.equal(true);
|
||
}
|
||
});
|
||
|
||
it("throws error for non-NPC actors", async () => {
|
||
/**
|
||
* Only NPCs can make morale checks.
|
||
*/
|
||
const pcActor = await Actor.create({
|
||
name: "Test PC",
|
||
type: "character",
|
||
system: { level: 1 },
|
||
});
|
||
|
||
try {
|
||
await moraleCheck(pcActor);
|
||
expect.fail("Should have thrown an error");
|
||
} catch (error) {
|
||
expect(error.message).to.include("only for NPCs");
|
||
} finally {
|
||
await pcActor.delete();
|
||
}
|
||
});
|
||
});
|
||
},
|
||
{ displayName: "Vagabond: Morale Check System" }
|
||
);
|
||
}
|