diff --git a/PROJECT_ROADMAP.json b/PROJECT_ROADMAP.json index 6db076f..c8d1870 100644 --- a/PROJECT_ROADMAP.json +++ b/PROJECT_ROADMAP.json @@ -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", diff --git a/lang/en.json b/lang/en.json index a524606..6144385 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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!" } diff --git a/module/applications/_module.mjs b/module/applications/_module.mjs index fd2ef8d..cf580e3 100644 --- a/module/applications/_module.mjs +++ b/module/applications/_module.mjs @@ -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"; diff --git a/module/applications/attack-roll-dialog.mjs b/module/applications/attack-roll-dialog.mjs new file mode 100644 index 0000000..34062b2 --- /dev/null +++ b/module/applications/attack-roll-dialog.mjs @@ -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} + * @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} + * @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} + * @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} + */ + 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} 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 }; + } +} diff --git a/module/applications/save-roll-dialog.mjs b/module/applications/save-roll-dialog.mjs new file mode 100644 index 0000000..126bc07 --- /dev/null +++ b/module/applications/save-roll-dialog.mjs @@ -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} + * @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} + */ + 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} + */ + 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} + */ + 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; + } +} diff --git a/module/applications/skill-check-dialog.mjs b/module/applications/skill-check-dialog.mjs index 39eb62c..2a22fe2 100644 --- a/module/applications/skill-check-dialog.mjs +++ b/module/applications/skill-check-dialog.mjs @@ -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, }); diff --git a/module/data/actor/character.mjs b/module/data/actor/character.mjs index a0bfe9c..dd9e7e2 100644 --- a/module/data/actor/character.mjs +++ b/module/data/actor/character.mjs @@ -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; } } diff --git a/module/dice/rolls.mjs b/module/dice/rolls.mjs index 8d677d8..b009cc8 100644 --- a/module/dice/rolls.mjs +++ b/module/dice/rolls.mjs @@ -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} 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 }; diff --git a/module/tests/dice.test.mjs b/module/tests/dice.test.mjs index f4ceff1..75a04db 100644 --- a/module/tests/dice.test.mjs +++ b/module/tests/dice.test.mjs @@ -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); diff --git a/module/vagabond.mjs b/module/vagabond.mjs index e6496f7..0d8e249 100644 --- a/module/vagabond.mjs +++ b/module/vagabond.mjs @@ -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"); + } } /* -------------------------------------------- */ diff --git a/styles/scss/chat/_chat-cards.scss b/styles/scss/chat/_chat-cards.scss index f46f8c8..51982aa 100644 --- a/styles/scss/chat/_chat-cards.scss +++ b/styles/scss/chat/_chat-cards.scss @@ -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 { diff --git a/styles/scss/dialogs/_roll-dialog.scss b/styles/scss/dialogs/_roll-dialog.scss index 36b9b69..00bde67 100644 --- a/styles/scss/dialogs/_roll-dialog.scss +++ b/styles/scss/dialogs/_roll-dialog.scss @@ -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 { diff --git a/templates/chat/attack-roll.hbs b/templates/chat/attack-roll.hbs new file mode 100644 index 0000000..84daa5f --- /dev/null +++ b/templates/chat/attack-roll.hbs @@ -0,0 +1,112 @@ +{{!-- Attack Roll Chat Card Template --}} +{{!-- Displays attack results with weapon info, hit/miss, and damage --}} + +
+ {{!-- Header with Weapon Info --}} +
+ {{weapon.name}} +
+

{{weapon.name}}

+ {{attackLabel}} +
+
+ + {{!-- Roll Result --}} +
+
{{total}}
+
+ {{#if isCrit}} + {{localize "VAGABOND.CriticalHit"}} + {{else if isFumble}} + {{localize "VAGABOND.Fumble"}} + {{else if success}} + {{localize "VAGABOND.Hit"}} + {{else}} + {{localize "VAGABOND.Miss"}} + {{/if}} +
+
+ + {{!-- Roll Details --}} +
+
+ {{localize "VAGABOND.Formula"}}: + {{formula}} +
+
+ + {{d20Result}} + + {{#if favorDie}} + + {{favorDie}} + + {{/if}} + {{#if modifier}} + + {{#if (gt modifier 0)}}+{{/if}}{{modifier}} + + {{/if}} +
+
+ + {{!-- Target Info --}} +
+
+ {{localize "VAGABOND.Difficulty"}}: + {{this.difficulty}} +
+ {{#if (lt this.critThreshold 20)}} +
+ {{localize "VAGABOND.CritThreshold"}}: + {{this.critThreshold}}+ +
+ {{/if}} +
+ + {{!-- Damage Section (if hit) --}} + {{#if hasDamage}} +
+
+ + {{localize "VAGABOND.Damage"}} + {{#if isCrit}} + ({{localize "VAGABOND.Critical"}}!) + {{/if}} +
+
+ {{damageTotal}} + {{weapon.damageTypeLabel}} +
+
+ {{damageFormula}} + {{#if twoHanded}} + ({{localize "VAGABOND.TwoHanded"}}) + {{/if}} +
+
+ {{/if}} + + {{!-- Weapon Properties --}} + {{#if weapon.properties.length}} +
+ {{#each weapon.properties}} + {{this}} + {{/each}} +
+ {{/if}} + + {{!-- Favor/Hinder Sources --}} + {{#if favorSources.length}} +
+ + {{localize "VAGABOND.Favor"}}: {{#each favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} + {{#if hinderSources.length}} +
+ + {{localize "VAGABOND.Hinder"}}: {{#each hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} +
diff --git a/templates/chat/save-roll.hbs b/templates/chat/save-roll.hbs new file mode 100644 index 0000000..7495fee --- /dev/null +++ b/templates/chat/save-roll.hbs @@ -0,0 +1,97 @@ +{{!-- Save Roll Chat Card Template --}} +{{!-- Displays save results with stats used and success/fail --}} + +
+ {{!-- Header --}} +
+

+ + {{saveLabel}} {{localize "VAGABOND.Save"}} +

+ {{#if isDefense}} + {{defenseLabel}} + {{/if}} +
+ + {{!-- Roll Result --}} +
+
{{total}}
+
+ {{#if isCrit}} + {{localize "VAGABOND.CriticalSuccess"}} + {{else if isFumble}} + {{localize "VAGABOND.Fumble"}} + {{else if success}} + {{localize "VAGABOND.Success"}} + {{else}} + {{localize "VAGABOND.Failure"}} + {{/if}} +
+
+ + {{!-- Roll Details --}} +
+
+ {{localize "VAGABOND.Formula"}}: + {{formula}} +
+
+ + {{d20Result}} + + {{#if favorDie}} + + {{favorDie}} + + {{/if}} + {{#if modifier}} + + {{#if (gt modifier 0)}}+{{/if}}{{modifier}} + + {{/if}} +
+
+ + {{!-- Save Info --}} +
+
+ {{localize "VAGABOND.Stats"}}: + + {{#each stats}} + {{this}}{{#unless @last}} + {{/unless}} + {{/each}} + +
+
+ {{localize "VAGABOND.Difficulty"}}: + {{this.difficulty}} +
+
+ + {{!-- Defense Info --}} + {{#if isDefense}} +
+ {{#if (eq defenseType 'block')}} + + {{localize "VAGABOND.BlockedWith"}} + {{else if (eq defenseType 'dodge')}} + + {{localize "VAGABOND.DodgedAttack"}} + {{/if}} +
+ {{/if}} + + {{!-- Favor/Hinder Sources --}} + {{#if favorSources.length}} +
+ + {{localize "VAGABOND.Favor"}}: {{#each favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} + {{#if hinderSources.length}} +
+ + {{localize "VAGABOND.Hinder"}}: {{#each hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} +
diff --git a/templates/chat/skill-roll.hbs b/templates/chat/skill-roll.hbs index 30f66f8..b3565f6 100644 --- a/templates/chat/skill-roll.hbs +++ b/templates/chat/skill-roll.hbs @@ -53,12 +53,12 @@
{{localize "VAGABOND.Difficulty"}}: - {{difficulty}} + {{this.difficulty}}
- {{#if (lt critThreshold 20)}} + {{#if (lt this.critThreshold 20)}}
{{localize "VAGABOND.CritThreshold"}}: - {{critThreshold}}+ + {{this.critThreshold}}+
{{/if}}
diff --git a/templates/dialog/attack-roll.hbs b/templates/dialog/attack-roll.hbs new file mode 100644 index 0000000..8d76181 --- /dev/null +++ b/templates/dialog/attack-roll.hbs @@ -0,0 +1,126 @@ +{{!-- Attack Roll Dialog Template --}} +{{!-- Extends roll-dialog-base with attack-specific content --}} + +
+ {{!-- Automatic Favor/Hinder from Active Effects --}} + {{#if hasAutoFavor}} +
+ + {{localize "VAGABOND.AutoFavor"}}: {{#each autoFavorHinder.favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} + {{#if hasAutoHinder}} +
+ + {{localize "VAGABOND.AutoHinder"}}: {{#each autoFavorHinder.hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} + + {{!-- Weapon Selection --}} +
+ + +
+ + {{!-- Attack Info (shown when weapon selected) --}} + {{#if rollSpecific.weapon}} +
+
+ {{localize "VAGABOND.AttackType"}}: + {{rollSpecific.attackLabel}} +
+
+ {{localize "VAGABOND.Stat"}}: + {{rollSpecific.statLabel}} ({{rollSpecific.statValue}}) +
+
+ {{localize "VAGABOND.Difficulty"}}: + {{rollSpecific.difficulty}} +
+ {{#if (lt rollSpecific.critThreshold 20)}} +
+ {{localize "VAGABOND.CritThreshold"}}: + {{rollSpecific.critThreshold}}+ +
+ {{/if}} +
+ + {{!-- Damage Preview --}} +
+
+ {{localize "VAGABOND.Damage"}}: + {{rollSpecific.damageFormula}} + ({{rollSpecific.damageTypeLabel}}) +
+ {{#if rollSpecific.properties.length}} +
+ {{#each rollSpecific.propertyLabels}} + {{this}} + {{/each}} +
+ {{/if}} +
+ + {{!-- Versatile Weapon Toggle --}} + {{#if rollSpecific.isVersatile}} +
+ +
+ {{/if}} + {{/if}} + + {{!-- Favor/Hinder Toggles --}} +
+ +
+ + +
+ {{#if (gt netFavorHinder 0)}} +
+ +d6 {{localize "VAGABOND.Favor"}} +
+ {{else if (lt netFavorHinder 0)}} +
+ -d6 {{localize "VAGABOND.Hinder"}} +
+ {{/if}} +
+ + {{!-- Situational Modifier --}} +
+ +
+ + + + +
+
+ +
+
+ + {{!-- Roll Button --}} +
+ +
+
diff --git a/templates/dialog/save-roll.hbs b/templates/dialog/save-roll.hbs new file mode 100644 index 0000000..451a14e --- /dev/null +++ b/templates/dialog/save-roll.hbs @@ -0,0 +1,130 @@ +{{!-- Save Roll Dialog Template --}} +{{!-- Extends roll-dialog-base with save-specific content --}} + +
+ {{!-- Automatic Favor/Hinder from Active Effects --}} + {{#if autoFavorHinder.favorSources.length}} +
+ + {{localize "VAGABOND.AutoFavor"}}: {{#each autoFavorHinder.favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} + {{#if autoFavorHinder.hinderSources.length}} +
+ + {{localize "VAGABOND.AutoHinder"}}: {{#each autoFavorHinder.hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} + + {{!-- Save Type Selection --}} +
+ + +
+ + {{!-- Save Info (shown when save type selected) --}} + {{#if rollSpecific.saveData}} +
+
+ {{localize "VAGABOND.Stats"}}: + + {{#each rollSpecific.statLabels}} + {{this}}{{#unless @last}} + {{/unless}} + {{/each}} + +
+
+ {{localize "VAGABOND.Difficulty"}}: + {{rollSpecific.difficulty}} +
+
+ + {{!-- Defense Options (Block/Dodge for Reflex) --}} + {{#if rollSpecific.showDefenseOptions}} +
+ +
+ + {{#if rollSpecific.hasShield}} + + {{else}} + + {{/if}} +
+ {{#if rollSpecific.defenseType}} +
+ {{#if (eq rollSpecific.defenseType 'block')}} + + {{localize "VAGABOND.BlockInfo"}} + {{else}} + + {{localize "VAGABOND.DodgeInfo"}} + {{/if}} +
+ {{/if}} +
+ {{/if}} + {{/if}} + + {{!-- Favor/Hinder Toggles --}} +
+ +
+ + +
+ {{#if (gt netFavorHinder 0)}} +
+ +d6 {{localize "VAGABOND.Favor"}} +
+ {{else if (lt netFavorHinder 0)}} +
+ -d6 {{localize "VAGABOND.Hinder"}} +
+ {{/if}} +
+ + {{!-- Situational Modifier --}} +
+ +
+ + + + +
+
+ +
+
+ + {{!-- Roll Button --}} +
+ +
+