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

265 lines
8.1 KiB
JavaScript
Raw Permalink 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.

/**
* Skill Check Dialog for Vagabond RPG
*
* Extends VagabondRollDialog to handle skill check configuration:
* - Skill selection (if not pre-selected)
* - Displays calculated difficulty
* - Displays crit threshold
* - Shows trained/untrained status
*
* @extends VagabondRollDialog
*/
import VagabondRollDialog from "./base-roll-dialog.mjs";
import { skillCheck } from "../dice/rolls.mjs";
export default class SkillCheckDialog extends VagabondRollDialog {
/**
* @param {VagabondActor} actor - The actor making the roll
* @param {Object} options - Dialog options
* @param {string} [options.skillId] - Pre-selected skill ID
*/
constructor(actor, options = {}) {
super(actor, options);
this.skillId = options.skillId || null;
// Load automatic favor/hinder for this skill
if (this.skillId) {
this.rollConfig.autoFavorHinder = actor.getNetFavorHinder({ skillId: this.skillId });
}
}
/* -------------------------------------------- */
/* Static Properties */
/* -------------------------------------------- */
/** @override */
static DEFAULT_OPTIONS = foundry.utils.mergeObject(
super.DEFAULT_OPTIONS,
{
id: "vagabond-skill-check-dialog",
window: {
title: "VAGABOND.SkillCheck",
icon: "fa-solid fa-dice-d20",
},
},
{ inplace: false }
);
/** @override */
static PARTS = {
form: {
template: "systems/vagabond/templates/dialog/skill-check.hbs",
},
};
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/** @override */
get title() {
if (this.skillId) {
const skillLabel = CONFIG.VAGABOND?.skills?.[this.skillId]?.label || this.skillId;
return `${game.i18n.localize(skillLabel)} ${game.i18n.localize("VAGABOND.Check")}`;
}
return game.i18n.localize("VAGABOND.SkillCheck");
}
/**
* Get the current skill data.
* @returns {Object|null} Skill data from actor
*/
get skillData() {
if (!this.skillId) return null;
return this.actor.system.skills?.[this.skillId] || null;
}
/* -------------------------------------------- */
/* Data Preparation */
/* -------------------------------------------- */
/** @override */
async _prepareRollContext(_options) {
const context = {};
// 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,
difficulty,
critThreshold: skillData.critThreshold || 20,
selected: id === this.skillId,
};
});
// Selected skill info
context.selectedSkill = this.skillId;
context.skillData = this.skillData;
if (this.skillData) {
// 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 = statValue;
}
}
return context;
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @override */
_onRender(context, options) {
super._onRender(context, options);
// Skill selection dropdown
const skillSelect = this.element.querySelector('[name="skillId"]');
skillSelect?.addEventListener("change", (event) => {
this.skillId = event.target.value;
this.rollConfig.autoFavorHinder = this.actor.getNetFavorHinder({ skillId: this.skillId });
this.render();
});
}
/** @override */
async _executeRoll() {
if (!this.skillId) {
ui.notifications.warn(game.i18n.localize("VAGABOND.SelectSkillFirst"));
return;
}
// 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,
});
// 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 skillLabel = game.i18n.localize(
CONFIG.VAGABOND?.skills?.[this.skillId]?.label || this.skillId
);
// Prepare template data
const templateData = {
actor: this.actor,
skillId: this.skillId,
skillLabel,
trained: this.skillData?.trained || false,
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,
};
// Render the chat card template
const content = await renderTemplate(
"systems/vagabond/templates/chat/skill-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 skill check dialog.
*
* @param {VagabondActor} actor - The actor making the roll
* @param {string} [skillId] - Optional pre-selected skill
* @param {Object} [options] - Additional options
* @returns {Promise<SkillCheckDialog>}
*/
static async prompt(actor, skillId = null, options = {}) {
return this.create(actor, { ...options, skillId });
}
/**
* Perform a quick roll without showing the dialog.
* Used for Shift+click fast rolling.
*
* @param {VagabondActor} actor - The actor making the roll
* @param {string} skillId - The skill to check
* @param {Object} [options] - Roll options
* @returns {Promise<VagabondRollResult>}
*/
static async quickRoll(actor, skillId, options = {}) {
// Get automatic favor/hinder
const autoFavorHinder = actor.getNetFavorHinder({ skillId });
// Perform the roll
const result = await skillCheck(actor, skillId, {
favorHinder: options.favorHinder ?? autoFavorHinder.net,
modifier: options.modifier || 0,
});
// Create a temporary dialog instance just for chat output
const tempDialog = new this(actor, { skillId });
tempDialog.rollConfig.autoFavorHinder = autoFavorHinder;
await tempDialog._sendToChat(result);
return result;
}
}