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>
935 lines
29 KiB
JavaScript
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}`;
|
|
}
|
|
}
|