vagabond-rpg-foundryvtt/module/vagabond.mjs
Cal Corum 77c9359601 Add Dodge and Block defense rolls to character sheet
- Add Dodge roll under Reflex save (auto-hindered by heavy armor)
- Add Block roll under Endure save (hindered vs ranged attacks toggle)
- Create DodgeRollDialog and BlockRollDialog with templates
- Display defense rolls as indented sub-rows on Main tab
- Block row visually dimmed when no shield equipped, shows notification on click

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 11:40:12 -06:00

867 lines
27 KiB
JavaScript

/**
* Vagabond RPG System for Foundry VTT
* @module vagabond
*/
/* global Actors, Items, AudioHelper */
// Import configuration
import { VAGABOND } from "./helpers/config.mjs";
// Import data models
import { CharacterData, NPCData } from "./data/actor/_module.mjs";
import {
AncestryData,
ClassData,
SpellData,
PerkData,
WeaponData,
ArmorData,
EquipmentData,
FeatureData,
StatusData,
} from "./data/item/_module.mjs";
// Import document classes
import { VagabondActor, VagabondItem } from "./documents/_module.mjs";
// Import application classes
import {
VagabondRollDialog,
SkillCheckDialog,
AttackRollDialog,
SaveRollDialog,
DodgeRollDialog,
BlockRollDialog,
SpellCastDialog,
FavorHinderDebug,
} from "./applications/_module.mjs";
// Import sheet classes
import {
VagabondActorSheet,
VagabondCharacterSheet,
VagabondNPCSheet,
VagabondItemSheet,
} from "./sheets/_module.mjs";
// Import test registration (for Quench)
import { registerQuenchTests } from "./tests/quench-init.mjs";
/* -------------------------------------------- */
/* Foundry VTT Initialization */
/* -------------------------------------------- */
/**
* Preload Handlebars templates.
* @returns {Promise}
*/
async function preloadHandlebarsTemplates() {
const templatePaths = [
// Character sheet parts
"systems/vagabond/templates/actor/character-header.hbs",
"systems/vagabond/templates/actor/character-main.hbs",
"systems/vagabond/templates/actor/character-inventory.hbs",
"systems/vagabond/templates/actor/character-abilities.hbs",
"systems/vagabond/templates/actor/character-magic.hbs",
"systems/vagabond/templates/actor/character-biography.hbs",
"systems/vagabond/templates/actor/parts/tabs.hbs",
"systems/vagabond/templates/actor/parts/status-bar.hbs",
// NPC sheet parts
"systems/vagabond/templates/actor/npc-header.hbs",
"systems/vagabond/templates/actor/npc-body.hbs",
"systems/vagabond/templates/actor/npc-stats.hbs",
"systems/vagabond/templates/actor/npc-actions.hbs",
"systems/vagabond/templates/actor/npc-abilities.hbs",
"systems/vagabond/templates/actor/npc-notes.hbs",
// Item sheet parts
"systems/vagabond/templates/item/parts/item-header.hbs",
"systems/vagabond/templates/item/parts/item-tabs.hbs",
"systems/vagabond/templates/item/parts/item-body.hbs",
"systems/vagabond/templates/item/parts/item-effects.hbs",
// Item type templates
"systems/vagabond/templates/item/types/weapon.hbs",
"systems/vagabond/templates/item/types/armor.hbs",
"systems/vagabond/templates/item/types/equipment.hbs",
"systems/vagabond/templates/item/types/ancestry.hbs",
"systems/vagabond/templates/item/types/class.hbs",
"systems/vagabond/templates/item/types/spell.hbs",
"systems/vagabond/templates/item/types/perk.hbs",
"systems/vagabond/templates/item/types/feature.hbs",
"systems/vagabond/templates/item/types/status.hbs",
];
return loadTemplates(templatePaths);
}
/**
* Init hook - runs once when Foundry initializes
*/
Hooks.once("init", async () => {
// eslint-disable-next-line no-console
console.log("Vagabond RPG | Initializing Vagabond RPG System");
// Add custom constants for configuration
CONFIG.VAGABOND = VAGABOND;
// Expose application classes globally for macro/API access
game.vagabond = {
applications: {
VagabondRollDialog,
SkillCheckDialog,
AttackRollDialog,
SaveRollDialog,
DodgeRollDialog,
BlockRollDialog,
SpellCastDialog,
FavorHinderDebug,
},
sheets: {
VagabondActorSheet,
VagabondCharacterSheet,
VagabondNPCSheet,
VagabondItemSheet,
},
};
// Register Actor data models
CONFIG.Actor.dataModels = {
character: CharacterData,
npc: NPCData,
};
// Register Item data models
CONFIG.Item.dataModels = {
ancestry: AncestryData,
class: ClassData,
spell: SpellData,
perk: PerkData,
weapon: WeaponData,
armor: ArmorData,
equipment: EquipmentData,
feature: FeatureData,
status: StatusData,
};
// Define custom Document classes
CONFIG.Actor.documentClass = VagabondActor;
CONFIG.Item.documentClass = VagabondItem;
// Register Actor sheet classes
Actors.unregisterSheet("core", ActorSheet);
Actors.registerSheet("vagabond", VagabondCharacterSheet, {
types: ["character"],
makeDefault: true,
label: "VAGABOND.SheetCharacter",
});
Actors.registerSheet("vagabond", VagabondNPCSheet, {
types: ["npc"],
makeDefault: true,
label: "VAGABOND.SheetNPC",
});
// Register Item sheet classes
Items.unregisterSheet("core", ItemSheet);
Items.registerSheet("vagabond", VagabondItemSheet, {
makeDefault: true,
label: "VAGABOND.SheetItem",
});
// Preload Handlebars templates
await preloadHandlebarsTemplates();
});
/* -------------------------------------------- */
/* Ready Hook */
/* -------------------------------------------- */
/**
* Ready hook - runs when Foundry is fully loaded
*/
Hooks.once("ready", async () => {
// eslint-disable-next-line no-console
console.log("Vagabond RPG | System Ready");
// Display welcome message for GMs
if (game.user.isGM) {
const version = game.system.version;
ui.notifications.info(`Vagabond RPG v${version} - System loaded successfully!`);
// Create development macros if they don't exist
await _createDevMacros();
}
});
/**
* Create development/debug macros if they don't already exist.
* @private
*/
async function _createDevMacros() {
// Favor/Hinder Debug macro
const debugMacroName = "Favor/Hinder Debug";
const existingMacro = game.macros.find((m) => m.name === debugMacroName);
if (!existingMacro) {
await Macro.create({
name: debugMacroName,
type: "script",
img: "icons/svg/bug.svg",
command: "game.vagabond.applications.FavorHinderDebug.open();",
flags: { vagabond: { systemMacro: true } },
});
// eslint-disable-next-line no-console
console.log("Vagabond RPG | Created Favor/Hinder Debug macro");
}
// Skill Check macro
const skillMacroName = "Skill Check";
const existingSkillMacro = game.macros.find((m) => m.name === skillMacroName);
if (!existingSkillMacro) {
await Macro.create({
name: skillMacroName,
type: "script",
img: "icons/svg/d20.svg",
command: `// Opens skill check dialog for selected token or prompts to select actor
const actor = canvas.tokens.controlled[0]?.actor
|| game.actors.find(a => a.type === "character");
if (!actor) {
ui.notifications.warn("Select a token or create a character first");
} else {
game.vagabond.applications.SkillCheckDialog.prompt(actor);
}`,
flags: { vagabond: { systemMacro: true } },
});
// eslint-disable-next-line no-console
console.log("Vagabond RPG | Created Skill Check macro");
}
// Attack Roll macro
const attackMacroName = "Attack Roll";
const existingAttackMacro = game.macros.find((m) => m.name === attackMacroName);
if (!existingAttackMacro) {
await Macro.create({
name: attackMacroName,
type: "script",
img: "icons/svg/sword.svg",
command: `// Opens attack roll dialog for selected token
const actor = canvas.tokens.controlled[0]?.actor
|| game.actors.find(a => a.type === "character");
if (!actor) {
ui.notifications.warn("Select a token or create a character first");
} else {
game.vagabond.applications.AttackRollDialog.prompt(actor);
}`,
flags: { vagabond: { systemMacro: true } },
});
// eslint-disable-next-line no-console
console.log("Vagabond RPG | Created Attack Roll macro");
}
// Save Roll macro
const saveMacroName = "Save Roll";
const existingSaveMacro = game.macros.find((m) => m.name === saveMacroName);
if (!existingSaveMacro) {
await Macro.create({
name: saveMacroName,
type: "script",
img: "icons/svg/shield.svg",
command: `// Opens save roll dialog for selected token
const actor = canvas.tokens.controlled[0]?.actor
|| game.actors.find(a => a.type === "character");
if (!actor) {
ui.notifications.warn("Select a token or create a character first");
} else {
game.vagabond.applications.SaveRollDialog.prompt(actor);
}`,
flags: { vagabond: { systemMacro: true } },
});
// eslint-disable-next-line no-console
console.log("Vagabond RPG | Created Save Roll macro");
}
// Cast Spell macro
const castMacroName = "Cast Spell";
const existingCastMacro = game.macros.find((m) => m.name === castMacroName);
if (!existingCastMacro) {
await Macro.create({
name: castMacroName,
type: "script",
img: "icons/svg/lightning.svg",
command: `// Opens spell cast dialog for selected token
const actor = canvas.tokens.controlled[0]?.actor
|| game.actors.find(a => a.type === "character");
if (!actor) {
ui.notifications.warn("Select a token or create a character first");
} else {
game.vagabond.applications.SpellCastDialog.prompt(actor);
}`,
flags: { vagabond: { systemMacro: true } },
});
// eslint-disable-next-line no-console
console.log("Vagabond RPG | Created Cast Spell macro");
}
// Morale Check macro
const moraleMacroName = "Morale Check";
const existingMoraleMacro = game.macros.find((m) => m.name === moraleMacroName);
if (!existingMoraleMacro) {
await Macro.create({
name: moraleMacroName,
type: "script",
img: "icons/svg/skull.svg",
command: `// Roll morale for selected NPC tokens
// Uses lowest morale score if multiple NPCs selected (group check)
const VagabondActor = CONFIG.Actor.documentClass;
VagabondActor.rollGroupMorale({ trigger: "manual" });`,
flags: { vagabond: { systemMacro: true } },
});
// eslint-disable-next-line no-console
console.log("Vagabond RPG | Created Morale Check macro");
}
}
/* -------------------------------------------- */
/* Handlebars Helpers */
/* -------------------------------------------- */
/**
* Define Handlebars helpers used throughout the system
*/
Hooks.once("init", () => {
// Multiply helper for formulas
Handlebars.registerHelper("multiply", (a, b) => Number(a) * Number(b));
// Subtract helper
Handlebars.registerHelper("subtract", (a, b) => Number(a) - Number(b));
// Calculate difficulty (20 - stat or 20 - stat*2 if trained)
Handlebars.registerHelper("difficulty", (stat, trained) => {
const statValue = Number(stat) || 0;
return trained ? 20 - statValue * 2 : 20 - statValue;
});
// Check if value equals comparison
Handlebars.registerHelper("eq", (a, b) => a === b);
// Check if value is greater than
Handlebars.registerHelper("gt", (a, b) => Number(a) > Number(b));
// Check if value is less than
Handlebars.registerHelper("lt", (a, b) => Number(a) < Number(b));
// Concatenate strings
Handlebars.registerHelper("concat", (...args) => {
// Remove the Handlebars options object from args
args.pop();
return args.join("");
});
// Capitalize first letter
Handlebars.registerHelper("capitalize", (str) => {
if (typeof str !== "string") return "";
return str.charAt(0).toUpperCase() + str.slice(1);
});
// Format number with sign (+/-)
Handlebars.registerHelper("signedNumber", (num) => {
const n = Number(num) || 0;
return n >= 0 ? `+${n}` : `${n}`;
});
// Join array elements with separator
Handlebars.registerHelper("join", (arr, separator) => {
if (!Array.isArray(arr)) return "";
return arr.join(separator);
});
});
/* -------------------------------------------- */
/* Class Feature Automation */
/* -------------------------------------------- */
/**
* Class feature application is handled by VagabondItem._onCreate
* which runs as part of the document creation flow.
*
* Class effect cleanup is handled by VagabondItem._preDelete
* which runs before the document is deleted.
*/
/**
* When a character's level changes, update class features.
* This must be a Hook since level changes don't go through Item lifecycle.
*/
Hooks.on("updateActor", async (actor, changed, _options, userId) => {
// Only process for the updating user
if (game.user.id !== userId) return;
// Only process character level changes
if (actor.type !== "character") return;
if (!foundry.utils.hasProperty(changed, "system.level")) return;
const newLevel = changed.system.level;
const oldLevel = actor._source.system.level; // Get previous value from source
if (newLevel === oldLevel) return;
// Update features for each class
const classes = actor.items.filter((i) => i.type === "class");
for (const classItem of classes) {
await classItem.updateClassFeatures(newLevel, oldLevel);
}
});
/* -------------------------------------------- */
/* NPC Morale System */
/* -------------------------------------------- */
/**
* When an NPC's HP drops to half or below, prompt a morale check.
* Only triggers once per combat (until morale status is reset).
*/
Hooks.on("updateActor", async (actor, changed, options, userId) => {
// Only process for the updating user (GM typically)
if (game.user.id !== userId) return;
// Only process NPC HP changes
if (actor.type !== "npc") return;
if (!foundry.utils.hasProperty(changed, "system.hp.value")) return;
// Skip if already broken or already prompted this combat
const moraleStatus = actor.system.moraleStatus;
if (moraleStatus?.broken || moraleStatus?.checkedThisCombat) return;
// Check if HP dropped to half or below
const newHP = changed.system.hp.value;
const maxHP = actor.system.hp.max;
const halfHP = Math.floor(maxHP / 2);
// Only trigger if crossing the half-HP threshold
// (options._previousHP is set by _preUpdate if we had it, but we'll use current logic)
if (newHP <= halfHP && newHP > 0) {
// Prompt the GM with a morale check button
await actor.promptMoraleCheck("half-hp");
}
});
/**
* Handle clicks on morale roll buttons in chat messages.
* The button is created by VagabondActor.promptMoraleCheck().
*/
Hooks.on("renderChatMessage", (message, html) => {
// Find morale roll buttons in this message
html.find(".morale-roll-btn").on("click", async (event) => {
event.preventDefault();
const button = event.currentTarget;
const actorId = button.dataset.actorId;
const trigger = button.dataset.trigger;
// Get the actor
const actor = game.actors.get(actorId);
if (!actor) {
ui.notifications.error("Could not find actor for morale check");
return;
}
// Roll morale
await actor.rollMorale({ trigger });
// Disable the button after rolling
button.disabled = true;
button.textContent = "Morale Checked";
});
});
/* -------------------------------------------- */
/* Attack Damage Roll Handling */
/* -------------------------------------------- */
/**
* Handle clicks on "Roll Damage" buttons in attack chat cards.
* Rolls damage using the stored weapon data and updates the message.
*/
Hooks.on("renderChatMessage", (message, html) => {
// Find damage roll buttons in this message
html.find(".roll-damage-btn").on("click", async (event) => {
event.preventDefault();
const button = event.currentTarget;
const flags = message.flags?.vagabond;
// Verify this is an attack roll message with pending damage
if (!flags || flags.type !== "attack-roll" || flags.damageRolled) {
ui.notifications.warn("Cannot roll damage for this message");
return;
}
// Disable button immediately to prevent double-clicks
button.disabled = true;
button.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Rolling...';
try {
// Get the actor for roll data
const actor = game.actors.get(flags.actorId);
if (!actor) {
ui.notifications.error("Could not find actor for damage roll");
return;
}
// Import damageRoll function
const { damageRoll } = await import("./dice/rolls.mjs");
// Roll damage
const roll = await damageRoll(flags.damageFormula, {
isCrit: flags.isCrit,
rollData: actor.getRollData(),
});
// Extract dice results for display
const damageDiceResults = [];
for (const term of roll.terms) {
if (term instanceof foundry.dice.terms.Die) {
for (const r of term.results) {
damageDiceResults.push({
faces: term.faces,
result: r.result,
});
}
}
}
// Render updated content with damage
const content = await renderTemplate("systems/vagabond/templates/chat/attack-roll.hbs", {
// Original attack data from message content
...(await _extractAttackDataFromMessage(message)),
// Damage data
hasDamage: true,
damageTotal: roll.total,
damageFormula: roll.formula,
damageDiceResults,
twoHanded: flags.twoHanded,
isCrit: flags.isCrit,
showDamageButton: false,
});
// Update the message with damage rolled
await message.update({
content,
rolls: [...message.rolls, roll],
"flags.vagabond.damageRolled": true,
});
// Play dice sound
AudioHelper.play({ src: CONFIG.sounds.dice }, true);
} catch (error) {
console.error("Vagabond RPG | Error rolling damage:", error);
ui.notifications.error("Failed to roll damage");
button.disabled = false;
button.innerHTML = '<i class="fa-solid fa-burst"></i> Roll Damage';
}
});
});
/**
* Extract attack data from an existing chat message for re-rendering.
* @param {ChatMessage} message - The chat message
* @returns {Promise<Object>} Template data extracted from the message
* @private
*/
async function _extractAttackDataFromMessage(message) {
const flags = message.flags?.vagabond || {};
const content = message.content;
// Parse values from the message HTML
const parser = new DOMParser();
const doc = parser.parseFromString(content, "text/html");
// Extract weapon info
const weaponImg = doc.querySelector(".weapon-icon")?.getAttribute("src") || "";
const weaponName =
doc.querySelector(".weapon-name")?.textContent || flags.weaponName || "Unknown";
const attackLabel = doc.querySelector(".attack-type-badge")?.textContent || "";
// Extract roll result
const total = doc.querySelector(".roll-total")?.textContent || "0";
const status = doc.querySelector(".roll-status .status")?.classList;
const isCrit = status?.contains("critical") || false;
const isFumble = status?.contains("fumble") || false;
const success = status?.contains("success") || status?.contains("critical") || false;
// Extract formula and breakdown
const formula = doc.querySelector(".roll-formula .value")?.textContent || "";
const d20Result = doc.querySelector(".d20-result")?.textContent?.replace(/[^\d]/g, "") || "0";
const favorDieEl = doc.querySelector(".favor-die");
const favorDie = favorDieEl?.textContent?.replace(/[^\d-]/g, "") || null;
const netFavorHinder = favorDieEl?.classList.contains("favor")
? 1
: favorDieEl?.classList.contains("hinder")
? -1
: 0;
const modifier = doc.querySelector(".modifier")?.textContent?.replace(/[^\d-]/g, "") || null;
// Extract difficulty info
const difficulty = doc.querySelector(".difficulty .value")?.textContent || "10";
const critThreshold =
doc.querySelector(".crit-threshold .value")?.textContent?.replace(/[^\d]/g, "") || "20";
// Extract weapon properties
const propertyTags = doc.querySelectorAll(".weapon-properties .property-tag");
const properties = Array.from(propertyTags).map((el) => el.textContent);
// Extract favor/hinder sources
const favorSourcesEl = doc.querySelector(".favor-sources span");
const hinderSourcesEl = doc.querySelector(".hinder-sources span");
const favorSources =
favorSourcesEl?.textContent
?.replace(/^[^:]+:\s*/, "")
.split(", ")
.filter(Boolean) || [];
const hinderSources =
hinderSourcesEl?.textContent
?.replace(/^[^:]+:\s*/, "")
.split(", ")
.filter(Boolean) || [];
return {
weapon: {
id: flags.weaponId,
name: weaponName,
img: weaponImg,
damageType: flags.damageType,
damageTypeLabel: flags.damageTypeLabel,
properties,
},
attackLabel,
total,
d20Result,
favorDie: favorDie ? parseInt(favorDie) : null,
modifier: modifier ? parseInt(modifier) : null,
formula,
difficulty,
critThreshold,
success,
isCrit,
isFumble,
netFavorHinder,
favorSources,
hinderSources,
};
}
/* -------------------------------------------- */
/* Spell Damage Roll Handling */
/* -------------------------------------------- */
/**
* Handle clicks on "Roll Damage" buttons in spell chat cards.
* Rolls damage using the stored spell data and updates the message.
*/
Hooks.on("renderChatMessage", (message, html) => {
// Only handle spell-cast messages
const flags = message.flags?.vagabond;
if (!flags || flags.type !== "spell-cast") return;
// Find damage roll buttons in this message
html.find(".roll-damage-btn").on("click", async (event) => {
event.preventDefault();
const button = event.currentTarget;
// Verify damage hasn't been rolled yet
if (flags.damageRolled) {
ui.notifications.warn("Damage has already been rolled for this spell");
return;
}
// Disable button immediately to prevent double-clicks
button.disabled = true;
button.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Rolling...';
try {
// Get the actor for roll data
const actor = game.actors.get(flags.actorId);
if (!actor) {
ui.notifications.error("Could not find actor for damage roll");
return;
}
// Import damageRoll function
const { damageRoll } = await import("./dice/rolls.mjs");
// Roll damage
const roll = await damageRoll(flags.damageFormula, {
isCrit: flags.isCrit,
rollData: actor.getRollData(),
});
// Render updated content with damage
const content = await renderTemplate("systems/vagabond/templates/chat/spell-cast.hbs", {
// Original spell data from message content
...(await _extractSpellDataFromMessage(message)),
// Damage data
hasDamage: true,
damageTotal: roll.total,
damageFormula: roll.formula,
isCrit: flags.isCrit,
showDamageButton: false,
});
// Update the message with damage rolled
await message.update({
content,
rolls: [...message.rolls, roll],
"flags.vagabond.damageRolled": true,
});
// Play dice sound
AudioHelper.play({ src: CONFIG.sounds.dice }, true);
} catch (error) {
console.error("Vagabond RPG | Error rolling spell damage:", error);
ui.notifications.error("Failed to roll damage");
button.disabled = false;
button.innerHTML = '<i class="fa-solid fa-burst"></i> Roll Damage';
}
});
});
/**
* Extract spell data from an existing chat message for re-rendering.
* @param {ChatMessage} message - The chat message
* @returns {Promise<Object>} Template data extracted from the message
* @private
*/
async function _extractSpellDataFromMessage(message) {
const flags = message.flags?.vagabond || {};
const content = message.content;
// Parse values from the message HTML
const parser = new DOMParser();
const doc = parser.parseFromString(content, "text/html");
// Extract spell info
const spellImg = doc.querySelector(".spell-icon")?.getAttribute("src") || "";
const spellName = doc.querySelector(".spell-name")?.textContent || flags.spellName || "Unknown";
const castingSkillLabel = doc.querySelector(".casting-skill-badge")?.textContent || "";
// Extract cast config
const deliveryLabel =
doc.querySelector(".config-item.delivery .value")?.textContent?.trim() || "";
const durationLabel =
doc.querySelector(".config-item.duration .value")?.textContent?.trim() || "";
const manaCost = doc.querySelector(".config-item.mana-cost .value")?.textContent?.trim() || "0";
// Extract roll result
const total = doc.querySelector(".roll-total")?.textContent || "0";
const status = doc.querySelector(".roll-status .status")?.classList;
const isCrit = status?.contains("critical") || false;
const isFumble = status?.contains("fumble") || false;
const success = status?.contains("success") || status?.contains("critical") || false;
// Extract formula and breakdown
const formula = doc.querySelector(".roll-formula .value")?.textContent || "";
const d20Result = doc.querySelector(".d20-result")?.textContent?.replace(/[^\d]/g, "") || "0";
const favorDieEl = doc.querySelector(".favor-die");
const favorDie = favorDieEl?.textContent?.replace(/[^\d-]/g, "") || null;
const netFavorHinder = favorDieEl?.classList.contains("favor")
? 1
: favorDieEl?.classList.contains("hinder")
? -1
: 0;
const modifier = doc.querySelector(".modifier")?.textContent?.replace(/[^\d-]/g, "") || null;
// Extract difficulty info
const difficulty = doc.querySelector(".difficulty .value")?.textContent || "10";
const critThreshold =
doc.querySelector(".crit-threshold .value")?.textContent?.replace(/[^\d]/g, "") || "20";
// Extract spell effect
const effectText = doc.querySelector(".spell-effect-section .effect-text")?.innerHTML || "";
const critEffectText = doc.querySelector(".spell-effect-section .crit-text")?.innerHTML || "";
const hasEffect = !!effectText;
const includeEffect = hasEffect;
// Extract focus indicator
const isFocus = !!doc.querySelector(".focus-indicator");
// Extract favor/hinder sources
const favorSourcesEl = doc.querySelector(".favor-sources span");
const hinderSourcesEl = doc.querySelector(".hinder-sources span");
const favorSources =
favorSourcesEl?.textContent
?.replace(/^[^:]+:\s*/, "")
.split(", ")
.filter(Boolean) || [];
const hinderSources =
hinderSourcesEl?.textContent
?.replace(/^[^:]+:\s*/, "")
.split(", ")
.filter(Boolean) || [];
return {
spell: {
id: flags.spellId,
name: spellName,
img: spellImg,
damageType: flags.damageType,
damageTypeLabel: flags.damageTypeLabel,
effect: effectText,
critEffect: critEffectText,
isDamaging: !!flags.damageFormula,
},
castingSkillLabel,
deliveryLabel,
durationLabel,
isFocus,
manaCost,
total,
d20Result,
favorDie: favorDie ? parseInt(favorDie) : null,
modifier: modifier ? parseInt(modifier) : null,
formula,
difficulty,
critThreshold,
success,
isCrit,
isFumble,
netFavorHinder,
favorSources,
hinderSources,
hasEffect,
includeEffect,
};
}
/* -------------------------------------------- */
/* Quench Test Registration */
/* -------------------------------------------- */
/**
* Register tests with the Quench testing framework if available.
* Quench provides in-Foundry testing using Mocha + Chai.
* @see https://github.com/Ethaks/FVTT-Quench
*/
Hooks.once("quenchReady", (quenchRunner) => {
registerQuenchTests(quenchRunner);
});
/* -------------------------------------------- */
/* Hot Reload Support (Development) */
/* -------------------------------------------- */
if (import.meta.hot) {
import.meta.hot.accept((_newModule) => {
// eslint-disable-next-line no-console
console.log("Vagabond RPG | Hot reload triggered");
});
}