diff --git a/module/data/actor/character.mjs b/module/data/actor/character.mjs index abe24f8..82a138a 100644 --- a/module/data/actor/character.mjs +++ b/module/data/actor/character.mjs @@ -229,10 +229,13 @@ export default class CharacterData extends VagabondActorBase { }), // Subtype for specific mechanics (formulae, marked-target, crit-reduction, etc.) subtype: new fields.StringField({ required: false, blank: true }), - // When this resource resets + // When this resource resets (null = manual/never) resetOn: new fields.StringField({ - initial: "", - choices: ["", "rest", "turn", "round", "day", "combat"], + required: false, + nullable: true, + blank: false, + initial: null, + choices: ["rest", "turn", "round", "day", "combat"], }), // Flexible data storage for complex resources (formulae lists, target IDs, etc.) data: new fields.ObjectField({ initial: {} }), @@ -498,10 +501,13 @@ export default class CharacterData extends VagabondActorBase { flankingAllies: new fields.ArrayField(new fields.StringField(), { initial: [] }), // Ignores flanking penalties (Situational Awareness perk) ignoresFlankingPenalty: new fields.BooleanField({ initial: false }), - // Current combat zone + // Current combat zone (nullable - not in combat if null) currentZone: new fields.StringField({ - initial: "", - choices: ["", "frontline", "midline", "backline"], + required: false, + nullable: true, + blank: false, + initial: null, + choices: ["frontline", "midline", "backline"], }), // Is dual-wielding? isDualWielding: new fields.BooleanField({ initial: false }), @@ -548,8 +554,11 @@ export default class CharacterData extends VagabondActorBase { death: new fields.SchemaField({ isDead: new fields.BooleanField({ initial: false }), deathCause: new fields.StringField({ - initial: "", - choices: ["", "hp-zero", "body-destroyed", "fatigue-five"], + required: false, + nullable: true, + blank: false, + initial: null, + choices: ["hp-zero", "body-destroyed", "fatigue-five"], }), canBeRevived: new fields.BooleanField({ initial: true }), revivedCount: new fields.NumberField({ integer: true, initial: 0, min: 0 }), diff --git a/module/data/actor/npc.mjs b/module/data/actor/npc.mjs index cec8d6d..8180c6a 100644 --- a/module/data/actor/npc.mjs +++ b/module/data/actor/npc.mjs @@ -79,13 +79,19 @@ export default class NPCData extends VagabondActorBase { checkedThisCombat: new fields.BooleanField({ initial: false }), // What triggered the last check? lastTrigger: new fields.StringField({ - initial: "", - choices: ["", "first-death", "half-hp", "half-incapacitated", "leader-death"], + required: false, + nullable: true, + blank: false, + initial: null, + choices: ["first-death", "half-hp", "half-incapacitated", "leader-death"], }), // Result of the last morale check lastResult: new fields.StringField({ - initial: "", - choices: ["", "passed", "failed-retreat", "failed-surrender"], + required: false, + nullable: true, + blank: false, + initial: null, + choices: ["passed", "failed-retreat", "failed-surrender"], }), // Is this NPC currently fleeing/surrendered? broken: new fields.BooleanField({ initial: false }), diff --git a/module/data/item/feature.mjs b/module/data/item/feature.mjs index 1285659..9e42606 100644 --- a/module/data/item/feature.mjs +++ b/module/data/item/feature.mjs @@ -43,8 +43,11 @@ export default class FeatureData extends VagabondItemBase { // For active features: activation type activation: new fields.SchemaField({ type: new fields.StringField({ - initial: "", - choices: ["", "action", "bonus", "reaction", "free", "special"], + required: false, + nullable: true, + blank: false, + initial: null, + choices: ["action", "bonus", "reaction", "free", "special"], }), 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 }), max: new fields.NumberField({ integer: true, initial: 0 }), per: new fields.StringField({ - initial: "", - choices: ["", "short", "long", "day", "encounter"], + required: false, + nullable: true, + blank: false, + initial: null, + choices: ["short", "long", "day", "encounter"], }), }), diff --git a/module/data/item/weapon.mjs b/module/data/item/weapon.mjs index 668b861..50f9a42 100644 --- a/module/data/item/weapon.mjs +++ b/module/data/item/weapon.mjs @@ -107,10 +107,13 @@ export default class WeaponData extends VagabondItemBase { // Is this weapon equipped? 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({ - initial: "", - choices: ["", "main", "off", "both"], + required: false, + nullable: true, + blank: false, + initial: null, + choices: ["main", "off", "both"], }), // Quantity (for ammunition, thrown weapons) diff --git a/module/dice/_module.mjs b/module/dice/_module.mjs new file mode 100644 index 0000000..90ce78f --- /dev/null +++ b/module/dice/_module.mjs @@ -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"; diff --git a/module/dice/rolls.mjs b/module/dice/rolls.mjs new file mode 100644 index 0000000..730c6e3 --- /dev/null +++ b/module/dice/rolls.mjs @@ -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} 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} 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} 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} 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} 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} 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} 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} 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} 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} The created chat message + */ +export async function sendRollToChat(result, options = {}) { + const { flavor = "Roll", speaker } = options; + + // Build the message content + let content = `
`; + + // Success/failure indicator + if (result.success !== undefined) { + const successClass = result.success ? "success" : "failure"; + const successText = result.success ? "Success" : "Failure"; + content += `
${successText}
`; + } + + // Crit indicator + if (result.isCrit) { + content += `
Critical!
`; + } + + // Fumble indicator + if (result.isFumble) { + content += `
Fumble!
`; + } + + // Roll details + content += `
`; + content += `Total: ${result.total}`; + if (result.difficulty !== undefined) { + content += ` vs DC ${result.difficulty}`; + } + content += `
`; + + content += `
`; + + // 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); +} diff --git a/module/documents/_module.mjs b/module/documents/_module.mjs new file mode 100644 index 0000000..0036806 --- /dev/null +++ b/module/documents/_module.mjs @@ -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"; diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs new file mode 100644 index 0000000..b0a00fd --- /dev/null +++ b/module/documents/actor.mjs @@ -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} 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} 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} 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} 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} 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} 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} 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} 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); + } +} diff --git a/module/documents/item.mjs b/module/documents/item.mjs new file mode 100644 index 0000000..cfb167c --- /dev/null +++ b/module/documents/item.mjs @@ -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} 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} 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 = `
`; + content += `

${this.name}

`; + content += `
`; + + // Type-specific details + switch (this.type) { + case "weapon": + content += `

Damage: ${system.damage || "1d6"}

`; + if (system.properties?.length) { + content += `

Properties: ${system.properties.join(", ")}

`; + } + break; + case "armor": + content += `

Armor: ${system.armorValue || 0}

`; + content += `

Type: ${system.armorType || "light"}

`; + break; + case "spell": + content += `

Base Cost: ${system.baseCost || 1} Mana

`; + if (system.effect) { + content += `

Effect: ${system.effect}

`; + } + break; + case "perk": + if (system.prerequisites?.length) { + content += `

Prerequisites:

`; + } + break; + } + + // Description + if (system.description) { + content += `
${system.description}
`; + } + + content += `
`; + 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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} 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; + } +} diff --git a/module/tests/actor.test.mjs b/module/tests/actor.test.mjs index f2ef040..225c885 100644 --- a/module/tests/actor.test.mjs +++ b/module/tests/actor.test.mjs @@ -53,19 +53,77 @@ export function registerActorTests(quenchRunner) { 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 - expect(testActor.system.speed.value).to.equal(30); + expect(testActor.system.speed.walk).to.equal(30); 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 }); - expect(testActor.system.speed.value).to.equal(25); + expect(testActor.system.speed.walk).to.equal(25); }); - it("calculates Item Slots as 8 + Might", async () => { - expect(testActor.system.itemSlots.max).to.equal(13); // 8 + 5 + it("applies speed bonus to walking speed", async () => { + /** + * 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 () => { @@ -94,18 +152,165 @@ export function registerActorTests(quenchRunner) { }); 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.itemSlots.max).to.equal(13); // 8 + 5 - 0 await testActor.update({ "system.resources.fatigue.value": 3 }); expect(testActor.system.resources.fatigue.value).to.equal(3); // 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); + + 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", () => { 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.hp.max).to.equal(4); }); 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); }); - 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"); }); - 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); }); }); + + 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" } ); diff --git a/module/tests/dice.test.mjs b/module/tests/dice.test.mjs new file mode 100644 index 0000000..f4ceff1 --- /dev/null +++ b/module/tests/dice.test.mjs @@ -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" } + ); +} diff --git a/module/tests/quench-init.mjs b/module/tests/quench-init.mjs index 5bcad0c..59eeb0b 100644 --- a/module/tests/quench-init.mjs +++ b/module/tests/quench-init.mjs @@ -8,9 +8,9 @@ */ // 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 { registerRollTests } from "./rolls.test.mjs"; // import { registerEffectTests } from "./effects.test.mjs"; /** @@ -63,10 +63,10 @@ export function registerQuenchTests(quenchRunner) { { displayName: "Vagabond: Sanity Checks" } ); - // Register domain-specific test batches (uncomment as implemented) - // registerActorTests(quenchRunner); + // Register domain-specific test batches + registerActorTests(quenchRunner); + registerDiceTests(quenchRunner); // registerItemTests(quenchRunner); - // registerRollTests(quenchRunner); // registerEffectTests(quenchRunner); // eslint-disable-next-line no-console diff --git a/module/vagabond.mjs b/module/vagabond.mjs index 84d70ec..d773d22 100644 --- a/module/vagabond.mjs +++ b/module/vagabond.mjs @@ -20,8 +20,7 @@ import { } from "./data/item/_module.mjs"; // Import document classes -// import { VagabondActor } from "./documents/actor.mjs"; -// import { VagabondItem } from "./documents/item.mjs"; +import { VagabondActor, VagabondItem } from "./documents/_module.mjs"; // Import sheet classes // import { VagabondCharacterSheet } from "./sheets/actor-sheet.mjs"; @@ -65,9 +64,9 @@ Hooks.once("init", () => { feature: FeatureData, }; - // Define custom Document classes (for future use) - // CONFIG.Actor.documentClass = VagabondActor; - // CONFIG.Item.documentClass = VagabondItem; + // Define custom Document classes + CONFIG.Actor.documentClass = VagabondActor; + CONFIG.Item.documentClass = VagabondItem; // Register sheet application classes (TODO: Phase 3-4) // Actors.unregisterSheet("core", ActorSheet); diff --git a/system.json b/system.json index 8fd3f17..529edcf 100644 --- a/system.json +++ b/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, "url": "https://github.com/calcorum/vagabond-rpg-foundryvtt", "manifest": "https://github.com/calcorum/vagabond-rpg-foundryvtt/releases/latest/download/system.json", @@ -131,6 +172,8 @@ "changelog": "CHANGELOG.md", "primaryTokenAttribute": "resources.hp", "secondaryTokenAttribute": "resources.mana", - "gridDistance": 5, - "gridUnits": "ft" + "grid": { + "distance": 5, + "units": "ft" + } }