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>
337 lines
10 KiB
JavaScript
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;
|
|
}
|
|
}
|