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:
Cal Corum 2025-12-13 10:21:48 -06:00
parent c06192f90f
commit 517b7045c7
14 changed files with 2541 additions and 42 deletions

View File

@ -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 }),

View File

@ -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 }),

View File

@ -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"],
}), }),
}), }),

View File

@ -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
View 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
View 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);
}

View 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
View 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
View 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;
}
}

View File

@ -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
View 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" }
);
}

View File

@ -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

View File

@ -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);

View File

@ -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"
}
} }