- 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>
867 lines
27 KiB
JavaScript
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");
|
|
});
|
|
}
|