vagabond-rpg-foundryvtt/module/documents/item.mjs
Cal Corum 8afcf8c07b Implement class feature automation system
- Add _onCreate/_preDelete lifecycle methods to VagabondItem for automatic
  feature application and cleanup when classes are added/removed
- Add updateActor hook to apply new features when character level increases
- Implement applyClassFeatures() with idempotency to prevent duplicate effects
- Add _applyClassProgression() for mana/castingMax from class progression
- Add _applyTrainedSkills() to mark class skills as trained
- Fix getCastingMaxAtLevel() to sum values instead of taking maximum
- Add comprehensive test suite (10 tests) covering unit and integration tests

Effects are tagged with vagabond flags for easy filtering and management.
Methods calculate progression values directly for robustness with embedded items.

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

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

790 lines
22 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 {
/* -------------------------------------------- */
/* Document Lifecycle */
/* -------------------------------------------- */
/**
* Handle item creation. For class items, apply features as Active Effects.
* Note: This runs asynchronously after createEmbeddedDocuments returns.
* The applyClassFeatures method is idempotent and safe to call multiple times.
*
* @override
*/
async _onCreate(data, options, userId) {
await super._onCreate(data, options, userId);
// Only process for the creating user
if (game.user.id !== userId) return;
// Apply class features when class is added to a character
// Check that actor still exists (may be deleted in tests)
if (this.type === "class" && this.parent?.type === "character" && this.actor?.id) {
try {
await this.applyClassFeatures();
} catch (err) {
// Actor may have been deleted during tests - silently ignore
if (!err.message?.includes("does not exist")) throw err;
}
}
}
/**
* Handle item deletion. For class items, remove associated Active Effects.
*
* @override
*/
async _preDelete(options, userId) {
// Remove class effects before deletion
if (this.type === "class" && this.parent?.type === "character") {
await this._removeClassEffects();
}
return super._preDelete(options, userId);
}
/* -------------------------------------------- */
/* 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;
}
/**
* Apply class features as Active Effects based on character's current level.
* Called when class is added to character or when level changes.
* This method is idempotent - it won't create duplicate effects.
*
* @param {number} [targetLevel] - Level to apply features for (defaults to actor's level)
* @returns {Promise<ActiveEffect[]>} Created effects
*/
async applyClassFeatures(targetLevel = null) {
if (this.type !== "class" || !this.actor) return [];
const level = targetLevel ?? this.actor.system.level ?? 1;
const features = this.system.features || [];
// Get features at or below current level that have changes
const applicableFeatures = features.filter((f) => f.level <= level && f.changes?.length > 0);
if (applicableFeatures.length === 0) {
// Still apply progression even if no features with changes
await this._applyClassProgression(level);
await this._applyTrainedSkills();
return [];
}
// Filter out features that already have effects applied (idempotent)
const existingEffects = this.actor.effects.filter((e) => e.origin === this.uuid);
const existingFeatureNames = new Set(
existingEffects.map((e) => e.flags?.vagabond?.featureName)
);
const newFeatures = applicableFeatures.filter((f) => !existingFeatureNames.has(f.name));
if (newFeatures.length === 0) {
// All features already applied, just update progression
await this._applyClassProgression(level);
await this._applyTrainedSkills();
return [];
}
// Build Active Effect data for each new feature
const effectsData = newFeatures.map((feature) => ({
name: `${this.name}: ${feature.name}`,
icon: this.img || "icons/svg/book.svg",
origin: this.uuid,
changes: feature.changes.map((change) => ({
key: change.key,
mode: change.mode ?? 2, // Default to ADD mode
value: String(change.value),
priority: change.priority ?? null,
})),
flags: {
vagabond: {
classFeature: true,
className: this.name,
featureName: feature.name,
featureLevel: feature.level,
},
},
}));
// Create the effects
const createdEffects = await this.actor.createEmbeddedDocuments("ActiveEffect", effectsData);
// Also update actor's mana and casting max from class progression
await this._applyClassProgression(level);
// Train skills from class
await this._applyTrainedSkills();
return createdEffects;
}
/**
* Update class features when character level changes.
* Adds new features gained at the new level.
*
* @param {number} newLevel - The new character level
* @param {number} oldLevel - The previous character level
* @returns {Promise<ActiveEffect[]>} Newly created effects
*/
async updateClassFeatures(newLevel, oldLevel) {
if (this.type !== "class" || !this.actor) return [];
const features = this.system.features || [];
// Find features gained between old and new level
const newFeatures = features.filter(
(f) => f.level > oldLevel && f.level <= newLevel && f.changes?.length > 0
);
if (newFeatures.length === 0) {
// Still update progression stats even if no new features
await this._applyClassProgression(newLevel);
return [];
}
// Build Active Effect data for new features
const effectsData = newFeatures.map((feature) => ({
name: `${this.name}: ${feature.name}`,
icon: this.img || "icons/svg/book.svg",
origin: this.uuid,
changes: feature.changes.map((change) => ({
key: change.key,
mode: change.mode ?? 2,
value: String(change.value),
priority: change.priority ?? null,
})),
flags: {
vagabond: {
classFeature: true,
className: this.name,
featureName: feature.name,
featureLevel: feature.level,
},
},
}));
const createdEffects = await this.actor.createEmbeddedDocuments("ActiveEffect", effectsData);
// Update mana and casting max
await this._applyClassProgression(newLevel);
return createdEffects;
}
/**
* Remove all Active Effects originating from this class.
*
* @private
* @returns {Promise<void>}
*/
async _removeClassEffects() {
if (!this.actor) return;
const classEffects = this.actor.effects.filter((e) => e.origin === this.uuid);
if (classEffects.length > 0) {
const ids = classEffects.map((e) => e.id);
await this.actor.deleteEmbeddedDocuments("ActiveEffect", ids);
}
}
/**
* Apply class progression stats (mana, casting max) to the actor.
*
* @private
* @param {number} level - Character level
* @returns {Promise<void>}
*/
async _applyClassProgression(level) {
if (!this.actor || !this.system.isCaster) return;
// Calculate mana and casting max directly from progression data
// (methods on data model may not be available on embedded items)
const progression = this.system.progression || [];
let mana = 0;
let castingMax = 0;
for (const prog of progression) {
if (prog.level <= level) {
mana += prog.mana || 0;
castingMax += prog.castingMax || 0;
}
}
// Update actor's mana pool
const updates = {};
if (mana > 0) {
updates["system.resources.mana.max"] = mana;
// Set current mana to max if it was 0 (initial grant)
if (this.actor.system.resources.mana.value === 0) {
updates["system.resources.mana.value"] = mana;
}
}
if (castingMax > 0) {
updates["system.resources.mana.castingMax"] = castingMax;
}
if (Object.keys(updates).length > 0) {
await this.actor.update(updates);
}
}
/**
* Apply trained skills from class to the actor.
*
* @private
* @returns {Promise<void>}
*/
async _applyTrainedSkills() {
if (!this.actor) return;
const trainedSkills = this.system.trainedSkills || [];
if (trainedSkills.length === 0) return;
const updates = {};
for (const skillId of trainedSkills) {
if (this.actor.system.skills?.[skillId]) {
updates[`system.skills.${skillId}.trained`] = true;
}
}
if (Object.keys(updates).length > 0) {
await this.actor.update(updates);
}
}
/* -------------------------------------------- */
/* 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;
}
}