/**
* 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,
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-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,
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 = ' 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 = ' Roll Damage';
}
});
});
/**
* Extract attack data from an existing chat message for re-rendering.
* @param {ChatMessage} message - The chat message
* @returns {Promise