vagabond-rpg-foundryvtt/module/tests/dice.test.mjs
Cal Corum 517b7045c7 Add Phase 2 core system logic: document classes, dice rolling, and fixes
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>
2025-12-13 10:21:48 -06:00

633 lines
19 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.

/**
* 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" }
);
}