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>
This commit is contained in:
parent
c06192f90f
commit
517b7045c7
@ -229,10 +229,13 @@ export default class CharacterData extends VagabondActorBase {
|
|||||||
}),
|
}),
|
||||||
// Subtype for specific mechanics (formulae, marked-target, crit-reduction, etc.)
|
// Subtype for specific mechanics (formulae, marked-target, crit-reduction, etc.)
|
||||||
subtype: new fields.StringField({ required: false, blank: true }),
|
subtype: new fields.StringField({ required: false, blank: true }),
|
||||||
// When this resource resets
|
// When this resource resets (null = manual/never)
|
||||||
resetOn: new fields.StringField({
|
resetOn: new fields.StringField({
|
||||||
initial: "",
|
required: false,
|
||||||
choices: ["", "rest", "turn", "round", "day", "combat"],
|
nullable: true,
|
||||||
|
blank: false,
|
||||||
|
initial: null,
|
||||||
|
choices: ["rest", "turn", "round", "day", "combat"],
|
||||||
}),
|
}),
|
||||||
// Flexible data storage for complex resources (formulae lists, target IDs, etc.)
|
// Flexible data storage for complex resources (formulae lists, target IDs, etc.)
|
||||||
data: new fields.ObjectField({ initial: {} }),
|
data: new fields.ObjectField({ initial: {} }),
|
||||||
@ -498,10 +501,13 @@ export default class CharacterData extends VagabondActorBase {
|
|||||||
flankingAllies: new fields.ArrayField(new fields.StringField(), { initial: [] }),
|
flankingAllies: new fields.ArrayField(new fields.StringField(), { initial: [] }),
|
||||||
// Ignores flanking penalties (Situational Awareness perk)
|
// Ignores flanking penalties (Situational Awareness perk)
|
||||||
ignoresFlankingPenalty: new fields.BooleanField({ initial: false }),
|
ignoresFlankingPenalty: new fields.BooleanField({ initial: false }),
|
||||||
// Current combat zone
|
// Current combat zone (nullable - not in combat if null)
|
||||||
currentZone: new fields.StringField({
|
currentZone: new fields.StringField({
|
||||||
initial: "",
|
required: false,
|
||||||
choices: ["", "frontline", "midline", "backline"],
|
nullable: true,
|
||||||
|
blank: false,
|
||||||
|
initial: null,
|
||||||
|
choices: ["frontline", "midline", "backline"],
|
||||||
}),
|
}),
|
||||||
// Is dual-wielding?
|
// Is dual-wielding?
|
||||||
isDualWielding: new fields.BooleanField({ initial: false }),
|
isDualWielding: new fields.BooleanField({ initial: false }),
|
||||||
@ -548,8 +554,11 @@ export default class CharacterData extends VagabondActorBase {
|
|||||||
death: new fields.SchemaField({
|
death: new fields.SchemaField({
|
||||||
isDead: new fields.BooleanField({ initial: false }),
|
isDead: new fields.BooleanField({ initial: false }),
|
||||||
deathCause: new fields.StringField({
|
deathCause: new fields.StringField({
|
||||||
initial: "",
|
required: false,
|
||||||
choices: ["", "hp-zero", "body-destroyed", "fatigue-five"],
|
nullable: true,
|
||||||
|
blank: false,
|
||||||
|
initial: null,
|
||||||
|
choices: ["hp-zero", "body-destroyed", "fatigue-five"],
|
||||||
}),
|
}),
|
||||||
canBeRevived: new fields.BooleanField({ initial: true }),
|
canBeRevived: new fields.BooleanField({ initial: true }),
|
||||||
revivedCount: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
|
revivedCount: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
|
||||||
|
|||||||
@ -79,13 +79,19 @@ export default class NPCData extends VagabondActorBase {
|
|||||||
checkedThisCombat: new fields.BooleanField({ initial: false }),
|
checkedThisCombat: new fields.BooleanField({ initial: false }),
|
||||||
// What triggered the last check?
|
// What triggered the last check?
|
||||||
lastTrigger: new fields.StringField({
|
lastTrigger: new fields.StringField({
|
||||||
initial: "",
|
required: false,
|
||||||
choices: ["", "first-death", "half-hp", "half-incapacitated", "leader-death"],
|
nullable: true,
|
||||||
|
blank: false,
|
||||||
|
initial: null,
|
||||||
|
choices: ["first-death", "half-hp", "half-incapacitated", "leader-death"],
|
||||||
}),
|
}),
|
||||||
// Result of the last morale check
|
// Result of the last morale check
|
||||||
lastResult: new fields.StringField({
|
lastResult: new fields.StringField({
|
||||||
initial: "",
|
required: false,
|
||||||
choices: ["", "passed", "failed-retreat", "failed-surrender"],
|
nullable: true,
|
||||||
|
blank: false,
|
||||||
|
initial: null,
|
||||||
|
choices: ["passed", "failed-retreat", "failed-surrender"],
|
||||||
}),
|
}),
|
||||||
// Is this NPC currently fleeing/surrendered?
|
// Is this NPC currently fleeing/surrendered?
|
||||||
broken: new fields.BooleanField({ initial: false }),
|
broken: new fields.BooleanField({ initial: false }),
|
||||||
|
|||||||
@ -43,8 +43,11 @@ export default class FeatureData extends VagabondItemBase {
|
|||||||
// For active features: activation type
|
// For active features: activation type
|
||||||
activation: new fields.SchemaField({
|
activation: new fields.SchemaField({
|
||||||
type: new fields.StringField({
|
type: new fields.StringField({
|
||||||
initial: "",
|
required: false,
|
||||||
choices: ["", "action", "bonus", "reaction", "free", "special"],
|
nullable: true,
|
||||||
|
blank: false,
|
||||||
|
initial: null,
|
||||||
|
choices: ["action", "bonus", "reaction", "free", "special"],
|
||||||
}),
|
}),
|
||||||
cost: new fields.StringField({ required: false, blank: true }),
|
cost: new fields.StringField({ required: false, blank: true }),
|
||||||
}),
|
}),
|
||||||
@ -54,8 +57,11 @@ export default class FeatureData extends VagabondItemBase {
|
|||||||
value: new fields.NumberField({ integer: true, initial: 0 }),
|
value: new fields.NumberField({ integer: true, initial: 0 }),
|
||||||
max: new fields.NumberField({ integer: true, initial: 0 }),
|
max: new fields.NumberField({ integer: true, initial: 0 }),
|
||||||
per: new fields.StringField({
|
per: new fields.StringField({
|
||||||
initial: "",
|
required: false,
|
||||||
choices: ["", "short", "long", "day", "encounter"],
|
nullable: true,
|
||||||
|
blank: false,
|
||||||
|
initial: null,
|
||||||
|
choices: ["short", "long", "day", "encounter"],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@ -107,10 +107,13 @@ export default class WeaponData extends VagabondItemBase {
|
|||||||
// Is this weapon equipped?
|
// Is this weapon equipped?
|
||||||
equipped: new fields.BooleanField({ initial: false }),
|
equipped: new fields.BooleanField({ initial: false }),
|
||||||
|
|
||||||
// Which hand is this weapon equipped in? (for dual-wielding)
|
// Which hand is this weapon equipped in? (for dual-wielding, null = not equipped)
|
||||||
equippedHand: new fields.StringField({
|
equippedHand: new fields.StringField({
|
||||||
initial: "",
|
required: false,
|
||||||
choices: ["", "main", "off", "both"],
|
nullable: true,
|
||||||
|
blank: false,
|
||||||
|
initial: null,
|
||||||
|
choices: ["main", "off", "both"],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Quantity (for ammunition, thrown weapons)
|
// Quantity (for ammunition, thrown weapons)
|
||||||
|
|||||||
19
module/dice/_module.mjs
Normal file
19
module/dice/_module.mjs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Vagabond RPG Dice Module
|
||||||
|
*
|
||||||
|
* Central export point for all dice rolling functions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
d20Check,
|
||||||
|
skillCheck,
|
||||||
|
attackCheck,
|
||||||
|
saveRoll,
|
||||||
|
damageRoll,
|
||||||
|
doubleDice,
|
||||||
|
explodingDice,
|
||||||
|
countdownRoll,
|
||||||
|
moraleCheck,
|
||||||
|
appearingRoll,
|
||||||
|
sendRollToChat,
|
||||||
|
} from "./rolls.mjs";
|
||||||
417
module/dice/rolls.mjs
Normal file
417
module/dice/rolls.mjs
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* Vagabond RPG Dice Rolling Module
|
||||||
|
*
|
||||||
|
* Provides specialized roll functions for the Vagabond RPG system.
|
||||||
|
*
|
||||||
|
* Core Mechanics:
|
||||||
|
* - d20 checks: Roll d20 >= difficulty (20 - stat for untrained, 20 - stat×2 for trained)
|
||||||
|
* - Favor: Add +d6 to the roll
|
||||||
|
* - Hinder: Subtract d6 from the roll
|
||||||
|
* - Crit: Roll >= critThreshold (default 20, can be lowered by class features)
|
||||||
|
* - Exploding dice: d6! for certain abilities (reroll and add on max)
|
||||||
|
* - Countdown dice: d6 → d4 → ends (for status effect durations)
|
||||||
|
*
|
||||||
|
* @module dice/rolls
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll result object returned by roll functions.
|
||||||
|
* @typedef {Object} VagabondRollResult
|
||||||
|
* @property {Roll} roll - The Foundry Roll object
|
||||||
|
* @property {number} total - The final roll total
|
||||||
|
* @property {boolean} success - Whether the roll met/exceeded difficulty
|
||||||
|
* @property {boolean} isCrit - Whether the roll was a critical success
|
||||||
|
* @property {boolean} isFumble - Whether the roll was a natural 1
|
||||||
|
* @property {number} d20Result - The natural d20 result
|
||||||
|
* @property {number} favorDie - The favor/hinder d6 result (positive or negative)
|
||||||
|
* @property {Object} details - Additional roll details
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a d20 skill/attack check.
|
||||||
|
*
|
||||||
|
* @param {Object} options - Roll options
|
||||||
|
* @param {number} options.difficulty - Target difficulty number
|
||||||
|
* @param {number} [options.critThreshold=20] - Crit on d20 >= this value
|
||||||
|
* @param {number} [options.favorHinder=0] - Net favor/hinder (+1, 0, or -1)
|
||||||
|
* @param {number} [options.modifier=0] - Flat modifier to add to roll
|
||||||
|
* @param {Object} [options.rollData={}] - Data for roll formula evaluation
|
||||||
|
* @returns {Promise<VagabondRollResult>} The roll result
|
||||||
|
*/
|
||||||
|
export async function d20Check({
|
||||||
|
difficulty,
|
||||||
|
critThreshold = 20,
|
||||||
|
favorHinder = 0,
|
||||||
|
modifier = 0,
|
||||||
|
rollData = {},
|
||||||
|
} = {}) {
|
||||||
|
// Build the roll formula
|
||||||
|
let formula = "1d20";
|
||||||
|
|
||||||
|
// Add favor (+d6) or hinder (-d6)
|
||||||
|
if (favorHinder > 0) {
|
||||||
|
formula += " + 1d6";
|
||||||
|
} else if (favorHinder < 0) {
|
||||||
|
formula += " - 1d6";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add flat modifier
|
||||||
|
if (modifier !== 0) {
|
||||||
|
formula += modifier > 0 ? ` + ${modifier}` : ` - ${Math.abs(modifier)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and evaluate the roll
|
||||||
|
const roll = new Roll(formula, rollData);
|
||||||
|
await roll.evaluate();
|
||||||
|
|
||||||
|
// Extract the d20 result
|
||||||
|
const d20Term = roll.terms.find((t) => t instanceof foundry.dice.terms.Die && t.faces === 20);
|
||||||
|
const d20Result = d20Term?.results?.[0]?.result || 0;
|
||||||
|
|
||||||
|
// Extract favor/hinder d6 if present
|
||||||
|
let favorDie = 0;
|
||||||
|
if (favorHinder !== 0) {
|
||||||
|
const d6Term = roll.terms.find((t) => t instanceof foundry.dice.terms.Die && t.faces === 6);
|
||||||
|
favorDie = d6Term?.results?.[0]?.result || 0;
|
||||||
|
if (favorHinder < 0) favorDie = -favorDie;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine success (total >= difficulty)
|
||||||
|
const success = roll.total >= difficulty;
|
||||||
|
|
||||||
|
// Determine critical (natural d20 >= critThreshold)
|
||||||
|
const isCrit = d20Result >= critThreshold;
|
||||||
|
|
||||||
|
// Determine fumble (natural 1)
|
||||||
|
const isFumble = d20Result === 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
roll,
|
||||||
|
total: roll.total,
|
||||||
|
success,
|
||||||
|
isCrit,
|
||||||
|
isFumble,
|
||||||
|
d20Result,
|
||||||
|
favorDie,
|
||||||
|
difficulty,
|
||||||
|
critThreshold,
|
||||||
|
details: {
|
||||||
|
formula,
|
||||||
|
modifier,
|
||||||
|
favorHinder,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a skill check for an actor.
|
||||||
|
*
|
||||||
|
* @param {VagabondActor} actor - The actor making the check
|
||||||
|
* @param {string} skillId - The skill key (e.g., "arcana", "brawl")
|
||||||
|
* @param {Object} options - Additional options
|
||||||
|
* @param {number} [options.modifier=0] - Situational modifier
|
||||||
|
* @param {number} [options.favorHinder] - Override favor/hinder (otherwise calculated from actor)
|
||||||
|
* @returns {Promise<VagabondRollResult>} The roll result
|
||||||
|
*/
|
||||||
|
export async function skillCheck(actor, skillId, options = {}) {
|
||||||
|
const skillConfig = CONFIG.VAGABOND?.skills?.[skillId];
|
||||||
|
if (!skillConfig) {
|
||||||
|
throw new Error(`Unknown skill: ${skillId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const system = actor.system;
|
||||||
|
const skillData = system.skills?.[skillId];
|
||||||
|
|
||||||
|
if (!skillData) {
|
||||||
|
throw new Error(`Actor does not have skill: ${skillId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get difficulty from calculated value
|
||||||
|
const difficulty = skillData.difficulty;
|
||||||
|
const critThreshold = skillData.critThreshold || 20;
|
||||||
|
|
||||||
|
// Determine favor/hinder
|
||||||
|
const favorHinder = options.favorHinder ?? actor.getNetFavorHinder?.(`${skillId} Checks`) ?? 0;
|
||||||
|
|
||||||
|
return d20Check({
|
||||||
|
difficulty,
|
||||||
|
critThreshold,
|
||||||
|
favorHinder,
|
||||||
|
modifier: options.modifier || 0,
|
||||||
|
rollData: actor.getRollData(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform an attack check for an actor with a weapon.
|
||||||
|
*
|
||||||
|
* @param {VagabondActor} actor - The actor making the attack
|
||||||
|
* @param {VagabondItem} weapon - The weapon being used
|
||||||
|
* @param {Object} options - Additional options
|
||||||
|
* @param {number} [options.modifier=0] - Situational modifier
|
||||||
|
* @param {number} [options.favorHinder] - Override favor/hinder
|
||||||
|
* @returns {Promise<VagabondRollResult>} The roll result
|
||||||
|
*/
|
||||||
|
export async function attackCheck(actor, weapon, options = {}) {
|
||||||
|
const attackType = weapon.system.attackSkill || "melee";
|
||||||
|
const attackConfig = CONFIG.VAGABOND?.attackTypes?.[attackType];
|
||||||
|
|
||||||
|
if (!attackConfig) {
|
||||||
|
throw new Error(`Unknown attack type: ${attackType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const system = actor.system;
|
||||||
|
const statKey = attackConfig.stat;
|
||||||
|
const statValue = system.stats?.[statKey]?.value || 0;
|
||||||
|
|
||||||
|
// Attack difficulty = 20 - stat (attacks are always "trained")
|
||||||
|
const difficulty = 20 - statValue * 2;
|
||||||
|
|
||||||
|
// Get crit threshold from attack data
|
||||||
|
const critThreshold = system.attacks?.[attackType]?.critThreshold || 20;
|
||||||
|
|
||||||
|
// Determine favor/hinder
|
||||||
|
const favorHinder = options.favorHinder ?? actor.getNetFavorHinder?.("Attack Checks") ?? 0;
|
||||||
|
|
||||||
|
return d20Check({
|
||||||
|
difficulty,
|
||||||
|
critThreshold,
|
||||||
|
favorHinder,
|
||||||
|
modifier: options.modifier || 0,
|
||||||
|
rollData: actor.getRollData(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a save roll for an actor.
|
||||||
|
*
|
||||||
|
* @param {VagabondActor} actor - The actor making the save
|
||||||
|
* @param {string} saveType - The save type ("reflex", "endure", "will")
|
||||||
|
* @param {number} difficulty - The target difficulty
|
||||||
|
* @param {Object} options - Additional options
|
||||||
|
* @param {boolean} [options.isBlock=false] - True if using Block (Reflex with shield)
|
||||||
|
* @param {boolean} [options.isDodge=false] - True if using Dodge (Reflex)
|
||||||
|
* @param {number} [options.favorHinder] - Override favor/hinder
|
||||||
|
* @returns {Promise<VagabondRollResult>} The roll result
|
||||||
|
*/
|
||||||
|
export async function saveRoll(actor, saveType, difficulty, options = {}) {
|
||||||
|
const saveConfig = CONFIG.VAGABOND?.saves?.[saveType];
|
||||||
|
if (!saveConfig) {
|
||||||
|
throw new Error(`Unknown save type: ${saveType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine favor/hinder based on save type
|
||||||
|
let rollType = `${saveType.charAt(0).toUpperCase() + saveType.slice(1)} Saves`;
|
||||||
|
if (options.isBlock) rollType = "Block Saves";
|
||||||
|
if (options.isDodge) rollType = "Dodge Saves";
|
||||||
|
|
||||||
|
const favorHinder = options.favorHinder ?? actor.getNetFavorHinder?.(rollType) ?? 0;
|
||||||
|
|
||||||
|
return d20Check({
|
||||||
|
difficulty,
|
||||||
|
critThreshold: 20, // Saves don't crit
|
||||||
|
favorHinder,
|
||||||
|
modifier: options.modifier || 0,
|
||||||
|
rollData: actor.getRollData(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll damage dice.
|
||||||
|
*
|
||||||
|
* @param {string} formula - The damage formula (e.g., "2d6", "1d8+3")
|
||||||
|
* @param {Object} options - Roll options
|
||||||
|
* @param {boolean} [options.isCrit=false] - Double the dice on crit
|
||||||
|
* @param {Object} [options.rollData={}] - Data for roll formula evaluation
|
||||||
|
* @returns {Promise<Roll>} The evaluated roll
|
||||||
|
*/
|
||||||
|
export async function damageRoll(formula, options = {}) {
|
||||||
|
const { isCrit = false, rollData = {} } = options;
|
||||||
|
|
||||||
|
let rollFormula = formula;
|
||||||
|
|
||||||
|
// On crit, double the dice (not modifiers)
|
||||||
|
if (isCrit) {
|
||||||
|
rollFormula = doubleDice(formula);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roll = new Roll(rollFormula, rollData);
|
||||||
|
await roll.evaluate();
|
||||||
|
|
||||||
|
return roll;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Double the dice in a formula (for crits).
|
||||||
|
* "2d6+3" becomes "4d6+3"
|
||||||
|
*
|
||||||
|
* @param {string} formula - The original formula
|
||||||
|
* @returns {string} Formula with doubled dice
|
||||||
|
*/
|
||||||
|
export function doubleDice(formula) {
|
||||||
|
return formula.replace(
|
||||||
|
/(\d+)d(\d+)/gi,
|
||||||
|
(match, count, faces) => `${parseInt(count) * 2}d${faces}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll exploding dice (d6!).
|
||||||
|
* When max is rolled, add another die and keep rolling.
|
||||||
|
*
|
||||||
|
* @param {number} count - Number of d6 to roll
|
||||||
|
* @param {Object} options - Roll options
|
||||||
|
* @param {number} [options.maxExplosions=10] - Safety limit on explosions
|
||||||
|
* @returns {Promise<Roll>} The evaluated roll
|
||||||
|
*/
|
||||||
|
export async function explodingDice(count, _options = {}) {
|
||||||
|
// Use Foundry's exploding dice syntax
|
||||||
|
// Note: maxExplosions could be used for custom capping if needed
|
||||||
|
const formula = `${count}d6x`;
|
||||||
|
|
||||||
|
const roll = new Roll(formula);
|
||||||
|
await roll.evaluate();
|
||||||
|
|
||||||
|
return roll;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll a countdown die and determine if effect continues.
|
||||||
|
* Countdown: d6 → d4 → ends
|
||||||
|
* Effect ends if roll is 1-2.
|
||||||
|
*
|
||||||
|
* @param {number} currentDie - Current die size (6 or 4)
|
||||||
|
* @returns {Promise<Object>} Result with roll, continues, and nextDie
|
||||||
|
*/
|
||||||
|
export async function countdownRoll(currentDie) {
|
||||||
|
if (currentDie <= 0) {
|
||||||
|
return { roll: null, continues: false, nextDie: 0, ended: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const formula = `1d${currentDie}`;
|
||||||
|
const roll = new Roll(formula);
|
||||||
|
await roll.evaluate();
|
||||||
|
|
||||||
|
const result = roll.total;
|
||||||
|
|
||||||
|
// Effect ends on 1-2
|
||||||
|
if (result <= 2) {
|
||||||
|
// Shrink die: d6 → d4 → 0 (ended)
|
||||||
|
const nextDie = currentDie === 6 ? 4 : 0;
|
||||||
|
const ended = nextDie === 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
roll,
|
||||||
|
result,
|
||||||
|
continues: !ended,
|
||||||
|
nextDie,
|
||||||
|
ended,
|
||||||
|
shrunk: !ended, // Die shrunk but didn't end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effect continues with same die
|
||||||
|
return {
|
||||||
|
roll,
|
||||||
|
result,
|
||||||
|
continues: true,
|
||||||
|
nextDie: currentDie,
|
||||||
|
ended: false,
|
||||||
|
shrunk: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll a morale check for an NPC.
|
||||||
|
* 2d6 vs Morale score - fails if roll > morale.
|
||||||
|
*
|
||||||
|
* @param {VagabondActor} npc - The NPC making the check
|
||||||
|
* @returns {Promise<Object>} Result with roll, passed, and morale
|
||||||
|
*/
|
||||||
|
export async function moraleCheck(npc) {
|
||||||
|
if (npc.type !== "npc") {
|
||||||
|
throw new Error("Morale checks are only for NPCs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const morale = npc.system.morale || 7;
|
||||||
|
|
||||||
|
const roll = new Roll("2d6");
|
||||||
|
await roll.evaluate();
|
||||||
|
|
||||||
|
const passed = roll.total <= morale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
roll,
|
||||||
|
total: roll.total,
|
||||||
|
morale,
|
||||||
|
passed,
|
||||||
|
fled: !passed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll the "appearing" dice for a monster type.
|
||||||
|
*
|
||||||
|
* @param {string} formula - The appearing formula (e.g., "1d6", "2d4")
|
||||||
|
* @returns {Promise<Roll>} The evaluated roll
|
||||||
|
*/
|
||||||
|
export async function appearingRoll(formula) {
|
||||||
|
const roll = new Roll(formula);
|
||||||
|
await roll.evaluate();
|
||||||
|
return roll;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a chat message for a roll result.
|
||||||
|
*
|
||||||
|
* @param {VagabondRollResult} result - The roll result
|
||||||
|
* @param {Object} options - Message options
|
||||||
|
* @param {string} options.flavor - Message flavor text
|
||||||
|
* @param {Actor} [options.speaker] - The speaking actor
|
||||||
|
* @returns {Promise<ChatMessage>} The created chat message
|
||||||
|
*/
|
||||||
|
export async function sendRollToChat(result, options = {}) {
|
||||||
|
const { flavor = "Roll", speaker } = options;
|
||||||
|
|
||||||
|
// Build the message content
|
||||||
|
let content = `<div class="vagabond roll-result">`;
|
||||||
|
|
||||||
|
// Success/failure indicator
|
||||||
|
if (result.success !== undefined) {
|
||||||
|
const successClass = result.success ? "success" : "failure";
|
||||||
|
const successText = result.success ? "Success" : "Failure";
|
||||||
|
content += `<div class="roll-outcome ${successClass}">${successText}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crit indicator
|
||||||
|
if (result.isCrit) {
|
||||||
|
content += `<div class="roll-crit">Critical!</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fumble indicator
|
||||||
|
if (result.isFumble) {
|
||||||
|
content += `<div class="roll-fumble">Fumble!</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll details
|
||||||
|
content += `<div class="roll-details">`;
|
||||||
|
content += `<span class="roll-total">Total: ${result.total}</span>`;
|
||||||
|
if (result.difficulty !== undefined) {
|
||||||
|
content += ` <span class="roll-difficulty">vs DC ${result.difficulty}</span>`;
|
||||||
|
}
|
||||||
|
content += `</div>`;
|
||||||
|
|
||||||
|
content += `</div>`;
|
||||||
|
|
||||||
|
// Create the chat message with the roll
|
||||||
|
const chatData = {
|
||||||
|
user: game.user.id,
|
||||||
|
speaker: speaker ? ChatMessage.getSpeaker({ actor: speaker }) : ChatMessage.getSpeaker(),
|
||||||
|
flavor,
|
||||||
|
content,
|
||||||
|
rolls: [result.roll],
|
||||||
|
type: CONST.CHAT_MESSAGE_STYLES.ROLL,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ChatMessage.create(chatData);
|
||||||
|
}
|
||||||
9
module/documents/_module.mjs
Normal file
9
module/documents/_module.mjs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Vagabond RPG Document Classes
|
||||||
|
*
|
||||||
|
* Central export point for all custom document classes.
|
||||||
|
* These classes extend Foundry's base documents to add system-specific functionality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as VagabondActor } from "./actor.mjs";
|
||||||
|
export { default as VagabondItem } from "./item.mjs";
|
||||||
492
module/documents/actor.mjs
Normal file
492
module/documents/actor.mjs
Normal file
@ -0,0 +1,492 @@
|
|||||||
|
/**
|
||||||
|
* VagabondActor Document Class
|
||||||
|
*
|
||||||
|
* Extended Actor document for Vagabond RPG system.
|
||||||
|
* Provides document-level functionality including:
|
||||||
|
* - Derived data preparation pipeline
|
||||||
|
* - Item management (equipped items, inventory)
|
||||||
|
* - Roll methods for skills, attacks, and saves
|
||||||
|
* - Resource management helpers
|
||||||
|
*
|
||||||
|
* Data models (CharacterData, NPCData) handle schema and derived calculations.
|
||||||
|
* This class handles document operations and Foundry integration.
|
||||||
|
*
|
||||||
|
* @extends Actor
|
||||||
|
*/
|
||||||
|
export default class VagabondActor extends Actor {
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Data Preparation */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare data for the actor.
|
||||||
|
* This is called automatically by Foundry when the actor is loaded or updated.
|
||||||
|
*
|
||||||
|
* The preparation pipeline:
|
||||||
|
* 1. prepareBaseData() - Set up data before embedded documents
|
||||||
|
* 2. prepareEmbeddedDocuments() - Process owned items
|
||||||
|
* 3. prepareDerivedData() - Calculate final derived values
|
||||||
|
*
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
prepareData() {
|
||||||
|
// Call the parent class preparation
|
||||||
|
super.prepareData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare base data before embedded documents are processed.
|
||||||
|
* Called by Foundry as part of the data preparation pipeline.
|
||||||
|
*
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
prepareBaseData() {
|
||||||
|
super.prepareBaseData();
|
||||||
|
// Base data is handled by the TypeDataModel (CharacterData/NPCData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare derived data after embedded documents are processed.
|
||||||
|
* This is where we calculate values that depend on owned items.
|
||||||
|
*
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
prepareDerivedData() {
|
||||||
|
super.prepareDerivedData();
|
||||||
|
|
||||||
|
// Type-specific derived data
|
||||||
|
const actorType = this.type;
|
||||||
|
if (actorType === "character") {
|
||||||
|
this._prepareCharacterDerivedData();
|
||||||
|
} else if (actorType === "npc") {
|
||||||
|
this._prepareNPCDerivedData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare character-specific derived data.
|
||||||
|
* Calculates values based on owned items (armor, weapons, etc.).
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_prepareCharacterDerivedData() {
|
||||||
|
const system = this.system;
|
||||||
|
|
||||||
|
// Calculate armor from equipped armor items
|
||||||
|
let totalArmor = 0;
|
||||||
|
for (const item of this.items) {
|
||||||
|
if (item.type === "armor" && item.system.equipped) {
|
||||||
|
totalArmor += item.system.armorValue || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
system.armor = totalArmor;
|
||||||
|
|
||||||
|
// Calculate used item slots from inventory
|
||||||
|
let usedSlots = 0;
|
||||||
|
for (const item of this.items) {
|
||||||
|
// Only count items that take slots (not features, classes, etc.)
|
||||||
|
if (["weapon", "armor", "equipment"].includes(item.type)) {
|
||||||
|
const slots = item.system.slots || 0;
|
||||||
|
const quantity = item.system.quantity || 1;
|
||||||
|
usedSlots += slots * quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
system.itemSlots.used = usedSlots;
|
||||||
|
|
||||||
|
// Recalculate overburdened status after slot calculation
|
||||||
|
system.itemSlots.overburdened = system.itemSlots.used > system.itemSlots.max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare NPC-specific derived data.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_prepareNPCDerivedData() {
|
||||||
|
// NPC derived data is handled by NPCData.prepareDerivedData()
|
||||||
|
// Add any document-level NPC calculations here if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Roll Data */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the roll data for this actor.
|
||||||
|
* Includes all stats, resources, and item bonuses for use in Roll formulas.
|
||||||
|
*
|
||||||
|
* @override
|
||||||
|
* @returns {Object} Roll data object
|
||||||
|
*/
|
||||||
|
getRollData() {
|
||||||
|
// Start with the system data
|
||||||
|
const data = { ...super.getRollData() };
|
||||||
|
|
||||||
|
// Add actor-level conveniences
|
||||||
|
data.name = this.name;
|
||||||
|
|
||||||
|
// Type-specific roll data is added by the TypeDataModel's getRollData()
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Item Management */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all items of a specific type owned by this actor.
|
||||||
|
*
|
||||||
|
* @param {string} type - The item type to filter by
|
||||||
|
* @returns {VagabondItem[]} Array of matching items
|
||||||
|
*/
|
||||||
|
getItemsByType(type) {
|
||||||
|
return this.items.filter((item) => item.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all equipped weapons.
|
||||||
|
*
|
||||||
|
* @returns {VagabondItem[]} Array of equipped weapon items
|
||||||
|
*/
|
||||||
|
getEquippedWeapons() {
|
||||||
|
return this.items.filter((item) => item.type === "weapon" && item.system.equipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all equipped armor (including shields).
|
||||||
|
*
|
||||||
|
* @returns {VagabondItem[]} Array of equipped armor items
|
||||||
|
*/
|
||||||
|
getEquippedArmor() {
|
||||||
|
return this.items.filter((item) => item.type === "armor" && item.system.equipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the character's class item(s).
|
||||||
|
*
|
||||||
|
* @returns {VagabondItem[]} Array of class items
|
||||||
|
*/
|
||||||
|
getClasses() {
|
||||||
|
return this.items.filter((item) => item.type === "class");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the character's ancestry item.
|
||||||
|
*
|
||||||
|
* @returns {VagabondItem|null} The ancestry item or null
|
||||||
|
*/
|
||||||
|
getAncestry() {
|
||||||
|
return this.items.find((item) => item.type === "ancestry") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all known spells.
|
||||||
|
*
|
||||||
|
* @returns {VagabondItem[]} Array of spell items
|
||||||
|
*/
|
||||||
|
getSpells() {
|
||||||
|
return this.items.filter((item) => item.type === "spell");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all perks.
|
||||||
|
*
|
||||||
|
* @returns {VagabondItem[]} Array of perk items
|
||||||
|
*/
|
||||||
|
getPerks() {
|
||||||
|
return this.items.filter((item) => item.type === "perk");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all features (from class, ancestry, etc.).
|
||||||
|
*
|
||||||
|
* @returns {VagabondItem[]} Array of feature items
|
||||||
|
*/
|
||||||
|
getFeatures() {
|
||||||
|
return this.items.filter((item) => item.type === "feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Resource Management */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modify a resource value (HP, Mana, Luck, etc.).
|
||||||
|
* Handles bounds checking and triggers appropriate hooks.
|
||||||
|
*
|
||||||
|
* @param {string} resource - The resource key (e.g., "hp", "mana", "luck")
|
||||||
|
* @param {number} delta - The amount to change (positive or negative)
|
||||||
|
* @returns {Promise<VagabondActor>} The updated actor
|
||||||
|
*/
|
||||||
|
async modifyResource(resource, delta) {
|
||||||
|
if (this.type !== "character") {
|
||||||
|
// For NPCs, only HP is tracked in the same way
|
||||||
|
if (resource === "hp") {
|
||||||
|
const npcCurrent = this.system.hp.value;
|
||||||
|
const npcMax = this.system.hp.max;
|
||||||
|
const npcNewValue = Math.clamp(npcCurrent + delta, 0, npcMax);
|
||||||
|
return this.update({ "system.hp.value": npcNewValue });
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceData = this.system.resources[resource];
|
||||||
|
if (!resourceData) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`Vagabond | Unknown resource: ${resource}`);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = resourceData.value;
|
||||||
|
const max = resourceData.max || Infinity;
|
||||||
|
const min = resource === "fatigue" ? 0 : 0; // All resources min at 0
|
||||||
|
const newValue = Math.clamp(current + delta, min, max);
|
||||||
|
|
||||||
|
return this.update({ [`system.resources.${resource}.value`]: newValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply damage to this actor.
|
||||||
|
* Reduces HP by the damage amount (after considering armor if applicable).
|
||||||
|
*
|
||||||
|
* @param {number} damage - The raw damage amount
|
||||||
|
* @param {Object} options - Damage options
|
||||||
|
* @param {boolean} options.ignoreArmor - If true, bypass armor reduction
|
||||||
|
* @param {string} options.damageType - The type of damage for resistance checks
|
||||||
|
* @returns {Promise<VagabondActor>} The updated actor
|
||||||
|
*/
|
||||||
|
async applyDamage(damage, options = {}) {
|
||||||
|
const { ignoreArmor = false, damageType = "blunt" } = options;
|
||||||
|
|
||||||
|
let finalDamage = damage;
|
||||||
|
|
||||||
|
// Apply armor reduction for characters (unless ignored)
|
||||||
|
if (this.type === "character" && !ignoreArmor) {
|
||||||
|
const armor = this.system.armor || 0;
|
||||||
|
finalDamage = Math.max(0, damage - armor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For NPCs, check immunities, resistances, and weaknesses
|
||||||
|
if (this.type === "npc") {
|
||||||
|
const system = this.system;
|
||||||
|
|
||||||
|
if (system.immunities?.includes(damageType)) {
|
||||||
|
finalDamage = 0;
|
||||||
|
} else if (system.resistances?.includes(damageType)) {
|
||||||
|
finalDamage = Math.floor(finalDamage / 2);
|
||||||
|
} else if (system.weaknesses?.includes(damageType)) {
|
||||||
|
finalDamage = Math.floor(finalDamage * 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply armor for NPCs
|
||||||
|
if (!ignoreArmor) {
|
||||||
|
const armor = system.armor || 0;
|
||||||
|
finalDamage = Math.max(0, finalDamage - armor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the damage
|
||||||
|
if (this.type === "character") {
|
||||||
|
return this.modifyResource("hp", -finalDamage);
|
||||||
|
}
|
||||||
|
const newHP = Math.max(0, this.system.hp.value - finalDamage);
|
||||||
|
return this.update({ "system.hp.value": newHP });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heal this actor.
|
||||||
|
*
|
||||||
|
* @param {number} amount - The amount to heal
|
||||||
|
* @returns {Promise<VagabondActor>} The updated actor
|
||||||
|
*/
|
||||||
|
async applyHealing(amount) {
|
||||||
|
if (this.type === "character") {
|
||||||
|
return this.modifyResource("hp", amount);
|
||||||
|
}
|
||||||
|
const max = this.system.hp.max;
|
||||||
|
const newHP = Math.min(max, this.system.hp.value + amount);
|
||||||
|
return this.update({ "system.hp.value": newHP });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spend mana for spellcasting.
|
||||||
|
*
|
||||||
|
* @param {number} cost - The mana cost
|
||||||
|
* @returns {Promise<boolean>} True if mana was spent, false if insufficient
|
||||||
|
*/
|
||||||
|
async spendMana(cost) {
|
||||||
|
if (this.type !== "character") return false;
|
||||||
|
|
||||||
|
const current = this.system.resources.mana.value;
|
||||||
|
if (current < cost) return false;
|
||||||
|
|
||||||
|
await this.modifyResource("mana", -cost);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spend a luck point.
|
||||||
|
*
|
||||||
|
* @returns {Promise<boolean>} True if luck was spent, false if none available
|
||||||
|
*/
|
||||||
|
async spendLuck() {
|
||||||
|
if (this.type !== "character") return false;
|
||||||
|
|
||||||
|
const current = this.system.resources.luck.value;
|
||||||
|
if (current < 1) return false;
|
||||||
|
|
||||||
|
await this.modifyResource("luck", -1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add fatigue to the character.
|
||||||
|
* Death occurs at 5 fatigue.
|
||||||
|
*
|
||||||
|
* @param {number} amount - Fatigue to add (default 1)
|
||||||
|
* @returns {Promise<VagabondActor>} The updated actor
|
||||||
|
*/
|
||||||
|
async addFatigue(amount = 1) {
|
||||||
|
if (this.type !== "character") return this;
|
||||||
|
|
||||||
|
const current = this.system.resources.fatigue.value;
|
||||||
|
const newValue = Math.min(5, current + amount);
|
||||||
|
|
||||||
|
// Check for death at 5 fatigue
|
||||||
|
if (newValue >= 5) {
|
||||||
|
// TODO: Trigger death state
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("Vagabond | Character has died from fatigue!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.update({ "system.resources.fatigue.value": newValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Rest & Recovery */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a short rest (breather).
|
||||||
|
* Recovers some HP based on Might.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Object>} Results of the rest
|
||||||
|
*/
|
||||||
|
async takeBreather() {
|
||||||
|
if (this.type !== "character") return { recovered: 0 };
|
||||||
|
|
||||||
|
const might = this.system.stats.might.value;
|
||||||
|
const currentHP = this.system.resources.hp.value;
|
||||||
|
const maxHP = this.system.resources.hp.max;
|
||||||
|
|
||||||
|
// Recover Might HP
|
||||||
|
const recovered = Math.min(might, maxHP - currentHP);
|
||||||
|
await this.modifyResource("hp", recovered);
|
||||||
|
|
||||||
|
// Track breathers taken
|
||||||
|
const breathersTaken = (this.system.restTracking?.breathersTaken || 0) + 1;
|
||||||
|
await this.update({ "system.restTracking.breathersTaken": breathersTaken });
|
||||||
|
|
||||||
|
return { recovered, breathersTaken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a full rest.
|
||||||
|
* Recovers all HP, Mana, Luck; reduces Fatigue by 1.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Object>} Results of the rest
|
||||||
|
*/
|
||||||
|
async takeFullRest() {
|
||||||
|
if (this.type !== "character") return {};
|
||||||
|
|
||||||
|
const system = this.system;
|
||||||
|
const updates = {};
|
||||||
|
|
||||||
|
// Restore HP to max
|
||||||
|
updates["system.resources.hp.value"] = system.resources.hp.max;
|
||||||
|
|
||||||
|
// Restore Mana to max
|
||||||
|
updates["system.resources.mana.value"] = system.resources.mana.max;
|
||||||
|
|
||||||
|
// Restore Luck to max
|
||||||
|
updates["system.resources.luck.value"] = system.resources.luck.max;
|
||||||
|
|
||||||
|
// Reduce Fatigue by 1 (minimum 0)
|
||||||
|
const newFatigue = Math.max(0, system.resources.fatigue.value - 1);
|
||||||
|
updates["system.resources.fatigue.value"] = newFatigue;
|
||||||
|
|
||||||
|
// Reset breathers counter
|
||||||
|
updates["system.restTracking.breathersTaken"] = 0;
|
||||||
|
|
||||||
|
// Track last rest time
|
||||||
|
updates["system.restTracking.lastRest"] = new Date().toISOString();
|
||||||
|
|
||||||
|
await this.update(updates);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hpRestored: system.resources.hp.max,
|
||||||
|
manaRestored: system.resources.mana.max,
|
||||||
|
luckRestored: system.resources.luck.max,
|
||||||
|
fatigueReduced: system.resources.fatigue.value > 0 ? 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Combat Helpers */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this actor is dead (HP <= 0 or Fatigue >= 5).
|
||||||
|
*
|
||||||
|
* @returns {boolean} True if the actor is dead
|
||||||
|
*/
|
||||||
|
get isDead() {
|
||||||
|
if (this.type === "character") {
|
||||||
|
return (
|
||||||
|
this.system.resources.hp.value <= 0 ||
|
||||||
|
this.system.resources.fatigue.value >= 5 ||
|
||||||
|
this.system.death?.isDead
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.system.hp.value <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this NPC should make a morale check.
|
||||||
|
*
|
||||||
|
* @returns {boolean} True if morale should be checked
|
||||||
|
*/
|
||||||
|
shouldCheckMorale() {
|
||||||
|
if (this.type !== "npc") return false;
|
||||||
|
return this.system.shouldCheckMorale?.() || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the net favor/hinder for a specific roll type.
|
||||||
|
* Favor and Hinder cancel 1-for-1.
|
||||||
|
*
|
||||||
|
* @param {string} rollType - The type of roll (e.g., "Attack Checks", "Reflex Saves")
|
||||||
|
* @returns {number} Net modifier: positive = favor, negative = hinder, 0 = neutral
|
||||||
|
*/
|
||||||
|
getNetFavorHinder(rollType) {
|
||||||
|
if (this.type !== "character") return 0;
|
||||||
|
|
||||||
|
const favorHinder = this.system.favorHinder;
|
||||||
|
if (!favorHinder) return 0;
|
||||||
|
|
||||||
|
// Count favor sources that apply to this roll type
|
||||||
|
const favorCount = (favorHinder.favor || []).filter(
|
||||||
|
(f) => !f.appliesTo?.length || f.appliesTo.includes(rollType)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Count hinder sources that apply to this roll type
|
||||||
|
const hinderCount = (favorHinder.hinder || []).filter(
|
||||||
|
(h) => !h.appliesTo?.length || h.appliesTo.includes(rollType)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// They cancel 1-for-1, max of +1 or -1
|
||||||
|
const net = favorCount - hinderCount;
|
||||||
|
return Math.clamp(net, -1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
542
module/documents/item.mjs
Normal file
542
module/documents/item.mjs
Normal file
@ -0,0 +1,542 @@
|
|||||||
|
/**
|
||||||
|
* VagabondItem Document Class
|
||||||
|
*
|
||||||
|
* Extended Item document for Vagabond RPG system.
|
||||||
|
* Provides document-level functionality including:
|
||||||
|
* - Chat card generation for items
|
||||||
|
* - Roll methods for weapons and spells
|
||||||
|
* - Usage tracking for consumables and limited-use items
|
||||||
|
*
|
||||||
|
* Data models handle schema and base calculations.
|
||||||
|
* This class handles document operations and Foundry integration.
|
||||||
|
*
|
||||||
|
* @extends Item
|
||||||
|
*/
|
||||||
|
export default class VagabondItem extends Item {
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Data Preparation */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare data for the item.
|
||||||
|
*
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
prepareData() {
|
||||||
|
super.prepareData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare derived data for the item.
|
||||||
|
*
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
prepareDerivedData() {
|
||||||
|
super.prepareDerivedData();
|
||||||
|
|
||||||
|
// Type-specific preparation
|
||||||
|
switch (this.type) {
|
||||||
|
case "spell":
|
||||||
|
this._prepareSpellData();
|
||||||
|
break;
|
||||||
|
case "weapon":
|
||||||
|
this._prepareWeaponData();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare spell-specific derived data.
|
||||||
|
* Pre-calculates mana costs for common configurations.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_prepareSpellData() {
|
||||||
|
const system = this.system;
|
||||||
|
if (!system) return;
|
||||||
|
|
||||||
|
// Calculate base mana cost (damage dice count)
|
||||||
|
// Full formula: base dice + delivery cost + duration modifier
|
||||||
|
// This will be calculated dynamically in the cast dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare weapon-specific derived data.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_prepareWeaponData() {
|
||||||
|
const system = this.system;
|
||||||
|
if (!system) return;
|
||||||
|
|
||||||
|
// Determine attack skill based on properties
|
||||||
|
if (!system.attackSkill) {
|
||||||
|
if (system.properties?.includes("finesse")) {
|
||||||
|
system.attackSkill = "finesse";
|
||||||
|
} else if (system.properties?.includes("brawl")) {
|
||||||
|
system.attackSkill = "brawl";
|
||||||
|
} else if (system.gripType === "ranged" || system.properties?.includes("thrown")) {
|
||||||
|
system.attackSkill = "ranged";
|
||||||
|
} else {
|
||||||
|
system.attackSkill = "melee";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Roll Data */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the roll data for this item.
|
||||||
|
* Includes item stats and owner's roll data if applicable.
|
||||||
|
*
|
||||||
|
* @override
|
||||||
|
* @returns {Object} Roll data object
|
||||||
|
*/
|
||||||
|
getRollData() {
|
||||||
|
const data = { ...this.system };
|
||||||
|
|
||||||
|
// Include owner's roll data if this item belongs to an actor
|
||||||
|
if (this.actor) {
|
||||||
|
data.actor = this.actor.getRollData();
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Chat Card Generation */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the item in chat as a card.
|
||||||
|
* Shows item details and provides roll buttons where applicable.
|
||||||
|
*
|
||||||
|
* @param {Object} options - Chat message options
|
||||||
|
* @returns {Promise<ChatMessage>} The created chat message
|
||||||
|
*/
|
||||||
|
async toChat(options = {}) {
|
||||||
|
const speaker = ChatMessage.getSpeaker({ actor: this.actor });
|
||||||
|
|
||||||
|
// Build chat card content based on item type
|
||||||
|
const content = await this._getChatCardContent();
|
||||||
|
|
||||||
|
const chatData = {
|
||||||
|
user: game.user.id,
|
||||||
|
speaker,
|
||||||
|
content,
|
||||||
|
flavor: this.name,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ChatMessage.create(chatData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate HTML content for the item's chat card.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @returns {Promise<string>} HTML content
|
||||||
|
*/
|
||||||
|
async _getChatCardContent() {
|
||||||
|
const data = {
|
||||||
|
item: this,
|
||||||
|
system: this.system,
|
||||||
|
actor: this.actor,
|
||||||
|
isOwner: this.isOwner,
|
||||||
|
config: CONFIG.VAGABOND,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use type-specific template if available, otherwise generic
|
||||||
|
const templatePath = `systems/vagabond/templates/chat/${this.type}-card.hbs`;
|
||||||
|
const genericPath = "systems/vagabond/templates/chat/item-card.hbs";
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await renderTemplate(templatePath, data);
|
||||||
|
} catch {
|
||||||
|
// Fall back to generic template
|
||||||
|
try {
|
||||||
|
return await renderTemplate(genericPath, data);
|
||||||
|
} catch {
|
||||||
|
// If no templates exist yet, return basic HTML
|
||||||
|
return this._getBasicChatCardHTML();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate basic HTML for chat card when templates aren't available.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @returns {string} Basic HTML content
|
||||||
|
*/
|
||||||
|
_getBasicChatCardHTML() {
|
||||||
|
const system = this.system;
|
||||||
|
let content = `<div class="vagabond chat-card item-card">`;
|
||||||
|
content += `<header class="card-header"><h3>${this.name}</h3></header>`;
|
||||||
|
content += `<div class="card-content">`;
|
||||||
|
|
||||||
|
// Type-specific details
|
||||||
|
switch (this.type) {
|
||||||
|
case "weapon":
|
||||||
|
content += `<p><strong>Damage:</strong> ${system.damage || "1d6"}</p>`;
|
||||||
|
if (system.properties?.length) {
|
||||||
|
content += `<p><strong>Properties:</strong> ${system.properties.join(", ")}</p>`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "armor":
|
||||||
|
content += `<p><strong>Armor:</strong> ${system.armorValue || 0}</p>`;
|
||||||
|
content += `<p><strong>Type:</strong> ${system.armorType || "light"}</p>`;
|
||||||
|
break;
|
||||||
|
case "spell":
|
||||||
|
content += `<p><strong>Base Cost:</strong> ${system.baseCost || 1} Mana</p>`;
|
||||||
|
if (system.effect) {
|
||||||
|
content += `<p><strong>Effect:</strong> ${system.effect}</p>`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "perk":
|
||||||
|
if (system.prerequisites?.length) {
|
||||||
|
content += `<p><strong>Prerequisites:</strong></p>`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
if (system.description) {
|
||||||
|
content += `<div class="card-description">${system.description}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
content += `</div></div>`;
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Item Actions */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use the item (attack with weapon, cast spell, use consumable).
|
||||||
|
* Opens appropriate dialog based on item type.
|
||||||
|
*
|
||||||
|
* @param {Object} options - Usage options
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async use(options = {}) {
|
||||||
|
if (!this.actor) {
|
||||||
|
ui.notifications.warn("This item must be owned by an actor to be used.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (this.type) {
|
||||||
|
case "weapon":
|
||||||
|
return this._useWeapon(options);
|
||||||
|
case "spell":
|
||||||
|
return this._useSpell(options);
|
||||||
|
case "equipment":
|
||||||
|
if (this.system.consumable) {
|
||||||
|
return this._useConsumable(options);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "feature":
|
||||||
|
if (!this.system.passive) {
|
||||||
|
return this._useFeature(options);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: just post to chat
|
||||||
|
return this.toChat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attack with this weapon.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param {Object} options - Attack options
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async _useWeapon(_options = {}) {
|
||||||
|
// TODO: Implement attack roll dialog (Phase 2.6)
|
||||||
|
// For now, just post to chat
|
||||||
|
await this.toChat();
|
||||||
|
|
||||||
|
// Placeholder for attack roll
|
||||||
|
const attackSkill = this.system.attackSkill || "melee";
|
||||||
|
ui.notifications.info(`Attack with ${this.name} using ${attackSkill} skill`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cast this spell.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param {Object} options - Casting options
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async _useSpell(_options = {}) {
|
||||||
|
// TODO: Implement spell casting dialog (Phase 2.8)
|
||||||
|
// For now, just post to chat
|
||||||
|
await this.toChat();
|
||||||
|
|
||||||
|
// Placeholder for spell cast
|
||||||
|
const baseCost = this.system.baseCost || 1;
|
||||||
|
ui.notifications.info(`Casting ${this.name} (Base cost: ${baseCost} Mana)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use a consumable item.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param {Object} options - Usage options
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async _useConsumable(_options = {}) {
|
||||||
|
const quantity = this.system.quantity || 1;
|
||||||
|
|
||||||
|
if (quantity <= 0) {
|
||||||
|
ui.notifications.warn(`No ${this.name} remaining!`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post to chat
|
||||||
|
await this.toChat();
|
||||||
|
|
||||||
|
// Reduce quantity
|
||||||
|
const newQuantity = quantity - 1;
|
||||||
|
await this.update({ "system.quantity": newQuantity });
|
||||||
|
|
||||||
|
if (newQuantity <= 0) {
|
||||||
|
ui.notifications.info(`Used last ${this.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use an active feature.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param {Object} options - Usage options
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async _useFeature(_options = {}) {
|
||||||
|
// Check if feature has uses
|
||||||
|
if (this.system.uses) {
|
||||||
|
const current = this.system.uses.value || 0;
|
||||||
|
const max = this.system.uses.max || 0;
|
||||||
|
|
||||||
|
if (max > 0 && current <= 0) {
|
||||||
|
ui.notifications.warn(`No uses of ${this.name} remaining!`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post to chat
|
||||||
|
await this.toChat();
|
||||||
|
|
||||||
|
// Reduce uses
|
||||||
|
if (max > 0) {
|
||||||
|
await this.update({ "system.uses.value": current - 1 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No use tracking, just post to chat
|
||||||
|
await this.toChat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Spell Helpers */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the mana cost for a spell with given options.
|
||||||
|
*
|
||||||
|
* @param {Object} options - Casting options
|
||||||
|
* @param {number} options.extraDice - Additional damage dice
|
||||||
|
* @param {string} options.delivery - Delivery type
|
||||||
|
* @param {string} options.duration - Duration type
|
||||||
|
* @returns {number} Total mana cost
|
||||||
|
*/
|
||||||
|
calculateManaCost(options = {}) {
|
||||||
|
if (this.type !== "spell") return 0;
|
||||||
|
|
||||||
|
const system = this.system;
|
||||||
|
const { extraDice = 0, delivery = "touch" } = options;
|
||||||
|
// Note: duration affects Focus mechanics but not mana cost directly
|
||||||
|
|
||||||
|
// Base cost is number of damage dice
|
||||||
|
const baseDice = system.baseDamageDice || 1;
|
||||||
|
let cost = baseDice + extraDice;
|
||||||
|
|
||||||
|
// Add delivery cost
|
||||||
|
const deliveryCosts = CONFIG.VAGABOND?.spellDelivery || {};
|
||||||
|
const deliveryData = deliveryCosts[delivery];
|
||||||
|
if (deliveryData) {
|
||||||
|
cost += deliveryData.cost || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration doesn't add cost, but Focus duration has ongoing effects
|
||||||
|
|
||||||
|
return Math.max(1, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available delivery types for this spell.
|
||||||
|
*
|
||||||
|
* @returns {string[]} Array of valid delivery type keys
|
||||||
|
*/
|
||||||
|
getValidDeliveryTypes() {
|
||||||
|
if (this.type !== "spell") return [];
|
||||||
|
|
||||||
|
const validTypes = this.system.validDeliveryTypes || [];
|
||||||
|
if (validTypes.length === 0) {
|
||||||
|
// Default to touch and remote
|
||||||
|
return ["touch", "remote"];
|
||||||
|
}
|
||||||
|
return validTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Perk Helpers */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this perk's prerequisites are met by an actor.
|
||||||
|
*
|
||||||
|
* @param {VagabondActor} actor - The actor to check against
|
||||||
|
* @returns {Object} Result with met (boolean) and missing (array of unmet prereqs)
|
||||||
|
*/
|
||||||
|
checkPrerequisites(actor) {
|
||||||
|
if (this.type !== "perk" || !actor) {
|
||||||
|
return { met: true, missing: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prereqs = this.system.prerequisites || [];
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
for (const prereq of prereqs) {
|
||||||
|
let met = false;
|
||||||
|
|
||||||
|
switch (prereq.type) {
|
||||||
|
case "stat": {
|
||||||
|
// Check stat minimum
|
||||||
|
const statValue = actor.system.stats?.[prereq.stat]?.value || 0;
|
||||||
|
met = statValue >= (prereq.value || 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "training": {
|
||||||
|
// Check if trained in skill
|
||||||
|
const skillData = actor.system.skills?.[prereq.skill];
|
||||||
|
met = skillData?.trained === true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "spell": {
|
||||||
|
// Check if actor knows the spell
|
||||||
|
const knownSpells = actor.getSpells?.() || [];
|
||||||
|
met = knownSpells.some((s) => s.name === prereq.spellName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "perk": {
|
||||||
|
// Check if actor has the prerequisite perk
|
||||||
|
const perks = actor.getPerks?.() || [];
|
||||||
|
met = perks.some((p) => p.name === prereq.perkName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "level":
|
||||||
|
// Check minimum level
|
||||||
|
met = (actor.system.level || 1) >= (prereq.value || 1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "class": {
|
||||||
|
// Check if actor has the class
|
||||||
|
const classes = actor.getClasses?.() || [];
|
||||||
|
met = classes.some((c) => c.name === prereq.className);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!met) {
|
||||||
|
missing.push(prereq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
met: missing.length === 0,
|
||||||
|
missing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Class Helpers */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get features granted at a specific level for this class.
|
||||||
|
*
|
||||||
|
* @param {number} level - The level to check
|
||||||
|
* @returns {Object[]} Array of feature definitions
|
||||||
|
*/
|
||||||
|
getFeaturesAtLevel(level) {
|
||||||
|
if (this.type !== "class") return [];
|
||||||
|
|
||||||
|
const progression = this.system.progression || [];
|
||||||
|
const levelData = progression.find((p) => p.level === level);
|
||||||
|
|
||||||
|
return levelData?.features || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cumulative features up to and including a level.
|
||||||
|
*
|
||||||
|
* @param {number} level - The maximum level
|
||||||
|
* @returns {Object[]} Array of all features up to this level
|
||||||
|
*/
|
||||||
|
getAllFeaturesUpToLevel(level) {
|
||||||
|
if (this.type !== "class") return [];
|
||||||
|
|
||||||
|
const progression = this.system.progression || [];
|
||||||
|
const features = [];
|
||||||
|
|
||||||
|
for (const levelData of progression) {
|
||||||
|
if (levelData.level <= level) {
|
||||||
|
features.push(...(levelData.features || []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Equipment Helpers */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the equipped state of this item.
|
||||||
|
*
|
||||||
|
* @returns {Promise<VagabondItem>} The updated item
|
||||||
|
*/
|
||||||
|
async toggleEquipped() {
|
||||||
|
if (!["weapon", "armor", "equipment"].includes(this.type)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipped = !this.system.equipped;
|
||||||
|
return this.update({ "system.equipped": equipped });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the total value of this item in copper pieces.
|
||||||
|
*
|
||||||
|
* @returns {number} Value in copper
|
||||||
|
*/
|
||||||
|
getValueInCopper() {
|
||||||
|
const value = this.system.value || {};
|
||||||
|
const gold = value.gold || 0;
|
||||||
|
const silver = value.silver || 0;
|
||||||
|
const copper = value.copper || 0;
|
||||||
|
|
||||||
|
// 1 gold = 10 silver = 100 copper
|
||||||
|
return gold * 100 + silver * 10 + copper;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -53,19 +53,77 @@ export function registerActorTests(quenchRunner) {
|
|||||||
expect(testActor.system.resources.hp.max).to.equal(15); // 5 × 3
|
expect(testActor.system.resources.hp.max).to.equal(15); // 5 × 3
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calculates Speed based on Dexterity", async () => {
|
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
|
// DEX 4 = 30 ft speed
|
||||||
expect(testActor.system.speed.value).to.equal(30);
|
expect(testActor.system.speed.walk).to.equal(30);
|
||||||
|
|
||||||
await testActor.update({ "system.stats.dexterity.value": 6 });
|
await testActor.update({ "system.stats.dexterity.value": 6 });
|
||||||
expect(testActor.system.speed.value).to.equal(35);
|
expect(testActor.system.speed.walk).to.equal(35);
|
||||||
|
|
||||||
await testActor.update({ "system.stats.dexterity.value": 2 });
|
await testActor.update({ "system.stats.dexterity.value": 2 });
|
||||||
expect(testActor.system.speed.value).to.equal(25);
|
expect(testActor.system.speed.walk).to.equal(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calculates Item Slots as 8 + Might", async () => {
|
it("applies speed bonus to walking speed", async () => {
|
||||||
expect(testActor.system.itemSlots.max).to.equal(13); // 8 + 5
|
/**
|
||||||
|
* 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 () => {
|
it("calculates Save difficulties correctly", async () => {
|
||||||
@ -94,18 +152,165 @@ export function registerActorTests(quenchRunner) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Resource Tracking", () => {
|
describe("Resource Tracking", () => {
|
||||||
it("tracks Fatigue from 0 to 5", async () => {
|
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.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 });
|
await testActor.update({ "system.resources.fatigue.value": 3 });
|
||||||
expect(testActor.system.resources.fatigue.value).to.equal(3);
|
expect(testActor.system.resources.fatigue.value).to.equal(3);
|
||||||
|
|
||||||
// Fatigue reduces item slots
|
// Fatigue reduces item slots
|
||||||
expect(testActor.system.itemSlots.max).to.equal(10); // 13 - 3
|
expect(testActor.system.itemSlots.max).to.equal(10); // 8 + 5 - 3
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks Current Luck up to Luck stat", async () => {
|
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);
|
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("tracks favor and hinder modifiers separately", async () => {
|
||||||
|
/**
|
||||||
|
* Favor adds +d6 to rolls, Hinder adds -d6.
|
||||||
|
* They cancel 1-for-1 and don't stack (multiple favors = still +1d6).
|
||||||
|
* Each entry tracks: source, appliesTo (what rolls), duration.
|
||||||
|
*/
|
||||||
|
await testActor.update({
|
||||||
|
"system.favorHinder.favor": [
|
||||||
|
{
|
||||||
|
source: "Flanking",
|
||||||
|
appliesTo: ["Attack Checks"],
|
||||||
|
duration: "until-next-turn",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"system.favorHinder.hinder": [
|
||||||
|
{
|
||||||
|
source: "Heavy Armor",
|
||||||
|
appliesTo: ["Dodge Saves"],
|
||||||
|
duration: "permanent",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testActor.system.favorHinder.favor.length).to.equal(1);
|
||||||
|
expect(testActor.system.favorHinder.hinder.length).to.equal(1);
|
||||||
|
expect(testActor.system.favorHinder.favor[0].source).to.equal("Flanking");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -143,22 +348,139 @@ export function registerActorTests(quenchRunner) {
|
|||||||
|
|
||||||
describe("NPC Stats", () => {
|
describe("NPC Stats", () => {
|
||||||
it("stores HD and HP independently", async () => {
|
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.hd).to.equal(1);
|
||||||
expect(testNPC.system.hp.max).to.equal(4);
|
expect(testNPC.system.hp.max).to.equal(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores Threat Level (TL)", async () => {
|
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);
|
expect(testNPC.system.tl).to.equal(0.8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores Zone behavior", async () => {
|
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");
|
expect(testNPC.system.zone).to.equal("frontline");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores Morale score", async () => {
|
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);
|
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" }
|
{ displayName: "Vagabond: NPC Actors" }
|
||||||
);
|
);
|
||||||
|
|||||||
632
module/tests/dice.test.mjs
Normal file
632
module/tests/dice.test.mjs
Normal file
@ -0,0 +1,632 @@
|
|||||||
|
/**
|
||||||
|
* 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" }
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,9 +8,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Import test modules
|
// Import test modules
|
||||||
// import { registerActorTests } from "./actor.test.mjs";
|
import { registerActorTests } from "./actor.test.mjs";
|
||||||
|
import { registerDiceTests } from "./dice.test.mjs";
|
||||||
// import { registerItemTests } from "./item.test.mjs";
|
// import { registerItemTests } from "./item.test.mjs";
|
||||||
// import { registerRollTests } from "./rolls.test.mjs";
|
|
||||||
// import { registerEffectTests } from "./effects.test.mjs";
|
// import { registerEffectTests } from "./effects.test.mjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -63,10 +63,10 @@ export function registerQuenchTests(quenchRunner) {
|
|||||||
{ displayName: "Vagabond: Sanity Checks" }
|
{ displayName: "Vagabond: Sanity Checks" }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register domain-specific test batches (uncomment as implemented)
|
// Register domain-specific test batches
|
||||||
// registerActorTests(quenchRunner);
|
registerActorTests(quenchRunner);
|
||||||
|
registerDiceTests(quenchRunner);
|
||||||
// registerItemTests(quenchRunner);
|
// registerItemTests(quenchRunner);
|
||||||
// registerRollTests(quenchRunner);
|
|
||||||
// registerEffectTests(quenchRunner);
|
// registerEffectTests(quenchRunner);
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
|
|||||||
@ -20,8 +20,7 @@ import {
|
|||||||
} from "./data/item/_module.mjs";
|
} from "./data/item/_module.mjs";
|
||||||
|
|
||||||
// Import document classes
|
// Import document classes
|
||||||
// import { VagabondActor } from "./documents/actor.mjs";
|
import { VagabondActor, VagabondItem } from "./documents/_module.mjs";
|
||||||
// import { VagabondItem } from "./documents/item.mjs";
|
|
||||||
|
|
||||||
// Import sheet classes
|
// Import sheet classes
|
||||||
// import { VagabondCharacterSheet } from "./sheets/actor-sheet.mjs";
|
// import { VagabondCharacterSheet } from "./sheets/actor-sheet.mjs";
|
||||||
@ -65,9 +64,9 @@ Hooks.once("init", () => {
|
|||||||
feature: FeatureData,
|
feature: FeatureData,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Define custom Document classes (for future use)
|
// Define custom Document classes
|
||||||
// CONFIG.Actor.documentClass = VagabondActor;
|
CONFIG.Actor.documentClass = VagabondActor;
|
||||||
// CONFIG.Item.documentClass = VagabondItem;
|
CONFIG.Item.documentClass = VagabondItem;
|
||||||
|
|
||||||
// Register sheet application classes (TODO: Phase 3-4)
|
// Register sheet application classes (TODO: Phase 3-4)
|
||||||
// Actors.unregisterSheet("core", ActorSheet);
|
// Actors.unregisterSheet("core", ActorSheet);
|
||||||
|
|||||||
47
system.json
47
system.json
@ -121,6 +121,47 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"documentTypes": {
|
||||||
|
"Actor": {
|
||||||
|
"character": {
|
||||||
|
"htmlFields": [
|
||||||
|
"biography.appearance",
|
||||||
|
"biography.background",
|
||||||
|
"biography.personality",
|
||||||
|
"biography.notes"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"npc": {
|
||||||
|
"htmlFields": ["abilities.description", "loot", "gmNotes"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Item": {
|
||||||
|
"ancestry": {
|
||||||
|
"htmlFields": ["description"]
|
||||||
|
},
|
||||||
|
"class": {
|
||||||
|
"htmlFields": ["description"]
|
||||||
|
},
|
||||||
|
"spell": {
|
||||||
|
"htmlFields": ["description"]
|
||||||
|
},
|
||||||
|
"perk": {
|
||||||
|
"htmlFields": ["description"]
|
||||||
|
},
|
||||||
|
"weapon": {
|
||||||
|
"htmlFields": ["description", "relic.abilityDescription", "relic.lore"]
|
||||||
|
},
|
||||||
|
"armor": {
|
||||||
|
"htmlFields": ["description", "relic.abilityDescription", "relic.lore"]
|
||||||
|
},
|
||||||
|
"equipment": {
|
||||||
|
"htmlFields": ["description"]
|
||||||
|
},
|
||||||
|
"feature": {
|
||||||
|
"htmlFields": ["description"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"socket": false,
|
"socket": false,
|
||||||
"url": "https://github.com/calcorum/vagabond-rpg-foundryvtt",
|
"url": "https://github.com/calcorum/vagabond-rpg-foundryvtt",
|
||||||
"manifest": "https://github.com/calcorum/vagabond-rpg-foundryvtt/releases/latest/download/system.json",
|
"manifest": "https://github.com/calcorum/vagabond-rpg-foundryvtt/releases/latest/download/system.json",
|
||||||
@ -131,6 +172,8 @@
|
|||||||
"changelog": "CHANGELOG.md",
|
"changelog": "CHANGELOG.md",
|
||||||
"primaryTokenAttribute": "resources.hp",
|
"primaryTokenAttribute": "resources.hp",
|
||||||
"secondaryTokenAttribute": "resources.mana",
|
"secondaryTokenAttribute": "resources.mana",
|
||||||
"gridDistance": 5,
|
"grid": {
|
||||||
"gridUnits": "ft"
|
"distance": 5,
|
||||||
|
"units": "ft"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user