vagabond-rpg-foundryvtt/module/applications/save-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

337 lines
10 KiB
JavaScript

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