vagabond-rpg-foundryvtt/module/applications/attack-roll-dialog.mjs
Cal Corum 27a5f481aa 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>
2025-12-13 19:52:28 -06:00

458 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 };
}
}