vagabond-rpg-foundryvtt/module/documents/item.mjs
Cal Corum 517b7045c7 Add Phase 2 core system logic: document classes, dice rolling, and fixes
Implements Phase 2 foundational components:
- VagabondActor document class with item management, resource tracking,
  damage/healing, rest mechanics, and combat helpers
- VagabondItem document class with chat card generation and item usage
- Comprehensive dice rolling module (d20 checks, skill/attack/save rolls,
  damage with crit doubling, countdown dice, morale checks)
- Quench tests for all dice rolling functions

Fixes Foundry VTT v13 compatibility issues:
- Add documentTypes to system.json declaring valid Actor/Item types
- Fix StringField validation errors by using nullable/null pattern
  instead of blank string choices for optional fields
- Update actor tests to use embedded documents for slot calculations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 10:21:48 -06:00

543 lines
14 KiB
JavaScript

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