vagabond-rpg-foundryvtt/module/documents/actor.mjs
Cal Corum bf2cd92e93 Add Status item system and separate attack/damage rolls
Status System:
- Add StatusData model with mechanical modifiers (damageDealt, healingReceived)
- Add status item sheet with modifier configuration
- Add status-bar.hbs for displaying status chips on actor sheets
- Status chips show tooltip on hover, can be removed via click
- Add 17 status items to compendium (Blinded, Burning, Charmed, etc.)
- Frightened applies -2 damage dealt, Sickened applies -2 healing received

Attack Roll Changes:
- Separate attack and damage into two discrete rolls
- Attack hit now shows "Roll Damage" button instead of auto-rolling
- Button click rolls damage and updates the chat message in-place
- Store weapon/attack data in message flags for later damage rolling
- Fix favor/hinder and modifier preset buttons in attack dialog
- Show individual damage dice results in chat card breakdown

Mechanical Integration:
- Add _applyStatusModifiers() to VagabondActor for aggregating status effects
- Update getRollData() to include statusModifiers for roll formulas
- Update damageRoll() to automatically apply damageDealt modifier
- Update applyHealing() to respect healingReceived modifier

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 14:36:57 -06:00

935 lines
29 KiB
JavaScript

import { LevelUpDialog } from "../applications/_module.mjs";
// Debug logging for level-up workflow - set to false to disable
const DEBUG_LEVELUP = true;
const debugLog = (...args) => {
if (DEBUG_LEVELUP) console.log("[VagabondActor]", ...args);
};
const debugWarn = (...args) => {
if (DEBUG_LEVELUP) console.warn("[VagabondActor]", ...args);
};
/**
* 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 {
/* -------------------------------------------- */
/* Document Lifecycle */
/* -------------------------------------------- */
/**
* Handle actor updates. Detect level changes and update class features.
*
* @override
*/
async _onUpdate(changed, options, userId) {
debugLog(`_onUpdate called for "${this.name}"`, {
changed,
options,
userId,
currentUserId: game.user.id,
});
await super._onUpdate(changed, options, userId);
// Only process for the updating user
if (game.user.id !== userId) {
debugLog("Skipping - not the updating user");
return;
}
// Check for level change on characters
if (this.type === "character" && changed.system?.level !== undefined) {
const newLevel = changed.system.level;
const oldLevel = options._previousLevel ?? 1;
debugLog(`Level change detected: ${oldLevel} -> ${newLevel}`);
if (newLevel !== oldLevel) {
debugLog("Calling _onLevelChange...");
await this._onLevelChange(newLevel, oldLevel);
} else {
debugLog("Level unchanged, skipping level change handling");
}
}
}
/**
* Capture current level before update for comparison.
*
* @override
*/
async _preUpdate(changed, options, userId) {
debugLog(`_preUpdate called for "${this.name}"`, {
changed,
currentLevel: this.system?.level,
});
await super._preUpdate(changed, options, userId);
// Store current level for level change detection
if (this.type === "character" && changed.system?.level !== undefined) {
options._previousLevel = this.system.level;
debugLog(`Stored previous level: ${options._previousLevel}`);
}
}
/**
* Handle character level changes.
* Shows level-up dialog to display gained features and handle choices.
*
* @param {number} newLevel - The new character level
* @param {number} oldLevel - The previous character level
* @private
*/
async _onLevelChange(newLevel, oldLevel) {
debugLog(`_onLevelChange called: ${oldLevel} -> ${newLevel}`);
// Check if there are any classes that have features to process
const classes = this.items.filter((i) => i.type === "class");
debugLog(
`Found ${classes.length} class(es):`,
classes.map((c) => c.name)
);
if (classes.length === 0) {
// No class, just notify
debugWarn("No classes found on actor - showing simple notification");
ui.notifications.info(`${this.name} advanced to level ${newLevel}!`);
return;
}
// Log class feature info
for (const classItem of classes) {
const features = classItem.system.features || [];
const progression = classItem.system.progression || [];
debugLog(`Class "${classItem.name}" data:`, {
featuresCount: features.length,
features: features.map((f) => ({
name: f.name,
level: f.level,
hasChanges: f.changes?.length > 0,
requiresChoice: f.requiresChoice,
})),
progression: progression.map((p) => ({
level: p.level,
features: p.features,
})),
});
}
// Show level-up dialog to handle features and choices
// The dialog will call updateClassFeatures after user confirms
debugLog("Creating LevelUpDialog...");
await LevelUpDialog.create(this, newLevel, oldLevel);
debugLog("LevelUpDialog created/displayed");
}
/* -------------------------------------------- */
/* 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;
// Apply status modifiers
this._applyStatusModifiers();
// Calculate used item slots from inventory
// Each item type implements getTotalSlots() with its own logic
let usedSlots = 0;
for (const item of this.items) {
if (typeof item.system.getTotalSlots === "function") {
usedSlots += item.system.getTotalSlots();
}
}
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()
// Apply status modifiers to NPCs as well
this._applyStatusModifiers();
}
/**
* Apply modifiers from status items to the actor.
* Aggregates damageDealt and healingReceived modifiers from all active statuses.
*
* @private
*/
_applyStatusModifiers() {
const system = this.system;
// Initialize modifiers if not present
if (!system.statusModifiers) {
system.statusModifiers = {
damageDealt: 0,
healingReceived: 0,
};
}
// Reset to base values
system.statusModifiers.damageDealt = 0;
system.statusModifiers.healingReceived = 0;
// Aggregate modifiers from all status items
for (const item of this.items) {
if (item.type === "status") {
const modifiers = item.system.modifiers || {};
system.statusModifiers.damageDealt += modifiers.damageDealt || 0;
system.statusModifiers.healingReceived += modifiers.healingReceived || 0;
}
}
}
/* -------------------------------------------- */
/* 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;
// Add status modifiers for use in damage/healing formulas
data.statusModifiers = this.system.statusModifiers || {
damageDealt: 0,
healingReceived: 0,
};
// 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.
* Applies status modifiers (e.g., Sickened reduces healing received).
*
* @param {number} amount - The amount to heal
* @returns {Promise<VagabondActor>} The updated actor
*/
async applyHealing(amount) {
// Apply status modifiers to healing (e.g., Sickened gives -2)
const healingModifier = this.system.statusModifiers?.healingReceived || 0;
const finalAmount = Math.max(0, amount + healingModifier);
if (this.type === "character") {
return this.modifyResource("hp", finalAmount);
}
const max = this.system.hp.max;
const newHP = Math.min(max, this.system.hp.value + finalAmount);
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;
}
/* -------------------------------------------- */
/* Morale System (NPC) */
/* -------------------------------------------- */
/**
* Roll a morale check for this NPC.
* Morale fails if 2d6 > Morale score.
*
* @param {Object} options - Roll options
* @param {string} [options.trigger] - What triggered this check (first-death, half-hp, etc.)
* @param {boolean} [options.skipMessage=false] - If true, don't post chat message
* @returns {Promise<Object>} Result with roll, passed, and morale data
*/
async rollMorale({ trigger = null, skipMessage = false } = {}) {
if (this.type !== "npc") {
ui.notifications.warn("Morale checks are only for NPCs");
return null;
}
const morale = this.system.morale;
// Roll 2d6
const roll = await new Roll("2d6").evaluate();
// Morale fails if roll > morale score
const passed = roll.total <= morale;
// Update morale status
const updates = {
"system.moraleStatus.checkedThisCombat": true,
"system.moraleStatus.lastTrigger": trigger,
"system.moraleStatus.lastResult": passed ? "passed" : "failed-retreat",
};
// If failed, mark as broken
if (!passed) {
updates["system.moraleStatus.broken"] = true;
}
await this.update(updates);
// Post chat message
if (!skipMessage) {
await this._postMoraleMessage(roll, morale, passed, trigger);
}
return { roll, passed, morale, trigger };
}
/**
* Post a morale check result to chat.
*
* @param {Roll} roll - The 2d6 roll
* @param {number} morale - The morale score
* @param {boolean} passed - Whether the check passed
* @param {string} trigger - What triggered the check
* @private
*/
async _postMoraleMessage(roll, morale, passed, trigger) {
const triggerLabels = {
"first-death": "first ally death",
"half-hp": "reaching half HP",
"half-incapacitated": "half of group incapacitated",
"leader-death": "leader death",
manual: "GM request",
};
const triggerText = triggerLabels[trigger] || trigger || "unknown trigger";
const resultText = passed
? `<span style="color: green; font-weight: bold;">HOLDS!</span> (rolled ${roll.total}${morale})`
: `<span style="color: red; font-weight: bold;">BREAKS!</span> (rolled ${roll.total} > ${morale})`;
const content = `
<div class="vagabond morale-check">
<h3>Morale Check</h3>
<p><strong>Trigger:</strong> ${triggerText}</p>
<p><strong>Morale Score:</strong> ${morale}</p>
<p><strong>Result:</strong> ${resultText}</p>
${!passed ? "<p><em>The enemy retreats or surrenders!</em></p>" : ""}
</div>
`;
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: this }),
content,
rolls: [roll],
sound: CONFIG.sounds.dice,
});
}
/**
* Roll morale for multiple selected NPCs as a group.
* Uses the lowest morale score among the group.
*
* @param {Object} options - Roll options
* @param {string} [options.trigger] - What triggered this check
* @returns {Promise<Object>} Result with roll, passed, and affected actors
*/
static async rollGroupMorale({ trigger = "manual" } = {}) {
// Get selected NPC tokens
const tokens = canvas.tokens.controlled.filter((t) => t.actor?.type === "npc");
if (tokens.length === 0) {
ui.notifications.warn("Select one or more NPC tokens to roll morale");
return null;
}
// Find the lowest morale score (weakest link)
const actors = tokens.map((t) => t.actor);
const lowestMorale = Math.min(...actors.map((a) => a.system.morale));
const _groupName =
tokens.length === 1
? tokens[0].name
: `${tokens.length} enemies (lowest morale: ${lowestMorale})`;
// Roll 2d6
const roll = await new Roll("2d6").evaluate();
const passed = roll.total <= lowestMorale;
// Update all affected actors
for (const actor of actors) {
await actor.update({
"system.moraleStatus.checkedThisCombat": true,
"system.moraleStatus.lastTrigger": trigger,
"system.moraleStatus.lastResult": passed ? "passed" : "failed-retreat",
"system.moraleStatus.broken": !passed,
});
}
// Post group result to chat
const triggerLabels = {
"first-death": "first ally death",
"half-hp": "reaching half HP",
"half-incapacitated": "half of group incapacitated",
"leader-death": "leader death",
manual: "GM request",
};
const triggerText = triggerLabels[trigger] || trigger || "unknown trigger";
const resultText = passed
? `<span style="color: green; font-weight: bold;">HOLDS!</span> (rolled ${roll.total}${lowestMorale})`
: `<span style="color: red; font-weight: bold;">BREAKS!</span> (rolled ${roll.total} > ${lowestMorale})`;
const actorNames = actors.map((a) => a.name).join(", ");
const content = `
<div class="vagabond morale-check group">
<h3>Group Morale Check</h3>
<p><strong>Group:</strong> ${actorNames}</p>
<p><strong>Trigger:</strong> ${triggerText}</p>
<p><strong>Morale Score:</strong> ${lowestMorale} (lowest in group)</p>
<p><strong>Result:</strong> ${resultText}</p>
${!passed ? "<p><em>The enemies retreat or surrender!</em></p>" : ""}
</div>
`;
await ChatMessage.create({
speaker: { alias: "Morale Check" },
content,
rolls: [roll],
sound: CONFIG.sounds.dice,
});
return { roll, passed, morale: lowestMorale, actors, trigger };
}
/**
* Post a morale prompt to chat when a trigger condition is met.
* Includes a clickable button to roll morale.
*
* @param {string} trigger - What triggered the prompt
*/
async promptMoraleCheck(trigger) {
if (this.type !== "npc") return;
// Don't prompt if already broken
if (this.system.moraleStatus?.broken) return;
const triggerLabels = {
"first-death": "first ally death",
"half-hp": "reaching half HP",
"half-incapacitated": "half of group incapacitated",
"leader-death": "leader death",
};
const triggerText = triggerLabels[trigger] || trigger;
const content = `
<div class="vagabond morale-prompt">
<h3>Morale Check Triggered</h3>
<p><strong>${this.name}</strong> has reached a morale trigger: <em>${triggerText}</em></p>
<p>Morale Score: <strong>${this.system.morale}</strong></p>
<button type="button" class="morale-roll-btn" data-actor-id="${this.id}" data-trigger="${trigger}">
Roll Morale Check
</button>
</div>
`;
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: this }),
content,
whisper: game.users.filter((u) => u.isGM).map((u) => u.id),
});
}
/**
* 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}`;
}
}