Phase 2.5: Skill Check System Implementation
Features:
- ApplicationV2-based roll dialogs with HandlebarsApplicationMixin
- Base VagabondRollDialog class for shared dialog functionality
- SkillCheckDialog for skill checks with auto-calculated difficulty
- Favor/Hinder system using Active Effects flags (simplified from schema)
- FavorHinderDebug panel for testing flags without actor sheets
- Auto-created development macros (Favor/Hinder Debug, Skill Check)
- Custom chat cards for skill roll results
Technical Changes:
- Removed favorHinder from character schema (now uses flags)
- Updated getNetFavorHinder() to use flag-based approach
- Returns { net, favorSources, hinderSources } for transparency
- Universal form styling fixes for Foundry dark theme compatibility
- Added Macro to ESLint globals
Flag Convention:
- flags.vagabond.favor.skills.<skillId>
- flags.vagabond.hinder.skills.<skillId>
- flags.vagabond.favor.attacks
- flags.vagabond.hinder.attacks
- flags.vagabond.favor.saves.<saveType>
- flags.vagabond.hinder.saves.<saveType>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
563 lines
17 KiB
JavaScript
563 lines
17 KiB
JavaScript
/**
|
|
* VagabondActor Document Class
|
|
*
|
|
* Extended Actor document for Vagabond RPG system.
|
|
* Provides document-level functionality including:
|
|
* - Derived data preparation pipeline
|
|
* - Item management (equipped items, inventory)
|
|
* - Roll methods for skills, attacks, and saves
|
|
* - Resource management helpers
|
|
*
|
|
* Data models (CharacterData, NPCData) handle schema and derived calculations.
|
|
* This class handles document operations and Foundry integration.
|
|
*
|
|
* @extends Actor
|
|
*/
|
|
export default class VagabondActor extends Actor {
|
|
/* -------------------------------------------- */
|
|
/* Data Preparation */
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Prepare data for the actor.
|
|
* This is called automatically by Foundry when the actor is loaded or updated.
|
|
*
|
|
* The preparation pipeline:
|
|
* 1. prepareBaseData() - Set up data before embedded documents
|
|
* 2. prepareEmbeddedDocuments() - Process owned items
|
|
* 3. prepareDerivedData() - Calculate final derived values
|
|
*
|
|
* @override
|
|
*/
|
|
prepareData() {
|
|
// Call the parent class preparation
|
|
super.prepareData();
|
|
}
|
|
|
|
/**
|
|
* Prepare base data before embedded documents are processed.
|
|
* Called by Foundry as part of the data preparation pipeline.
|
|
*
|
|
* @override
|
|
*/
|
|
prepareBaseData() {
|
|
super.prepareBaseData();
|
|
// Base data is handled by the TypeDataModel (CharacterData/NPCData)
|
|
}
|
|
|
|
/**
|
|
* Prepare derived data after embedded documents are processed.
|
|
* This is where we calculate values that depend on owned items.
|
|
*
|
|
* @override
|
|
*/
|
|
prepareDerivedData() {
|
|
super.prepareDerivedData();
|
|
|
|
// Type-specific derived data
|
|
const actorType = this.type;
|
|
if (actorType === "character") {
|
|
this._prepareCharacterDerivedData();
|
|
} else if (actorType === "npc") {
|
|
this._prepareNPCDerivedData();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prepare character-specific derived data.
|
|
* Calculates values based on owned items (armor, weapons, etc.).
|
|
*
|
|
* @private
|
|
*/
|
|
_prepareCharacterDerivedData() {
|
|
const system = this.system;
|
|
|
|
// Calculate armor from equipped armor items
|
|
let totalArmor = 0;
|
|
for (const item of this.items) {
|
|
if (item.type === "armor" && item.system.equipped) {
|
|
totalArmor += item.system.armorValue || 0;
|
|
}
|
|
}
|
|
system.armor = totalArmor;
|
|
|
|
// Calculate used item slots from inventory
|
|
let usedSlots = 0;
|
|
for (const item of this.items) {
|
|
// Only count items that take slots (not features, classes, etc.)
|
|
if (["weapon", "armor", "equipment"].includes(item.type)) {
|
|
const slots = item.system.slots || 0;
|
|
const quantity = item.system.quantity || 1;
|
|
usedSlots += slots * quantity;
|
|
}
|
|
}
|
|
system.itemSlots.used = usedSlots;
|
|
|
|
// Recalculate overburdened status after slot calculation
|
|
system.itemSlots.overburdened = system.itemSlots.used > system.itemSlots.max;
|
|
}
|
|
|
|
/**
|
|
* Prepare NPC-specific derived data.
|
|
*
|
|
* @private
|
|
*/
|
|
_prepareNPCDerivedData() {
|
|
// NPC derived data is handled by NPCData.prepareDerivedData()
|
|
// Add any document-level NPC calculations here if needed
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Roll Data */
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Get the roll data for this actor.
|
|
* Includes all stats, resources, and item bonuses for use in Roll formulas.
|
|
*
|
|
* @override
|
|
* @returns {Object} Roll data object
|
|
*/
|
|
getRollData() {
|
|
// Start with the system data
|
|
const data = { ...super.getRollData() };
|
|
|
|
// Add actor-level conveniences
|
|
data.name = this.name;
|
|
|
|
// Type-specific roll data is added by the TypeDataModel's getRollData()
|
|
|
|
return data;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Item Management */
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Get all items of a specific type owned by this actor.
|
|
*
|
|
* @param {string} type - The item type to filter by
|
|
* @returns {VagabondItem[]} Array of matching items
|
|
*/
|
|
getItemsByType(type) {
|
|
return this.items.filter((item) => item.type === type);
|
|
}
|
|
|
|
/**
|
|
* Get all equipped weapons.
|
|
*
|
|
* @returns {VagabondItem[]} Array of equipped weapon items
|
|
*/
|
|
getEquippedWeapons() {
|
|
return this.items.filter((item) => item.type === "weapon" && item.system.equipped);
|
|
}
|
|
|
|
/**
|
|
* Get all equipped armor (including shields).
|
|
*
|
|
* @returns {VagabondItem[]} Array of equipped armor items
|
|
*/
|
|
getEquippedArmor() {
|
|
return this.items.filter((item) => item.type === "armor" && item.system.equipped);
|
|
}
|
|
|
|
/**
|
|
* Get the character's class item(s).
|
|
*
|
|
* @returns {VagabondItem[]} Array of class items
|
|
*/
|
|
getClasses() {
|
|
return this.items.filter((item) => item.type === "class");
|
|
}
|
|
|
|
/**
|
|
* Get the character's ancestry item.
|
|
*
|
|
* @returns {VagabondItem|null} The ancestry item or null
|
|
*/
|
|
getAncestry() {
|
|
return this.items.find((item) => item.type === "ancestry") || null;
|
|
}
|
|
|
|
/**
|
|
* Get all known spells.
|
|
*
|
|
* @returns {VagabondItem[]} Array of spell items
|
|
*/
|
|
getSpells() {
|
|
return this.items.filter((item) => item.type === "spell");
|
|
}
|
|
|
|
/**
|
|
* Get all perks.
|
|
*
|
|
* @returns {VagabondItem[]} Array of perk items
|
|
*/
|
|
getPerks() {
|
|
return this.items.filter((item) => item.type === "perk");
|
|
}
|
|
|
|
/**
|
|
* Get all features (from class, ancestry, etc.).
|
|
*
|
|
* @returns {VagabondItem[]} Array of feature items
|
|
*/
|
|
getFeatures() {
|
|
return this.items.filter((item) => item.type === "feature");
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Resource Management */
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Modify a resource value (HP, Mana, Luck, etc.).
|
|
* Handles bounds checking and triggers appropriate hooks.
|
|
*
|
|
* @param {string} resource - The resource key (e.g., "hp", "mana", "luck")
|
|
* @param {number} delta - The amount to change (positive or negative)
|
|
* @returns {Promise<VagabondActor>} The updated actor
|
|
*/
|
|
async modifyResource(resource, delta) {
|
|
if (this.type !== "character") {
|
|
// For NPCs, only HP is tracked in the same way
|
|
if (resource === "hp") {
|
|
const npcCurrent = this.system.hp.value;
|
|
const npcMax = this.system.hp.max;
|
|
const npcNewValue = Math.clamp(npcCurrent + delta, 0, npcMax);
|
|
return this.update({ "system.hp.value": npcNewValue });
|
|
}
|
|
return this;
|
|
}
|
|
|
|
const resourceData = this.system.resources[resource];
|
|
if (!resourceData) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(`Vagabond | Unknown resource: ${resource}`);
|
|
return this;
|
|
}
|
|
|
|
const current = resourceData.value;
|
|
const max = resourceData.max || Infinity;
|
|
const min = resource === "fatigue" ? 0 : 0; // All resources min at 0
|
|
const newValue = Math.clamp(current + delta, min, max);
|
|
|
|
return this.update({ [`system.resources.${resource}.value`]: newValue });
|
|
}
|
|
|
|
/**
|
|
* Apply damage to this actor.
|
|
* Reduces HP by the damage amount (after considering armor if applicable).
|
|
*
|
|
* @param {number} damage - The raw damage amount
|
|
* @param {Object} options - Damage options
|
|
* @param {boolean} options.ignoreArmor - If true, bypass armor reduction
|
|
* @param {string} options.damageType - The type of damage for resistance checks
|
|
* @returns {Promise<VagabondActor>} The updated actor
|
|
*/
|
|
async applyDamage(damage, options = {}) {
|
|
const { ignoreArmor = false, damageType = "blunt" } = options;
|
|
|
|
let finalDamage = damage;
|
|
|
|
// Apply armor reduction for characters (unless ignored)
|
|
if (this.type === "character" && !ignoreArmor) {
|
|
const armor = this.system.armor || 0;
|
|
finalDamage = Math.max(0, damage - armor);
|
|
}
|
|
|
|
// For NPCs, check immunities, resistances, and weaknesses
|
|
if (this.type === "npc") {
|
|
const system = this.system;
|
|
|
|
if (system.immunities?.includes(damageType)) {
|
|
finalDamage = 0;
|
|
} else if (system.resistances?.includes(damageType)) {
|
|
finalDamage = Math.floor(finalDamage / 2);
|
|
} else if (system.weaknesses?.includes(damageType)) {
|
|
finalDamage = Math.floor(finalDamage * 1.5);
|
|
}
|
|
|
|
// Apply armor for NPCs
|
|
if (!ignoreArmor) {
|
|
const armor = system.armor || 0;
|
|
finalDamage = Math.max(0, finalDamage - armor);
|
|
}
|
|
}
|
|
|
|
// Apply the damage
|
|
if (this.type === "character") {
|
|
return this.modifyResource("hp", -finalDamage);
|
|
}
|
|
const newHP = Math.max(0, this.system.hp.value - finalDamage);
|
|
return this.update({ "system.hp.value": newHP });
|
|
}
|
|
|
|
/**
|
|
* Heal this actor.
|
|
*
|
|
* @param {number} amount - The amount to heal
|
|
* @returns {Promise<VagabondActor>} The updated actor
|
|
*/
|
|
async applyHealing(amount) {
|
|
if (this.type === "character") {
|
|
return this.modifyResource("hp", amount);
|
|
}
|
|
const max = this.system.hp.max;
|
|
const newHP = Math.min(max, this.system.hp.value + amount);
|
|
return this.update({ "system.hp.value": newHP });
|
|
}
|
|
|
|
/**
|
|
* Spend mana for spellcasting.
|
|
*
|
|
* @param {number} cost - The mana cost
|
|
* @returns {Promise<boolean>} True if mana was spent, false if insufficient
|
|
*/
|
|
async spendMana(cost) {
|
|
if (this.type !== "character") return false;
|
|
|
|
const current = this.system.resources.mana.value;
|
|
if (current < cost) return false;
|
|
|
|
await this.modifyResource("mana", -cost);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Spend a luck point.
|
|
*
|
|
* @returns {Promise<boolean>} True if luck was spent, false if none available
|
|
*/
|
|
async spendLuck() {
|
|
if (this.type !== "character") return false;
|
|
|
|
const current = this.system.resources.luck.value;
|
|
if (current < 1) return false;
|
|
|
|
await this.modifyResource("luck", -1);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Add fatigue to the character.
|
|
* Death occurs at 5 fatigue.
|
|
*
|
|
* @param {number} amount - Fatigue to add (default 1)
|
|
* @returns {Promise<VagabondActor>} The updated actor
|
|
*/
|
|
async addFatigue(amount = 1) {
|
|
if (this.type !== "character") return this;
|
|
|
|
const current = this.system.resources.fatigue.value;
|
|
const newValue = Math.min(5, current + amount);
|
|
|
|
// Check for death at 5 fatigue
|
|
if (newValue >= 5) {
|
|
// TODO: Trigger death state
|
|
// eslint-disable-next-line no-console
|
|
console.log("Vagabond | Character has died from fatigue!");
|
|
}
|
|
|
|
return this.update({ "system.resources.fatigue.value": newValue });
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Rest & Recovery */
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Perform a short rest (breather).
|
|
* Recovers some HP based on Might.
|
|
*
|
|
* @returns {Promise<Object>} Results of the rest
|
|
*/
|
|
async takeBreather() {
|
|
if (this.type !== "character") return { recovered: 0 };
|
|
|
|
const might = this.system.stats.might.value;
|
|
const currentHP = this.system.resources.hp.value;
|
|
const maxHP = this.system.resources.hp.max;
|
|
|
|
// Recover Might HP
|
|
const recovered = Math.min(might, maxHP - currentHP);
|
|
await this.modifyResource("hp", recovered);
|
|
|
|
// Track breathers taken
|
|
const breathersTaken = (this.system.restTracking?.breathersTaken || 0) + 1;
|
|
await this.update({ "system.restTracking.breathersTaken": breathersTaken });
|
|
|
|
return { recovered, breathersTaken };
|
|
}
|
|
|
|
/**
|
|
* Perform a full rest.
|
|
* Recovers all HP, Mana, Luck; reduces Fatigue by 1.
|
|
*
|
|
* @returns {Promise<Object>} Results of the rest
|
|
*/
|
|
async takeFullRest() {
|
|
if (this.type !== "character") return {};
|
|
|
|
const system = this.system;
|
|
const updates = {};
|
|
|
|
// Restore HP to max
|
|
updates["system.resources.hp.value"] = system.resources.hp.max;
|
|
|
|
// Restore Mana to max
|
|
updates["system.resources.mana.value"] = system.resources.mana.max;
|
|
|
|
// Restore Luck to max
|
|
updates["system.resources.luck.value"] = system.resources.luck.max;
|
|
|
|
// Reduce Fatigue by 1 (minimum 0)
|
|
const newFatigue = Math.max(0, system.resources.fatigue.value - 1);
|
|
updates["system.resources.fatigue.value"] = newFatigue;
|
|
|
|
// Reset breathers counter
|
|
updates["system.restTracking.breathersTaken"] = 0;
|
|
|
|
// Track last rest time
|
|
updates["system.restTracking.lastRest"] = new Date().toISOString();
|
|
|
|
await this.update(updates);
|
|
|
|
return {
|
|
hpRestored: system.resources.hp.max,
|
|
manaRestored: system.resources.mana.max,
|
|
luckRestored: system.resources.luck.max,
|
|
fatigueReduced: system.resources.fatigue.value > 0 ? 1 : 0,
|
|
};
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Combat Helpers */
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Check if this actor is dead (HP <= 0 or Fatigue >= 5).
|
|
*
|
|
* @returns {boolean} True if the actor is dead
|
|
*/
|
|
get isDead() {
|
|
if (this.type === "character") {
|
|
return (
|
|
this.system.resources.hp.value <= 0 ||
|
|
this.system.resources.fatigue.value >= 5 ||
|
|
this.system.death?.isDead
|
|
);
|
|
}
|
|
return this.system.hp.value <= 0;
|
|
}
|
|
|
|
/**
|
|
* Check if this NPC should make a morale check.
|
|
*
|
|
* @returns {boolean} True if morale should be checked
|
|
*/
|
|
shouldCheckMorale() {
|
|
if (this.type !== "npc") return false;
|
|
return this.system.shouldCheckMorale?.() || false;
|
|
}
|
|
|
|
/**
|
|
* Get the net favor/hinder for a specific roll type.
|
|
* Checks Active Effect flags for persistent favor/hinder sources.
|
|
* Favor and Hinder cancel 1-for-1, capped at +1 or -1.
|
|
*
|
|
* Flag convention (set by Active Effects):
|
|
* - flags.vagabond.favor.skills.<skillId> - Favor on specific skill
|
|
* - flags.vagabond.hinder.skills.<skillId> - Hinder on specific skill
|
|
* - flags.vagabond.favor.attacks - Favor on attack rolls
|
|
* - flags.vagabond.hinder.attacks - Hinder on attack rolls
|
|
* - flags.vagabond.favor.saves.<saveType> - Favor on specific save
|
|
* - flags.vagabond.hinder.saves.<saveType> - Hinder on specific save
|
|
*
|
|
* @param {Object} options - Options for determining favor/hinder
|
|
* @param {string} [options.skillId] - Skill ID for skill checks (e.g., "arcana", "brawl")
|
|
* @param {boolean} [options.isAttack] - True if this is an attack roll
|
|
* @param {string} [options.saveType] - Save type (e.g., "reflex", "endure", "will")
|
|
* @returns {Object} Result with net value and sources
|
|
* @returns {number} result.net - Net modifier: +1 (favor), 0 (neutral), -1 (hinder)
|
|
* @returns {string[]} result.favorSources - Names of active favor sources
|
|
* @returns {string[]} result.hinderSources - Names of active hinder sources
|
|
*/
|
|
getNetFavorHinder({ skillId = null, isAttack = false, saveType = null } = {}) {
|
|
if (this.type !== "character") return { net: 0, favorSources: [], hinderSources: [] };
|
|
|
|
const favorSources = [];
|
|
const hinderSources = [];
|
|
|
|
// Check skill-specific flags
|
|
if (skillId) {
|
|
if (this.getFlag("vagabond", `favor.skills.${skillId}`)) {
|
|
favorSources.push(this._getFavorHinderSourceName("favor", "skills", skillId));
|
|
}
|
|
if (this.getFlag("vagabond", `hinder.skills.${skillId}`)) {
|
|
hinderSources.push(this._getFavorHinderSourceName("hinder", "skills", skillId));
|
|
}
|
|
}
|
|
|
|
// Check attack flags
|
|
if (isAttack) {
|
|
if (this.getFlag("vagabond", "favor.attacks")) {
|
|
favorSources.push(this._getFavorHinderSourceName("favor", "attacks"));
|
|
}
|
|
if (this.getFlag("vagabond", "hinder.attacks")) {
|
|
hinderSources.push(this._getFavorHinderSourceName("hinder", "attacks"));
|
|
}
|
|
}
|
|
|
|
// Check save-specific flags
|
|
if (saveType) {
|
|
if (this.getFlag("vagabond", `favor.saves.${saveType}`)) {
|
|
favorSources.push(this._getFavorHinderSourceName("favor", "saves", saveType));
|
|
}
|
|
if (this.getFlag("vagabond", `hinder.saves.${saveType}`)) {
|
|
hinderSources.push(this._getFavorHinderSourceName("hinder", "saves", saveType));
|
|
}
|
|
}
|
|
|
|
// They cancel 1-for-1, max of +1 or -1
|
|
const net = Math.clamp(favorSources.length - hinderSources.length, -1, 1);
|
|
|
|
return { net, favorSources, hinderSources };
|
|
}
|
|
|
|
/**
|
|
* Get the source name for a favor/hinder flag by finding the Active Effect that set it.
|
|
*
|
|
* @param {string} type - "favor" or "hinder"
|
|
* @param {string} category - "skills", "attacks", or "saves"
|
|
* @param {string} [subtype] - Skill ID or save type
|
|
* @returns {string} Source name or generic description
|
|
* @private
|
|
*/
|
|
_getFavorHinderSourceName(type, category, subtype = null) {
|
|
const flagKey = subtype
|
|
? `flags.vagabond.${type}.${category}.${subtype}`
|
|
: `flags.vagabond.${type}.${category}`;
|
|
|
|
// Find the Active Effect that sets this flag
|
|
for (const effect of this.effects) {
|
|
if (!effect.active) continue;
|
|
for (const change of effect.changes) {
|
|
if (change.key === flagKey) {
|
|
return effect.name || effect.parent?.name || `${type} effect`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback if source not found
|
|
const categoryLabel =
|
|
category === "skills"
|
|
? `${subtype} checks`
|
|
: category === "saves"
|
|
? `${subtype} saves`
|
|
: category;
|
|
return `${type} on ${categoryLabel}`;
|
|
}
|
|
}
|