vagabond-rpg-foundryvtt/module/tests/dice.test.mjs
Cal Corum 27a5f481aa Implement attack and save roll systems with difficulty fix
Phase 2 Tasks 2.6 & 2.7: Complete roll dialog system
- Add AttackRollDialog with weapon selection, grip toggle, attack type display
- Add SaveRollDialog with save type selection, defense options (block/dodge)
- Fix Handlebars template context resolution bug ({{this.difficulty}} pattern)
- Calculate difficulty once in dialog, pass to roll function via options
- Add difficulty/critThreshold pass-through tests for skill checks
- Fix attack check tests: use embedded items, correct damageType to "slashing"
- Add i18n strings for saves, attacks, defense types
- Add chat card and dialog styles for all roll types
- Export all roll dialogs and create system macros

Key technical fix: Handlebars was resolving {{difficulty}} through context
chain to actor.system.skills.X.difficulty (schema default 20) instead of
root template data. Using {{this.difficulty}} explicitly references root.

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

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

656 lines
20 KiB
JavaScript
Raw Permalink 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.

/**
* 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 provided difficulty when passed in options", async () => {
/**
* When difficulty is passed via options (e.g., from the dialog),
* it should be used instead of calculating from stats.
* This ensures the dialog-displayed difficulty matches the roll.
*/
const result = await skillCheck(testActor, "arcana", { difficulty: 12 });
// Should use the provided difficulty, not calculate it
expect(result.difficulty).to.equal(12);
});
it("uses provided critThreshold when passed in options", async () => {
/**
* When critThreshold is passed via options (e.g., from the dialog),
* it should override the skill's default critThreshold.
*/
const result = await skillCheck(testActor, "arcana", { critThreshold: 18 });
expect(result.critThreshold).to.equal(18);
});
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;
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,
},
items: [
{
name: "Test Sword",
type: "weapon",
system: {
damage: "1d8",
attackType: "melee",
grip: "1h",
damageType: "slashing",
equipped: true,
},
},
],
});
});
afterEach(async () => {
if (testActor) await testActor.delete();
testActor = 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 testWeapon = testActor.items.find((i) => i.type === "weapon");
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 testWeapon = testActor.items.find((i) => i.type === "weapon");
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" }
);
}