Implement attack and save roll systems with difficulty fix

Phase 2 Tasks 2.6 & 2.7: Complete roll dialog system
- Add AttackRollDialog with weapon selection, grip toggle, attack type display
- Add SaveRollDialog with save type selection, defense options (block/dodge)
- Fix Handlebars template context resolution bug ({{this.difficulty}} pattern)
- Calculate difficulty once in dialog, pass to roll function via options
- Add difficulty/critThreshold pass-through tests for skill checks
- Fix attack check tests: use embedded items, correct damageType to "slashing"
- Add i18n strings for saves, attacks, defense types
- Add chat card and dialog styles for all roll types
- Export all roll dialogs and create system macros

Key technical fix: Handlebars was resolving {{difficulty}} through context
chain to actor.system.skills.X.difficulty (schema default 20) instead of
root template data. Using {{this.difficulty}} explicitly references root.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-12-13 19:52:28 -06:00
parent 463a130c18
commit 27a5f481aa
17 changed files with 1677 additions and 44 deletions

View File

@ -275,19 +275,21 @@
"id": "2.5",
"name": "Implement skill check system",
"description": "Roll dialog with skill selection, favor/hinder toggles, automatic difficulty calculation, crit threshold display",
"completed": false,
"completed": true,
"tested": false,
"priority": "critical",
"dependencies": ["2.2", "2.4"]
"dependencies": ["2.2", "2.4"],
"notes": "Implemented with ApplicationV2, favor/hinder via Active Effects flags, FavorHinderDebug panel for testing"
},
{
"id": "2.6",
"name": "Implement attack roll system",
"description": "Weapon attack rolls with stat selection, damage calculation, crit bonus damage, Block/Dodge prompts for targets",
"completed": false,
"completed": true,
"tested": false,
"priority": "critical",
"dependencies": ["2.4", "2.5", "1.11"]
"dependencies": ["2.4", "2.5", "1.11"],
"notes": "AttackRollDialog with weapon selection, damage roll on hit, crit doubles dice. Block/Dodge prompts deferred to Task 7.8"
},
{
"id": "2.7",

View File

@ -228,5 +228,34 @@
"VAGABOND.Formula": "Formula",
"VAGABOND.SelectActor": "Select Actor",
"VAGABOND.Save": "Save"
"VAGABOND.Save": "Save",
"VAGABOND.Attack": "Attack",
"VAGABOND.AttackRoll": "Attack Roll",
"VAGABOND.AttackType": "Attack Type",
"VAGABOND.Weapon": "Weapon",
"VAGABOND.SelectWeapon": "Select Weapon...",
"VAGABOND.SelectWeaponFirst": "Please select a weapon first",
"VAGABOND.NoWeaponsAvailable": "No weapons available",
"VAGABOND.Unequipped": "unequipped",
"VAGABOND.Hit": "Hit!",
"VAGABOND.Miss": "Miss",
"VAGABOND.CriticalHit": "Critical Hit!",
"VAGABOND.TwoHanded": "Two-Handed",
"VAGABOND.TwoHandedGrip": "Use Two-Handed Grip",
"VAGABOND.Unarmed": "Unarmed",
"VAGABOND.Fist": "Fist",
"VAGABOND.SaveRoll": "Save Roll",
"VAGABOND.SaveType": "Save Type",
"VAGABOND.SelectSave": "Select Save",
"VAGABOND.SelectSaveFirst": "Please select a save type first",
"VAGABOND.Stats": "Stats",
"VAGABOND.DefenseType": "Defense Type",
"VAGABOND.RequiresShield": "Requires an equipped shield",
"VAGABOND.BlockInfo": "Block uses your shield to reduce incoming damage",
"VAGABOND.DodgeInfo": "Dodge allows you to avoid the attack entirely",
"VAGABOND.BlockedWith": "Blocked with shield",
"VAGABOND.DodgedAttack": "Dodged the attack",
"VAGABOND.CriticalSuccess": "Critical Success!"
}

View File

@ -5,4 +5,6 @@
export { default as VagabondRollDialog } from "./base-roll-dialog.mjs";
export { default as SkillCheckDialog } from "./skill-check-dialog.mjs";
export { default as AttackRollDialog } from "./attack-roll-dialog.mjs";
export { default as SaveRollDialog } from "./save-roll-dialog.mjs";
export { default as FavorHinderDebug } from "./favor-hinder-debug.mjs";

View File

@ -0,0 +1,457 @@
/**
* Attack Roll Dialog for Vagabond RPG
*
* Extends VagabondRollDialog to handle attack roll configuration:
* - Weapon selection from equipped weapons
* - Attack type display (Melee/Brawl/Ranged/Finesse)
* - Difficulty/crit threshold calculation
* - Two-handed toggle for versatile weapons
* - Damage roll on hit
*
* @extends VagabondRollDialog
*/
import VagabondRollDialog from "./base-roll-dialog.mjs";
import { attackCheck, damageRoll } from "../dice/rolls.mjs";
export default class AttackRollDialog extends VagabondRollDialog {
/**
* @param {VagabondActor} actor - The actor making the roll
* @param {Object} options - Dialog options
* @param {string} [options.weaponId] - Pre-selected weapon ID
*/
constructor(actor, options = {}) {
super(actor, options);
this.weaponId = options.weaponId || null;
this.twoHanded = false;
// Auto-select first equipped weapon if none specified, otherwise default to unarmed
if (!this.weaponId) {
const equippedWeapons = this._getEquippedWeapons();
if (equippedWeapons.length > 0) {
this.weaponId = equippedWeapons[0].id;
} else {
this.weaponId = "unarmed";
}
}
// Load automatic favor/hinder for attacks
this.rollConfig.autoFavorHinder = actor.getNetFavorHinder({ isAttack: true });
}
/* -------------------------------------------- */
/* Static Properties */
/* -------------------------------------------- */
/** @override */
static DEFAULT_OPTIONS = foundry.utils.mergeObject(
super.DEFAULT_OPTIONS,
{
id: "vagabond-attack-roll-dialog",
window: {
title: "VAGABOND.AttackRoll",
icon: "fa-solid fa-swords",
},
position: {
width: 380,
},
},
{ inplace: false }
);
/** @override */
static PARTS = {
form: {
template: "systems/vagabond/templates/dialog/attack-roll.hbs",
},
};
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/** @override */
get title() {
if (this.weapon) {
return `${game.i18n.localize("VAGABOND.Attack")}: ${this.weapon.name}`;
}
return game.i18n.localize("VAGABOND.AttackRoll");
}
/**
* Get the currently selected weapon.
* Returns a virtual "Unarmed" weapon object if weaponId is "unarmed".
* @returns {VagabondItem|Object|null}
*/
get weapon() {
if (!this.weaponId) return null;
// Return virtual unarmed weapon
if (this.weaponId === "unarmed") {
return this._getUnarmedWeapon();
}
return this.actor.items.get(this.weaponId) || null;
}
/**
* Get the virtual unarmed strike weapon.
* All characters have access to this attack.
* @returns {Object} Virtual weapon object matching weapon item interface
* @private
*/
_getUnarmedWeapon() {
return {
id: "unarmed",
name: game.i18n.localize("VAGABOND.Unarmed"),
img: "icons/skills/melee/unarmed-punch-fist.webp",
type: "weapon",
system: {
damage: "1",
damageType: "blunt",
bonusDamage: 0,
grip: "fist",
attackType: "brawl",
range: { value: 0, units: "ft" },
properties: {
finesse: false,
thrown: false,
cleave: false,
reach: false,
loading: false,
brawl: true,
crude: false,
versatile: false,
},
equipped: true,
slots: 0,
value: 0,
critThreshold: null,
// Methods to match weapon item interface
getAttackStat: () => "might",
getDamageFormula: () => "1",
getActiveProperties: () => ["brawl"],
},
};
}
/**
* Get the attack data for the current weapon.
* @returns {Object|null}
*/
get attackData() {
const weapon = this.weapon;
if (!weapon) return null;
const attackType = weapon.system.attackType || "melee";
const attackConfig = CONFIG.VAGABOND?.attackTypes?.[attackType];
if (!attackConfig) return null;
const statKey = weapon.system.getAttackStat?.() || attackConfig.stat;
const statValue = this.actor.system.stats?.[statKey]?.value || 0;
// Attacks use trained difficulty (20 - stat × 2)
const difficulty = 20 - statValue * 2;
// Get crit threshold from actor's attack data or weapon override
const actorCritThreshold = this.actor.system.attacks?.[attackType]?.critThreshold || 20;
const weaponCritThreshold = weapon.system.critThreshold;
const critThreshold = weaponCritThreshold ?? actorCritThreshold;
return {
attackType,
attackLabel: game.i18n.localize(attackConfig.label),
statKey,
statLabel: game.i18n.localize(CONFIG.VAGABOND?.stats?.[statKey] || statKey),
statValue,
difficulty,
critThreshold,
};
}
/* -------------------------------------------- */
/* Helper Methods */
/* -------------------------------------------- */
/**
* Get all equipped weapons for this actor.
* @returns {Array<VagabondItem>}
* @private
*/
_getEquippedWeapons() {
return this.actor.items.filter((item) => item.type === "weapon" && item.system.equipped);
}
/**
* Get all weapons (equipped or not) for this actor.
* @returns {Array<VagabondItem>}
* @private
*/
_getAllWeapons() {
return this.actor.items.filter((item) => item.type === "weapon");
}
/**
* Get the damage formula for the current weapon.
* @returns {string}
* @private
*/
_getDamageFormula() {
const weapon = this.weapon;
if (!weapon) return "1d6";
return weapon.system.getDamageFormula?.(this.twoHanded) || weapon.system.damage || "1d6";
}
/* -------------------------------------------- */
/* Data Preparation */
/* -------------------------------------------- */
/** @override */
async _prepareRollContext(_options) {
const context = {};
// Get all weapons for selection (including unarmed)
const allWeapons = this._getAllWeapons();
const unarmed = this._getUnarmedWeapon();
// Build weapons list with unarmed always first
context.weapons = [
{
id: "unarmed",
name: unarmed.name,
img: unarmed.img,
equipped: true,
attackType: unarmed.system.attackType,
damage: unarmed.system.damage,
grip: unarmed.system.grip,
isVersatile: false,
isUnarmed: true,
selected: this.weaponId === "unarmed",
},
...allWeapons.map((w) => ({
id: w.id,
name: w.name,
img: w.img,
equipped: w.system.equipped,
attackType: w.system.attackType,
damage: w.system.damage,
grip: w.system.grip,
isVersatile: w.system.properties?.versatile || false,
isUnarmed: false,
selected: w.id === this.weaponId,
})),
];
context.hasWeapons = true; // Always true now since unarmed is always available
context.selectedWeaponId = this.weaponId;
context.weapon = this.weapon;
// Attack data
const attackData = this.attackData;
if (attackData) {
context.attackType = attackData.attackType;
context.attackLabel = attackData.attackLabel;
context.statLabel = attackData.statLabel;
context.statValue = attackData.statValue;
context.difficulty = attackData.difficulty;
context.critThreshold = attackData.critThreshold;
}
// Versatile weapon handling
const weapon = this.weapon;
if (weapon) {
context.isVersatile = weapon.system.properties?.versatile || false;
context.twoHanded = this.twoHanded;
context.damageFormula = this._getDamageFormula();
context.damageType = weapon.system.damageType;
context.damageTypeLabel = game.i18n.localize(
CONFIG.VAGABOND?.damageTypes?.[weapon.system.damageType] || weapon.system.damageType
);
// Weapon properties
context.properties = weapon.system.getActiveProperties?.() || [];
context.propertyLabels = context.properties.map((p) =>
game.i18n.localize(CONFIG.VAGABOND?.weaponProperties?.[p] || p)
);
}
return context;
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @override */
_onRender(context, options) {
super._onRender(context, options);
// Weapon selection dropdown
const weaponSelect = this.element.querySelector('[name="weaponId"]');
weaponSelect?.addEventListener("change", (event) => {
this.weaponId = event.target.value;
this.twoHanded = false; // Reset two-handed when changing weapon
this.render();
});
// Two-handed toggle for versatile weapons
const twoHandedToggle = this.element.querySelector('[name="twoHanded"]');
twoHandedToggle?.addEventListener("change", (event) => {
this.twoHanded = event.target.checked;
this.render();
});
}
/** @override */
async _executeRoll() {
const weapon = this.weapon;
if (!weapon) {
ui.notifications.warn(game.i18n.localize("VAGABOND.SelectWeaponFirst"));
return;
}
// Perform the attack check
const result = await attackCheck(this.actor, weapon, {
favorHinder: this.netFavorHinder,
modifier: this.rollConfig.modifier,
});
// Roll damage if the attack hit
let damageResult = null;
if (result.success) {
const damageFormula = this._getDamageFormula();
damageResult = await damageRoll(damageFormula, {
isCrit: result.isCrit,
rollData: this.actor.getRollData(),
});
}
// Send to chat with custom template
await this._sendToChat(result, damageResult);
}
/**
* Send the roll result to chat.
*
* @param {VagabondRollResult} result - The attack roll result
* @param {Roll|null} damageResult - The damage roll (if hit)
* @returns {Promise<ChatMessage>}
* @private
*/
async _sendToChat(result, damageResult) {
const weapon = this.weapon;
const attackData = this.attackData;
// Prepare template data
const templateData = {
actor: this.actor,
weapon: {
id: weapon.id,
name: weapon.name,
img: weapon.img,
attackType: weapon.system.attackType,
damageType: weapon.system.damageType,
damageTypeLabel: game.i18n.localize(
CONFIG.VAGABOND?.damageTypes?.[weapon.system.damageType] || weapon.system.damageType
),
properties: weapon.system.getActiveProperties?.() || [],
},
attackLabel: attackData?.attackLabel,
difficulty: result.difficulty,
critThreshold: result.critThreshold,
total: result.total,
d20Result: result.d20Result,
favorDie: result.favorDie,
modifier: this.rollConfig.modifier,
success: result.success,
isCrit: result.isCrit,
isFumble: result.isFumble,
formula: result.roll.formula,
netFavorHinder: this.netFavorHinder,
favorSources: this.rollConfig.autoFavorHinder.favorSources,
hinderSources: this.rollConfig.autoFavorHinder.hinderSources,
// Damage info
hasDamage: !!damageResult,
damageTotal: damageResult?.total,
damageFormula: damageResult?.formula,
twoHanded: this.twoHanded,
};
// Render the chat card template
const content = await renderTemplate(
"systems/vagabond/templates/chat/attack-roll.hbs",
templateData
);
// Collect all rolls
const rolls = [result.roll];
if (damageResult) rolls.push(damageResult);
// Create the chat message
const chatData = {
user: game.user.id,
speaker: ChatMessage.getSpeaker({ actor: this.actor }),
content,
rolls,
sound: CONFIG.sounds.dice,
};
return ChatMessage.create(chatData);
}
/* -------------------------------------------- */
/* Static Methods */
/* -------------------------------------------- */
/**
* Create and render an attack roll dialog.
*
* @param {VagabondActor} actor - The actor making the roll
* @param {string} [weaponId] - Optional pre-selected weapon ID
* @param {Object} [options] - Additional options
* @returns {Promise<AttackRollDialog>}
*/
static async prompt(actor, weaponId = null, options = {}) {
return this.create(actor, { ...options, weaponId });
}
/**
* Perform a quick attack roll without showing the dialog.
*
* @param {VagabondActor} actor - The actor making the roll
* @param {VagabondItem} weapon - The weapon to attack with
* @param {Object} [options] - Roll options
* @returns {Promise<Object>} Attack and damage results
*/
static async quickRoll(actor, weapon, options = {}) {
// Get automatic favor/hinder
const autoFavorHinder = actor.getNetFavorHinder({ isAttack: true });
// Perform the attack
const result = await attackCheck(actor, weapon, {
favorHinder: options.favorHinder ?? autoFavorHinder.net,
modifier: options.modifier || 0,
});
// Roll damage if hit
let damageResult = null;
if (result.success) {
const damageFormula =
weapon.system.getDamageFormula?.(options.twoHanded) || weapon.system.damage || "1d6";
damageResult = await damageRoll(damageFormula, {
isCrit: result.isCrit,
rollData: actor.getRollData(),
});
}
// Create temporary dialog for chat output
const tempDialog = new this(actor, { weaponId: weapon.id });
tempDialog.rollConfig.autoFavorHinder = autoFavorHinder;
tempDialog.twoHanded = options.twoHanded || false;
await tempDialog._sendToChat(result, damageResult);
return { attack: result, damage: damageResult };
}
}

View File

@ -0,0 +1,336 @@
/**
* Save Roll Dialog for Vagabond RPG
*
* Extends VagabondRollDialog to handle saving throw configuration:
* - Save type selection (Reflex, Endure, Will)
* - Displays calculated difficulty from stats
* - Block/Dodge choice for Reflex saves (defense)
* - Favor/Hinder toggles
*
* Save Difficulties:
* - Reflex: 20 - DEX - AWR
* - Endure: 20 - MIT - MIT (MIT counts twice)
* - Will: 20 - RSN - PRS
*
* @extends VagabondRollDialog
*/
import VagabondRollDialog from "./base-roll-dialog.mjs";
import { saveRoll } from "../dice/rolls.mjs";
export default class SaveRollDialog extends VagabondRollDialog {
/**
* @param {VagabondActor} actor - The actor making the roll
* @param {Object} options - Dialog options
* @param {string} [options.saveType] - Pre-selected save type
* @param {number} [options.difficulty] - Target difficulty (if known)
* @param {boolean} [options.isDefense=false] - If true, this is a defensive save (show Block/Dodge)
*/
constructor(actor, options = {}) {
super(actor, options);
this.saveType = options.saveType || null;
this.targetDifficulty = options.difficulty || null;
this.isDefense = options.isDefense || false;
this.defenseType = null; // "block" or "dodge" for Reflex defense saves
// Load automatic favor/hinder for this save type
if (this.saveType) {
this.rollConfig.autoFavorHinder = actor.getNetFavorHinder({ saveType: this.saveType });
}
}
/* -------------------------------------------- */
/* Static Properties */
/* -------------------------------------------- */
/** @override */
static DEFAULT_OPTIONS = foundry.utils.mergeObject(
super.DEFAULT_OPTIONS,
{
id: "vagabond-save-roll-dialog",
window: {
title: "VAGABOND.SaveRoll",
icon: "fa-solid fa-shield-halved",
},
position: {
width: 360,
},
},
{ inplace: false }
);
/** @override */
static PARTS = {
form: {
template: "systems/vagabond/templates/dialog/save-roll.hbs",
},
};
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/** @override */
get title() {
if (this.saveType) {
const saveLabel = CONFIG.VAGABOND?.saves?.[this.saveType]?.label || this.saveType;
return `${game.i18n.localize(saveLabel)} ${game.i18n.localize("VAGABOND.Save")}`;
}
return game.i18n.localize("VAGABOND.SaveRoll");
}
/**
* Get the current save data from the actor.
* @returns {Object|null}
*/
get saveData() {
if (!this.saveType) return null;
return this.actor.system.saves?.[this.saveType] || null;
}
/**
* Get the difficulty for this save.
* Uses targetDifficulty if provided, otherwise uses actor's calculated difficulty.
* @returns {number}
*/
get difficulty() {
// If a specific difficulty was provided (from an effect), use that
if (this.targetDifficulty !== null) {
return this.targetDifficulty;
}
// Otherwise use the actor's calculated save difficulty
return this.saveData?.difficulty || 10;
}
/* -------------------------------------------- */
/* Data Preparation */
/* -------------------------------------------- */
/** @override */
async _prepareRollContext(_options) {
const context = {};
// Available saves for dropdown
context.saves = Object.entries(CONFIG.VAGABOND?.saves || {}).map(([id, config]) => {
const saveData = this.actor.system.saves?.[id] || {};
const stats = config.stats || [];
const statLabels = stats.map((s) => game.i18n.localize(CONFIG.VAGABOND?.statsAbbr?.[s] || s));
return {
id,
label: game.i18n.localize(config.label),
stats: statLabels.join(" + "),
difficulty: saveData.difficulty || 10,
selected: id === this.saveType,
};
});
context.selectedSaveType = this.saveType;
context.saveData = this.saveData;
if (this.saveData) {
context.difficulty = this.difficulty;
// Get the associated stats
const saveConfig = CONFIG.VAGABOND?.saves?.[this.saveType];
if (saveConfig?.stats) {
context.statLabels = saveConfig.stats.map((s) =>
game.i18n.localize(CONFIG.VAGABOND?.stats?.[s] || s)
);
context.statValues = saveConfig.stats.map((s) => this.actor.system.stats?.[s]?.value || 0);
}
}
// Defense options for Reflex saves
context.isDefense = this.isDefense;
context.showDefenseOptions = this.isDefense && this.saveType === "reflex";
context.defenseType = this.defenseType;
// Check if actor has a shield equipped for Block option
context.hasShield = this._hasShieldEquipped();
return context;
}
/**
* Check if the actor has a shield equipped.
* @returns {boolean}
* @private
*/
_hasShieldEquipped() {
return this.actor.items.some(
(item) => item.type === "armor" && item.system.armorType === "shield" && item.system.equipped
);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @override */
_onRender(context, options) {
super._onRender(context, options);
// Save type selection dropdown
const saveSelect = this.element.querySelector('[name="saveType"]');
saveSelect?.addEventListener("change", (event) => {
this.saveType = event.target.value;
this.rollConfig.autoFavorHinder = this.actor.getNetFavorHinder({
saveType: this.saveType,
});
this.defenseType = null; // Reset defense type when save changes
this.render();
});
// Defense type selection (Block/Dodge)
const defenseButtons = this.element.querySelectorAll("[data-defense]");
for (const btn of defenseButtons) {
btn.addEventListener("click", (event) => {
this.defenseType = event.currentTarget.dataset.defense;
this.render();
});
}
}
/** @override */
async _executeRoll() {
if (!this.saveType) {
ui.notifications.warn(game.i18n.localize("VAGABOND.SelectSaveFirst"));
return;
}
// Perform the save roll
const result = await saveRoll(this.actor, this.saveType, this.difficulty, {
favorHinder: this.netFavorHinder,
modifier: this.rollConfig.modifier,
isBlock: this.defenseType === "block",
isDodge: this.defenseType === "dodge",
});
// Send to chat with custom template
await this._sendToChat(result);
}
/**
* Send the roll result to chat.
*
* @param {VagabondRollResult} result - The roll result
* @returns {Promise<ChatMessage>}
* @private
*/
async _sendToChat(result) {
const saveConfig = CONFIG.VAGABOND?.saves?.[this.saveType];
const saveLabel = game.i18n.localize(saveConfig?.label || this.saveType);
// Prepare template data
const templateData = {
actor: this.actor,
saveType: this.saveType,
saveLabel,
stats: saveConfig?.stats?.map((s) =>
game.i18n.localize(CONFIG.VAGABOND?.statsAbbr?.[s] || s)
),
difficulty: result.difficulty,
total: result.total,
d20Result: result.d20Result,
favorDie: result.favorDie,
modifier: this.rollConfig.modifier,
success: result.success,
isCrit: result.isCrit,
isFumble: result.isFumble,
formula: result.roll.formula,
netFavorHinder: this.netFavorHinder,
favorSources: this.rollConfig.autoFavorHinder.favorSources,
hinderSources: this.rollConfig.autoFavorHinder.hinderSources,
// Defense info
isDefense: this.isDefense,
defenseType: this.defenseType,
defenseLabel: this.defenseType
? game.i18n.localize(
`VAGABOND.${this.defenseType.charAt(0).toUpperCase() + this.defenseType.slice(1)}`
)
: null,
};
// Render the chat card template
const content = await renderTemplate(
"systems/vagabond/templates/chat/save-roll.hbs",
templateData
);
// Create the chat message
const chatData = {
user: game.user.id,
speaker: ChatMessage.getSpeaker({ actor: this.actor }),
content,
rolls: [result.roll],
sound: CONFIG.sounds.dice,
};
return ChatMessage.create(chatData);
}
/* -------------------------------------------- */
/* Static Methods */
/* -------------------------------------------- */
/**
* Create and render a save roll dialog.
*
* @param {VagabondActor} actor - The actor making the roll
* @param {string} [saveType] - Optional pre-selected save type
* @param {Object} [options] - Additional options
* @returns {Promise<SaveRollDialog>}
*/
static async prompt(actor, saveType = null, options = {}) {
return this.create(actor, { ...options, saveType });
}
/**
* Prompt for a defensive save (Block or Dodge).
*
* @param {VagabondActor} actor - The actor making the defense
* @param {number} difficulty - The attack roll to beat
* @param {Object} [options] - Additional options
* @returns {Promise<SaveRollDialog>}
*/
static async promptDefense(actor, difficulty, options = {}) {
return this.create(actor, {
...options,
saveType: "reflex",
difficulty,
isDefense: true,
});
}
/**
* Perform a quick save roll without showing the dialog.
*
* @param {VagabondActor} actor - The actor making the roll
* @param {string} saveType - The save type
* @param {number} [difficulty] - Target difficulty (uses actor's save if not provided)
* @param {Object} [options] - Roll options
* @returns {Promise<VagabondRollResult>}
*/
static async quickRoll(actor, saveType, difficulty = null, options = {}) {
// Get automatic favor/hinder
const autoFavorHinder = actor.getNetFavorHinder({ saveType });
// Use provided difficulty or actor's calculated save difficulty
const targetDifficulty = difficulty ?? actor.system.saves?.[saveType]?.difficulty ?? 10;
// Perform the roll
const result = await saveRoll(actor, saveType, targetDifficulty, {
favorHinder: options.favorHinder ?? autoFavorHinder.net,
modifier: options.modifier || 0,
});
// Create temporary dialog for chat output
const tempDialog = new this(actor, { saveType, difficulty: targetDifficulty });
tempDialog.rollConfig.autoFavorHinder = autoFavorHinder;
await tempDialog._sendToChat(result);
return result;
}
}

View File

@ -87,12 +87,16 @@ export default class SkillCheckDialog extends VagabondRollDialog {
// Available skills for dropdown (if no skill pre-selected)
context.skills = Object.entries(CONFIG.VAGABOND?.skills || {}).map(([id, config]) => {
const skillData = this.actor.system.skills?.[id] || {};
const statValue = this.actor.system.stats?.[config.stat]?.value || 0;
const trained = skillData.trained || false;
// Calculate difficulty directly: 20 - stat (untrained) or 20 - stat×2 (trained)
const difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
return {
id,
label: game.i18n.localize(config.label),
stat: config.stat,
trained: skillData.trained || false,
difficulty: skillData.difficulty || 20,
trained,
difficulty,
critThreshold: skillData.critThreshold || 20,
selected: id === this.skillId,
};
@ -103,15 +107,25 @@ export default class SkillCheckDialog extends VagabondRollDialog {
context.skillData = this.skillData;
if (this.skillData) {
context.difficulty = this.skillData.difficulty;
context.critThreshold = this.skillData.critThreshold || 20;
context.trained = this.skillData.trained;
// Get the associated stat
// Get the associated stat and calculate difficulty
const statKey = CONFIG.VAGABOND?.skills?.[this.skillId]?.stat;
const statValue = this.actor.system.stats?.[statKey]?.value || 0;
const trained = this.skillData.trained;
// Calculate difficulty: 20 - stat (untrained) or 20 - stat×2 (trained)
const difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
const critThreshold = this.skillData.critThreshold || 20;
// Store on instance for use in _executeRoll
this._calculatedDifficulty = difficulty;
this._calculatedCritThreshold = critThreshold;
context.difficulty = difficulty;
context.critThreshold = critThreshold;
context.trained = trained;
if (statKey) {
context.statLabel = game.i18n.localize(CONFIG.VAGABOND?.stats?.[statKey]?.label || statKey);
context.statValue = this.actor.system.stats?.[statKey]?.value || 0;
context.statValue = statValue;
}
}
@ -142,8 +156,10 @@ export default class SkillCheckDialog extends VagabondRollDialog {
return;
}
// Perform the skill check
// Perform the skill check with pre-calculated difficulty
const result = await skillCheck(this.actor, this.skillId, {
difficulty: this._calculatedDifficulty,
critThreshold: this._calculatedCritThreshold,
favorHinder: this.netFavorHinder,
modifier: this.rollConfig.modifier,
});

View File

@ -120,54 +120,66 @@ export default class CharacterData extends VagabondActorBase {
}),
}),
// 12 skills with training and custom crit thresholds
// 12 skills with training, difficulty (computed), and custom crit thresholds
skills: new fields.SchemaField({
arcana: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
brawl: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
craft: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
detect: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
finesse: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
influence: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
leadership: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
medicine: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
mysticism: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
performance: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
sneak: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
survival: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
}),
@ -662,9 +674,6 @@ export default class CharacterData extends VagabondActorBase {
// Calculate difficulty: 20 - stat (untrained) or 20 - stat×2 (trained)
skillData.difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
// Store the associated stat for reference
skillData.stat = statKey;
}
}

View File

@ -126,9 +126,17 @@ export async function skillCheck(actor, skillId, options = {}) {
throw new Error(`Actor does not have skill: ${skillId}`);
}
// Get difficulty from calculated value
const difficulty = skillData.difficulty;
const critThreshold = skillData.critThreshold || 20;
// Use provided difficulty or calculate from stat and training
let difficulty;
if (options.difficulty !== undefined) {
difficulty = options.difficulty;
} else {
const statKey = skillConfig.stat;
const statValue = system.stats?.[statKey]?.value || 0;
const trained = skillData.trained;
difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
}
const critThreshold = options.critThreshold ?? skillData.critThreshold ?? 20;
// Determine favor/hinder from Active Effect flags or override
const favorHinderResult = actor.getNetFavorHinder?.({ skillId }) ?? { net: 0 };
@ -154,7 +162,7 @@ export async function skillCheck(actor, skillId, options = {}) {
* @returns {Promise<VagabondRollResult>} The roll result
*/
export async function attackCheck(actor, weapon, options = {}) {
const attackType = weapon.system.attackSkill || "melee";
const attackType = weapon.system.attackType || "melee";
const attackConfig = CONFIG.VAGABOND?.attackTypes?.[attackType];
if (!attackConfig) {
@ -162,14 +170,18 @@ export async function attackCheck(actor, weapon, options = {}) {
}
const system = actor.system;
const statKey = attackConfig.stat;
// Use weapon's getAttackStat() if available, otherwise fall back to config
const statKey = weapon.system.getAttackStat?.() || attackConfig.stat;
const statValue = system.stats?.[statKey]?.value || 0;
// Attack difficulty = 20 - stat (attacks are always "trained")
// Attack difficulty = 20 - stat × 2 (attacks are always "trained")
const difficulty = 20 - statValue * 2;
// Get crit threshold from attack data
const critThreshold = system.attacks?.[attackType]?.critThreshold || 20;
// Get crit threshold: weapon override > actor attack data > default
const actorCritThreshold = system.attacks?.[attackType]?.critThreshold || 20;
const weaponCritThreshold = weapon.system.critThreshold;
const critThreshold = weaponCritThreshold ?? actorCritThreshold;
// Determine favor/hinder from Active Effect flags or override
const favorHinderResult = actor.getNetFavorHinder?.({ isAttack: true }) ?? { net: 0 };

View File

@ -232,6 +232,28 @@ export function registerDiceTests(quenchRunner) {
expect(result.difficulty).to.equal(16);
});
it("uses provided difficulty when passed in options", async () => {
/**
* When difficulty is passed via options (e.g., from the dialog),
* it should be used instead of calculating from stats.
* This ensures the dialog-displayed difficulty matches the roll.
*/
const result = await skillCheck(testActor, "arcana", { difficulty: 12 });
// Should use the provided difficulty, not calculate it
expect(result.difficulty).to.equal(12);
});
it("uses provided critThreshold when passed in options", async () => {
/**
* When critThreshold is passed via options (e.g., from the dialog),
* it should override the skill's default critThreshold.
*/
const result = await skillCheck(testActor, "arcana", { critThreshold: 18 });
expect(result.critThreshold).to.equal(18);
});
it("uses skill-specific crit threshold", async () => {
/**
* Skills can have modified crit thresholds from class features.
@ -264,7 +286,6 @@ export function registerDiceTests(quenchRunner) {
const { describe, it, expect, beforeEach, afterEach } = context;
let testActor = null;
let testWeapon = null;
beforeEach(async () => {
testActor = await Actor.create({
@ -287,25 +308,25 @@ export function registerDiceTests(quenchRunner) {
},
level: 1,
},
});
testWeapon = await Item.create({
name: "Test Sword",
type: "weapon",
system: {
damage: "1d8",
attackSkill: "melee",
gripType: "1h",
properties: [],
},
items: [
{
name: "Test Sword",
type: "weapon",
system: {
damage: "1d8",
attackType: "melee",
grip: "1h",
damageType: "slashing",
equipped: true,
},
},
],
});
});
afterEach(async () => {
if (testActor) await testActor.delete();
if (testWeapon) await testWeapon.delete();
testActor = null;
testWeapon = null;
});
describe("Attack Check Rolls", () => {
@ -314,6 +335,7 @@ export function registerDiceTests(quenchRunner) {
* Attack difficulty = 20 - (stat × 2) (attacks are always trained)
* Melee uses Might (5), so difficulty = 20 - 10 = 10
*/
const testWeapon = testActor.items.find((i) => i.type === "weapon");
const result = await attackCheck(testActor, testWeapon);
expect(result.difficulty).to.equal(10);
@ -324,6 +346,7 @@ export function registerDiceTests(quenchRunner) {
* Attack types can have modified crit thresholds.
* Melee attacks have critThreshold: 19 in test data.
*/
const testWeapon = testActor.items.find((i) => i.type === "weapon");
const result = await attackCheck(testActor, testWeapon);
expect(result.critThreshold).to.equal(19);

View File

@ -23,7 +23,13 @@ import {
import { VagabondActor, VagabondItem } from "./documents/_module.mjs";
// Import application classes
import { VagabondRollDialog, SkillCheckDialog, FavorHinderDebug } from "./applications/_module.mjs";
import {
VagabondRollDialog,
SkillCheckDialog,
AttackRollDialog,
SaveRollDialog,
FavorHinderDebug,
} from "./applications/_module.mjs";
// Import sheet classes
// import { VagabondCharacterSheet } from "./sheets/actor-sheet.mjs";
@ -54,6 +60,8 @@ Hooks.once("init", () => {
applications: {
VagabondRollDialog,
SkillCheckDialog,
AttackRollDialog,
SaveRollDialog,
FavorHinderDebug,
},
};
@ -163,6 +171,54 @@ if (!actor) {
// 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");
}
}
/* -------------------------------------------- */

View File

@ -284,6 +284,122 @@
}
}
// Attack roll card specific
.vagabond.chat-card.attack-roll {
.card-header {
display: flex;
align-items: center;
gap: $spacing-3;
.weapon-icon {
width: 32px;
height: 32px;
border-radius: $radius-sm;
border: 1px solid $color-border;
}
.header-text {
flex: 1;
.weapon-name {
margin: 0;
font-size: $font-size-base;
}
.attack-type-badge {
font-size: $font-size-xs;
padding: $spacing-1 $spacing-2;
background-color: rgba($color-accent-primary, 0.2);
color: $color-accent-primary;
border-radius: $radius-full;
font-weight: $font-weight-medium;
}
}
}
.damage-section {
margin-top: $spacing-3;
padding: $spacing-3;
background-color: rgba($color-danger, 0.1);
border: 1px solid rgba($color-danger, 0.3);
border-radius: $radius-md;
&.critical {
background-color: rgba($color-warning, 0.15);
border-color: $color-warning;
.damage-total {
color: $color-warning;
}
}
.damage-header {
@include flex-center;
gap: $spacing-2;
font-weight: $font-weight-semibold;
margin-bottom: $spacing-2;
i {
color: $color-danger;
}
.crit-label {
color: $color-warning;
font-size: $font-size-sm;
}
}
.damage-result {
@include flex-center;
gap: $spacing-2;
.damage-total {
font-family: $font-family-header;
font-size: $font-size-3xl;
font-weight: $font-weight-bold;
color: $color-danger;
line-height: 1;
}
.damage-type {
font-size: $font-size-sm;
color: $color-text-secondary;
text-transform: capitalize;
}
}
.damage-formula {
@include flex-center;
gap: $spacing-2;
margin-top: $spacing-2;
font-family: $font-family-mono;
font-size: $font-size-sm;
color: $color-text-muted;
.grip-indicator {
color: $color-text-secondary;
}
}
}
.weapon-properties {
display: flex;
flex-wrap: wrap;
gap: $spacing-1;
margin-top: $spacing-2;
padding: $spacing-2;
.property-tag {
font-size: $font-size-xs;
padding: $spacing-1 $spacing-2;
background-color: $color-parchment-dark;
border-radius: $radius-full;
color: $color-text-secondary;
text-transform: capitalize;
}
}
}
// Spell card specific
.vagabond.chat-card.spell-card {
.spell-effect {

View File

@ -205,6 +205,116 @@
// Additional skill-specific styles if needed
}
// Attack roll dialog specific
.vagabond.attack-roll-dialog {
.weapon-selection {
@include flex-column;
gap: $spacing-2;
label {
font-weight: $font-weight-semibold;
}
.no-weapons-message {
padding: $spacing-3;
text-align: center;
color: $color-text-muted;
font-style: italic;
}
}
.attack-info {
@include panel;
@include grid(2, $spacing-2);
padding: $spacing-3;
> div {
@include flex-between;
}
.label {
font-size: $font-size-sm;
color: $color-text-muted;
}
.value {
font-weight: $font-weight-medium;
&.difficulty {
font-family: $font-family-header;
font-size: $font-size-lg;
font-weight: $font-weight-bold;
}
&.crit {
color: $color-warning;
}
}
}
.damage-preview {
@include panel;
padding: $spacing-3;
background-color: rgba($color-danger, 0.1);
border-color: rgba($color-danger, 0.3);
.damage-formula {
@include flex-center;
gap: $spacing-2;
.label {
font-size: $font-size-sm;
color: $color-text-muted;
}
.value {
font-family: $font-family-mono;
font-weight: $font-weight-bold;
font-size: $font-size-lg;
}
.damage-type {
font-size: $font-size-sm;
color: $color-text-secondary;
}
}
.weapon-properties {
display: flex;
flex-wrap: wrap;
gap: $spacing-1;
margin-top: $spacing-2;
justify-content: center;
.property-tag {
font-size: $font-size-xs;
padding: $spacing-1 $spacing-2;
background-color: $color-parchment-dark;
border-radius: $radius-full;
color: $color-text-secondary;
text-transform: uppercase;
}
}
}
.versatile-toggle {
@include flex-center;
padding: $spacing-2;
.checkbox-label {
@include flex-center;
gap: $spacing-2;
cursor: pointer;
input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
accent-color: $color-accent-primary;
}
}
}
}
// Legacy dialog styles (for backward compatibility)
.vagabond.dialog.roll-dialog {
.dialog-content {

View File

@ -0,0 +1,112 @@
{{!-- Attack Roll Chat Card Template --}}
{{!-- Displays attack results with weapon info, hit/miss, and damage --}}
<div class="vagabond chat-card attack-roll">
{{!-- Header with Weapon Info --}}
<header class="card-header">
<img src="{{weapon.img}}" alt="{{weapon.name}}" class="weapon-icon">
<div class="header-text">
<h3 class="weapon-name">{{weapon.name}}</h3>
<span class="attack-type-badge">{{attackLabel}}</span>
</div>
</header>
{{!-- Roll Result --}}
<div class="roll-result {{#if isCrit}}critical{{else if isFumble}}fumble{{else if success}}success{{else}}failure{{/if}}">
<div class="roll-total">{{total}}</div>
<div class="roll-status">
{{#if isCrit}}
<span class="status critical">{{localize "VAGABOND.CriticalHit"}}</span>
{{else if isFumble}}
<span class="status fumble">{{localize "VAGABOND.Fumble"}}</span>
{{else if success}}
<span class="status success">{{localize "VAGABOND.Hit"}}</span>
{{else}}
<span class="status failure">{{localize "VAGABOND.Miss"}}</span>
{{/if}}
</div>
</div>
{{!-- Roll Details --}}
<div class="roll-details">
<div class="roll-formula">
<span class="label">{{localize "VAGABOND.Formula"}}:</span>
<span class="value">{{formula}}</span>
</div>
<div class="roll-breakdown">
<span class="d20-result">
<i class="fa-solid fa-dice-d20"></i> {{d20Result}}
</span>
{{#if favorDie}}
<span class="favor-die {{#if (gt netFavorHinder 0)}}favor{{else}}hinder{{/if}}">
<i class="fa-solid fa-dice-d6"></i> {{favorDie}}
</span>
{{/if}}
{{#if modifier}}
<span class="modifier">
{{#if (gt modifier 0)}}+{{/if}}{{modifier}}
</span>
{{/if}}
</div>
</div>
{{!-- Target Info --}}
<div class="target-info">
<div class="difficulty">
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
<span class="value">{{this.difficulty}}</span>
</div>
{{#if (lt this.critThreshold 20)}}
<div class="crit-threshold">
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
<span class="value">{{this.critThreshold}}+</span>
</div>
{{/if}}
</div>
{{!-- Damage Section (if hit) --}}
{{#if hasDamage}}
<div class="damage-section {{#if isCrit}}critical{{/if}}">
<div class="damage-header">
<i class="fa-solid fa-burst"></i>
<span>{{localize "VAGABOND.Damage"}}</span>
{{#if isCrit}}
<span class="crit-label">({{localize "VAGABOND.Critical"}}!)</span>
{{/if}}
</div>
<div class="damage-result">
<span class="damage-total">{{damageTotal}}</span>
<span class="damage-type">{{weapon.damageTypeLabel}}</span>
</div>
<div class="damage-formula">
{{damageFormula}}
{{#if twoHanded}}
<span class="grip-indicator">({{localize "VAGABOND.TwoHanded"}})</span>
{{/if}}
</div>
</div>
{{/if}}
{{!-- Weapon Properties --}}
{{#if weapon.properties.length}}
<div class="weapon-properties">
{{#each weapon.properties}}
<span class="property-tag">{{this}}</span>
{{/each}}
</div>
{{/if}}
{{!-- Favor/Hinder Sources --}}
{{#if favorSources.length}}
<div class="favor-sources">
<i class="fa-solid fa-arrow-up"></i>
<span>{{localize "VAGABOND.Favor"}}: {{#each favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
</div>
{{/if}}
{{#if hinderSources.length}}
<div class="hinder-sources">
<i class="fa-solid fa-arrow-down"></i>
<span>{{localize "VAGABOND.Hinder"}}: {{#each hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
</div>
{{/if}}
</div>

View File

@ -0,0 +1,97 @@
{{!-- Save Roll Chat Card Template --}}
{{!-- Displays save results with stats used and success/fail --}}
<div class="vagabond chat-card save-roll">
{{!-- Header --}}
<header class="card-header">
<h3 class="save-name">
<i class="fa-solid fa-shield-halved"></i>
{{saveLabel}} {{localize "VAGABOND.Save"}}
</h3>
{{#if isDefense}}
<span class="defense-badge {{defenseType}}">{{defenseLabel}}</span>
{{/if}}
</header>
{{!-- Roll Result --}}
<div class="roll-result {{#if isCrit}}critical{{else if isFumble}}fumble{{else if success}}success{{else}}failure{{/if}}">
<div class="roll-total">{{total}}</div>
<div class="roll-status">
{{#if isCrit}}
<span class="status critical">{{localize "VAGABOND.CriticalSuccess"}}</span>
{{else if isFumble}}
<span class="status fumble">{{localize "VAGABOND.Fumble"}}</span>
{{else if success}}
<span class="status success">{{localize "VAGABOND.Success"}}</span>
{{else}}
<span class="status failure">{{localize "VAGABOND.Failure"}}</span>
{{/if}}
</div>
</div>
{{!-- Roll Details --}}
<div class="roll-details">
<div class="roll-formula">
<span class="label">{{localize "VAGABOND.Formula"}}:</span>
<span class="value">{{formula}}</span>
</div>
<div class="roll-breakdown">
<span class="d20-result">
<i class="fa-solid fa-dice-d20"></i> {{d20Result}}
</span>
{{#if favorDie}}
<span class="favor-die {{#if (gt netFavorHinder 0)}}favor{{else}}hinder{{/if}}">
<i class="fa-solid fa-dice-d6"></i> {{favorDie}}
</span>
{{/if}}
{{#if modifier}}
<span class="modifier">
{{#if (gt modifier 0)}}+{{/if}}{{modifier}}
</span>
{{/if}}
</div>
</div>
{{!-- Save Info --}}
<div class="save-info">
<div class="stats-used">
<span class="label">{{localize "VAGABOND.Stats"}}:</span>
<span class="value">
{{#each stats}}
<span class="stat-abbr">{{this}}</span>{{#unless @last}} + {{/unless}}
{{/each}}
</span>
</div>
<div class="difficulty">
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
<span class="value">{{this.difficulty}}</span>
</div>
</div>
{{!-- Defense Info --}}
{{#if isDefense}}
<div class="defense-info-display">
{{#if (eq defenseType 'block')}}
<i class="fa-solid fa-shield"></i>
<span>{{localize "VAGABOND.BlockedWith"}}</span>
{{else if (eq defenseType 'dodge')}}
<i class="fa-solid fa-person-running"></i>
<span>{{localize "VAGABOND.DodgedAttack"}}</span>
{{/if}}
</div>
{{/if}}
{{!-- Favor/Hinder Sources --}}
{{#if favorSources.length}}
<div class="favor-sources">
<i class="fa-solid fa-arrow-up"></i>
<span>{{localize "VAGABOND.Favor"}}: {{#each favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
</div>
{{/if}}
{{#if hinderSources.length}}
<div class="hinder-sources">
<i class="fa-solid fa-arrow-down"></i>
<span>{{localize "VAGABOND.Hinder"}}: {{#each hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
</div>
{{/if}}
</div>

View File

@ -53,12 +53,12 @@
<div class="target-info">
<div class="difficulty">
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
<span class="value">{{difficulty}}</span>
<span class="value">{{this.difficulty}}</span>
</div>
{{#if (lt critThreshold 20)}}
{{#if (lt this.critThreshold 20)}}
<div class="crit-threshold">
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
<span class="value">{{critThreshold}}+</span>
<span class="value">{{this.critThreshold}}+</span>
</div>
{{/if}}
</div>

View File

@ -0,0 +1,126 @@
{{!-- Attack Roll Dialog Template --}}
{{!-- Extends roll-dialog-base with attack-specific content --}}
<div class="roll-dialog-content">
{{!-- Automatic Favor/Hinder from Active Effects --}}
{{#if hasAutoFavor}}
<div class="auto-favor-hinder favor">
<i class="fa-solid fa-arrow-up"></i>
<span>{{localize "VAGABOND.AutoFavor"}}: {{#each autoFavorHinder.favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
</div>
{{/if}}
{{#if hasAutoHinder}}
<div class="auto-favor-hinder hinder">
<i class="fa-solid fa-arrow-down"></i>
<span>{{localize "VAGABOND.AutoHinder"}}: {{#each autoFavorHinder.hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
</div>
{{/if}}
{{!-- Weapon Selection --}}
<div class="weapon-selection">
<label for="weaponId">{{localize "VAGABOND.Weapon"}}</label>
<select name="weaponId">
{{#each rollSpecific.weapons}}
<option value="{{this.id}}" {{#if this.selected}}selected{{/if}}>
{{this.name}}{{#if this.isUnarmed}} ({{localize "VAGABOND.Fist"}}){{else unless this.equipped}} ({{localize "VAGABOND.Unequipped"}}){{/if}}
</option>
{{/each}}
</select>
</div>
{{!-- Attack Info (shown when weapon selected) --}}
{{#if rollSpecific.weapon}}
<div class="attack-info">
<div class="attack-type">
<span class="label">{{localize "VAGABOND.AttackType"}}:</span>
<span class="value">{{rollSpecific.attackLabel}}</span>
</div>
<div class="attack-stat">
<span class="label">{{localize "VAGABOND.Stat"}}:</span>
<span class="value">{{rollSpecific.statLabel}} ({{rollSpecific.statValue}})</span>
</div>
<div class="attack-difficulty">
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
</div>
{{#if (lt rollSpecific.critThreshold 20)}}
<div class="attack-crit">
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
<span class="value crit">{{rollSpecific.critThreshold}}+</span>
</div>
{{/if}}
</div>
{{!-- Damage Preview --}}
<div class="damage-preview">
<div class="damage-formula">
<span class="label">{{localize "VAGABOND.Damage"}}:</span>
<span class="value">{{rollSpecific.damageFormula}}</span>
<span class="damage-type">({{rollSpecific.damageTypeLabel}})</span>
</div>
{{#if rollSpecific.properties.length}}
<div class="weapon-properties">
{{#each rollSpecific.propertyLabels}}
<span class="property-tag">{{this}}</span>
{{/each}}
</div>
{{/if}}
</div>
{{!-- Versatile Weapon Toggle --}}
{{#if rollSpecific.isVersatile}}
<div class="versatile-toggle">
<label class="checkbox-label">
<input type="checkbox" name="twoHanded" {{#if rollSpecific.twoHanded}}checked{{/if}}>
<span>{{localize "VAGABOND.TwoHandedGrip"}}</span>
</label>
</div>
{{/if}}
{{/if}}
{{!-- Favor/Hinder Toggles --}}
<div class="favor-hinder-section">
<label>{{localize "VAGABOND.FavorHinder"}}</label>
<div class="favor-hinder-toggles">
<button type="button" class="favor-btn {{#if (eq config.favorHinder 1)}}active{{/if}}" data-favor="1">
<i class="fa-solid fa-arrow-up"></i>
{{localize "VAGABOND.Favor"}}
</button>
<button type="button" class="hinder-btn {{#if (eq config.favorHinder -1)}}active{{/if}}" data-favor="-1">
<i class="fa-solid fa-arrow-down"></i>
{{localize "VAGABOND.Hinder"}}
</button>
</div>
{{#if (gt netFavorHinder 0)}}
<div class="net-favor-hinder favor">
<i class="fa-solid fa-dice-d6"></i> +d6 {{localize "VAGABOND.Favor"}}
</div>
{{else if (lt netFavorHinder 0)}}
<div class="net-favor-hinder hinder">
<i class="fa-solid fa-dice-d6"></i> -d6 {{localize "VAGABOND.Hinder"}}
</div>
{{/if}}
</div>
{{!-- Situational Modifier --}}
<div class="modifier-section">
<label>{{localize "VAGABOND.SituationalModifier"}}</label>
<div class="modifier-presets">
<button type="button" class="modifier-preset" data-modifier="-5">-5</button>
<button type="button" class="modifier-preset" data-modifier="-1">-1</button>
<button type="button" class="modifier-preset" data-modifier="1">+1</button>
<button type="button" class="modifier-preset" data-modifier="5">+5</button>
</div>
<div class="modifier-input">
<input type="number" name="modifier" value="{{config.modifier}}" placeholder="0">
</div>
</div>
{{!-- Roll Button --}}
<div class="dialog-buttons">
<button type="submit" class="roll-btn" {{#unless rollSpecific.weapon}}disabled{{/unless}}>
<i class="fa-solid fa-dice-d20"></i>
{{localize "VAGABOND.RollAttack"}}
</button>
</div>
</div>

View File

@ -0,0 +1,130 @@
{{!-- Save Roll Dialog Template --}}
{{!-- Extends roll-dialog-base with save-specific content --}}
<div class="roll-dialog-content">
{{!-- Automatic Favor/Hinder from Active Effects --}}
{{#if autoFavorHinder.favorSources.length}}
<div class="auto-favor-hinder favor">
<i class="fa-solid fa-arrow-up"></i>
<span>{{localize "VAGABOND.AutoFavor"}}: {{#each autoFavorHinder.favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
</div>
{{/if}}
{{#if autoFavorHinder.hinderSources.length}}
<div class="auto-favor-hinder hinder">
<i class="fa-solid fa-arrow-down"></i>
<span>{{localize "VAGABOND.AutoHinder"}}: {{#each autoFavorHinder.hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
</div>
{{/if}}
{{!-- Save Type Selection --}}
<div class="save-selection">
<label for="saveType">{{localize "VAGABOND.SaveType"}}</label>
<select name="saveType">
<option value="">-- {{localize "VAGABOND.SelectSave"}} --</option>
{{#each rollSpecific.saves}}
<option value="{{this.id}}" {{#if this.selected}}selected{{/if}}>
{{this.label}} ({{this.stats}})
</option>
{{/each}}
</select>
</div>
{{!-- Save Info (shown when save type selected) --}}
{{#if rollSpecific.saveData}}
<div class="save-info">
<div class="save-stats">
<span class="label">{{localize "VAGABOND.Stats"}}:</span>
<span class="value">
{{#each rollSpecific.statLabels}}
<span class="stat-name">{{this}}</span>{{#unless @last}} + {{/unless}}
{{/each}}
</span>
</div>
<div class="save-difficulty">
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
</div>
</div>
{{!-- Defense Options (Block/Dodge for Reflex) --}}
{{#if rollSpecific.showDefenseOptions}}
<div class="defense-options">
<label>{{localize "VAGABOND.DefenseType"}}</label>
<div class="defense-buttons">
<button type="button" class="defense-btn dodge-btn {{#if (eq rollSpecific.defenseType 'dodge')}}active{{/if}}" data-defense="dodge">
<i class="fa-solid fa-person-running"></i>
{{localize "VAGABOND.Dodge"}}
</button>
{{#if rollSpecific.hasShield}}
<button type="button" class="defense-btn block-btn {{#if (eq rollSpecific.defenseType 'block')}}active{{/if}}" data-defense="block">
<i class="fa-solid fa-shield"></i>
{{localize "VAGABOND.Block"}}
</button>
{{else}}
<button type="button" class="defense-btn block-btn" disabled title="{{localize 'VAGABOND.RequiresShield'}}">
<i class="fa-solid fa-shield"></i>
{{localize "VAGABOND.Block"}}
</button>
{{/if}}
</div>
{{#if rollSpecific.defenseType}}
<div class="defense-info">
{{#if (eq rollSpecific.defenseType 'block')}}
<i class="fa-solid fa-info-circle"></i>
<span>{{localize "VAGABOND.BlockInfo"}}</span>
{{else}}
<i class="fa-solid fa-info-circle"></i>
<span>{{localize "VAGABOND.DodgeInfo"}}</span>
{{/if}}
</div>
{{/if}}
</div>
{{/if}}
{{/if}}
{{!-- Favor/Hinder Toggles --}}
<div class="favor-hinder-section">
<label>{{localize "VAGABOND.FavorHinder"}}</label>
<div class="favor-hinder-toggles">
<button type="button" class="favor-btn {{#if (eq config.favorHinder 1)}}active{{/if}}" data-action="toggle-favor">
<i class="fa-solid fa-arrow-up"></i>
{{localize "VAGABOND.Favor"}}
</button>
<button type="button" class="hinder-btn {{#if (eq config.favorHinder -1)}}active{{/if}}" data-action="toggle-hinder">
<i class="fa-solid fa-arrow-down"></i>
{{localize "VAGABOND.Hinder"}}
</button>
</div>
{{#if (gt netFavorHinder 0)}}
<div class="net-favor-hinder favor">
<i class="fa-solid fa-dice-d6"></i> +d6 {{localize "VAGABOND.Favor"}}
</div>
{{else if (lt netFavorHinder 0)}}
<div class="net-favor-hinder hinder">
<i class="fa-solid fa-dice-d6"></i> -d6 {{localize "VAGABOND.Hinder"}}
</div>
{{/if}}
</div>
{{!-- Situational Modifier --}}
<div class="modifier-section">
<label>{{localize "VAGABOND.SituationalModifier"}}</label>
<div class="modifier-presets">
<button type="button" class="modifier-preset" data-modifier-preset="-5">-5</button>
<button type="button" class="modifier-preset" data-modifier-preset="-1">-1</button>
<button type="button" class="modifier-preset" data-modifier-preset="1">+1</button>
<button type="button" class="modifier-preset" data-modifier-preset="5">+5</button>
</div>
<div class="modifier-input">
<input type="number" name="modifier" value="{{config.modifier}}" placeholder="0">
</div>
</div>
{{!-- Roll Button --}}
<div class="dialog-buttons">
<button type="submit" class="roll-btn" {{#unless rollSpecific.saveData}}disabled{{/unless}}>
<i class="fa-solid fa-shield-halved"></i>
{{localize "VAGABOND.RollSave"}}
</button>
</div>
</div>