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>
This commit is contained in:
parent
463a130c18
commit
27a5f481aa
@ -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",
|
||||
|
||||
31
lang/en.json
31
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!"
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
457
module/applications/attack-roll-dialog.mjs
Normal file
457
module/applications/attack-roll-dialog.mjs
Normal file
@ -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<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 };
|
||||
}
|
||||
}
|
||||
336
module/applications/save-roll-dialog.mjs
Normal file
336
module/applications/save-roll-dialog.mjs
Normal file
@ -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<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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<VagabondRollResult>} 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 };
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
112
templates/chat/attack-roll.hbs
Normal file
112
templates/chat/attack-roll.hbs
Normal file
@ -0,0 +1,112 @@
|
||||
{{!-- Attack Roll Chat Card Template --}}
|
||||
{{!-- Displays attack results with weapon info, hit/miss, and damage --}}
|
||||
|
||||
<div class="vagabond chat-card attack-roll">
|
||||
{{!-- Header with Weapon Info --}}
|
||||
<header class="card-header">
|
||||
<img src="{{weapon.img}}" alt="{{weapon.name}}" class="weapon-icon">
|
||||
<div class="header-text">
|
||||
<h3 class="weapon-name">{{weapon.name}}</h3>
|
||||
<span class="attack-type-badge">{{attackLabel}}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{!-- Roll Result --}}
|
||||
<div class="roll-result {{#if isCrit}}critical{{else if isFumble}}fumble{{else if success}}success{{else}}failure{{/if}}">
|
||||
<div class="roll-total">{{total}}</div>
|
||||
<div class="roll-status">
|
||||
{{#if isCrit}}
|
||||
<span class="status critical">{{localize "VAGABOND.CriticalHit"}}</span>
|
||||
{{else if isFumble}}
|
||||
<span class="status fumble">{{localize "VAGABOND.Fumble"}}</span>
|
||||
{{else if success}}
|
||||
<span class="status success">{{localize "VAGABOND.Hit"}}</span>
|
||||
{{else}}
|
||||
<span class="status failure">{{localize "VAGABOND.Miss"}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Roll Details --}}
|
||||
<div class="roll-details">
|
||||
<div class="roll-formula">
|
||||
<span class="label">{{localize "VAGABOND.Formula"}}:</span>
|
||||
<span class="value">{{formula}}</span>
|
||||
</div>
|
||||
<div class="roll-breakdown">
|
||||
<span class="d20-result">
|
||||
<i class="fa-solid fa-dice-d20"></i> {{d20Result}}
|
||||
</span>
|
||||
{{#if favorDie}}
|
||||
<span class="favor-die {{#if (gt netFavorHinder 0)}}favor{{else}}hinder{{/if}}">
|
||||
<i class="fa-solid fa-dice-d6"></i> {{favorDie}}
|
||||
</span>
|
||||
{{/if}}
|
||||
{{#if modifier}}
|
||||
<span class="modifier">
|
||||
{{#if (gt modifier 0)}}+{{/if}}{{modifier}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Target Info --}}
|
||||
<div class="target-info">
|
||||
<div class="difficulty">
|
||||
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||
<span class="value">{{this.difficulty}}</span>
|
||||
</div>
|
||||
{{#if (lt this.critThreshold 20)}}
|
||||
<div class="crit-threshold">
|
||||
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
|
||||
<span class="value">{{this.critThreshold}}+</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{!-- Damage Section (if hit) --}}
|
||||
{{#if hasDamage}}
|
||||
<div class="damage-section {{#if isCrit}}critical{{/if}}">
|
||||
<div class="damage-header">
|
||||
<i class="fa-solid fa-burst"></i>
|
||||
<span>{{localize "VAGABOND.Damage"}}</span>
|
||||
{{#if isCrit}}
|
||||
<span class="crit-label">({{localize "VAGABOND.Critical"}}!)</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="damage-result">
|
||||
<span class="damage-total">{{damageTotal}}</span>
|
||||
<span class="damage-type">{{weapon.damageTypeLabel}}</span>
|
||||
</div>
|
||||
<div class="damage-formula">
|
||||
{{damageFormula}}
|
||||
{{#if twoHanded}}
|
||||
<span class="grip-indicator">({{localize "VAGABOND.TwoHanded"}})</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Weapon Properties --}}
|
||||
{{#if weapon.properties.length}}
|
||||
<div class="weapon-properties">
|
||||
{{#each weapon.properties}}
|
||||
<span class="property-tag">{{this}}</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Favor/Hinder Sources --}}
|
||||
{{#if favorSources.length}}
|
||||
<div class="favor-sources">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
<span>{{localize "VAGABOND.Favor"}}: {{#each favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if hinderSources.length}}
|
||||
<div class="hinder-sources">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
<span>{{localize "VAGABOND.Hinder"}}: {{#each hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
97
templates/chat/save-roll.hbs
Normal file
97
templates/chat/save-roll.hbs
Normal file
@ -0,0 +1,97 @@
|
||||
{{!-- Save Roll Chat Card Template --}}
|
||||
{{!-- Displays save results with stats used and success/fail --}}
|
||||
|
||||
<div class="vagabond chat-card save-roll">
|
||||
{{!-- Header --}}
|
||||
<header class="card-header">
|
||||
<h3 class="save-name">
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
{{saveLabel}} {{localize "VAGABOND.Save"}}
|
||||
</h3>
|
||||
{{#if isDefense}}
|
||||
<span class="defense-badge {{defenseType}}">{{defenseLabel}}</span>
|
||||
{{/if}}
|
||||
</header>
|
||||
|
||||
{{!-- Roll Result --}}
|
||||
<div class="roll-result {{#if isCrit}}critical{{else if isFumble}}fumble{{else if success}}success{{else}}failure{{/if}}">
|
||||
<div class="roll-total">{{total}}</div>
|
||||
<div class="roll-status">
|
||||
{{#if isCrit}}
|
||||
<span class="status critical">{{localize "VAGABOND.CriticalSuccess"}}</span>
|
||||
{{else if isFumble}}
|
||||
<span class="status fumble">{{localize "VAGABOND.Fumble"}}</span>
|
||||
{{else if success}}
|
||||
<span class="status success">{{localize "VAGABOND.Success"}}</span>
|
||||
{{else}}
|
||||
<span class="status failure">{{localize "VAGABOND.Failure"}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Roll Details --}}
|
||||
<div class="roll-details">
|
||||
<div class="roll-formula">
|
||||
<span class="label">{{localize "VAGABOND.Formula"}}:</span>
|
||||
<span class="value">{{formula}}</span>
|
||||
</div>
|
||||
<div class="roll-breakdown">
|
||||
<span class="d20-result">
|
||||
<i class="fa-solid fa-dice-d20"></i> {{d20Result}}
|
||||
</span>
|
||||
{{#if favorDie}}
|
||||
<span class="favor-die {{#if (gt netFavorHinder 0)}}favor{{else}}hinder{{/if}}">
|
||||
<i class="fa-solid fa-dice-d6"></i> {{favorDie}}
|
||||
</span>
|
||||
{{/if}}
|
||||
{{#if modifier}}
|
||||
<span class="modifier">
|
||||
{{#if (gt modifier 0)}}+{{/if}}{{modifier}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Save Info --}}
|
||||
<div class="save-info">
|
||||
<div class="stats-used">
|
||||
<span class="label">{{localize "VAGABOND.Stats"}}:</span>
|
||||
<span class="value">
|
||||
{{#each stats}}
|
||||
<span class="stat-abbr">{{this}}</span>{{#unless @last}} + {{/unless}}
|
||||
{{/each}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="difficulty">
|
||||
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||
<span class="value">{{this.difficulty}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Defense Info --}}
|
||||
{{#if isDefense}}
|
||||
<div class="defense-info-display">
|
||||
{{#if (eq defenseType 'block')}}
|
||||
<i class="fa-solid fa-shield"></i>
|
||||
<span>{{localize "VAGABOND.BlockedWith"}}</span>
|
||||
{{else if (eq defenseType 'dodge')}}
|
||||
<i class="fa-solid fa-person-running"></i>
|
||||
<span>{{localize "VAGABOND.DodgedAttack"}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Favor/Hinder Sources --}}
|
||||
{{#if favorSources.length}}
|
||||
<div class="favor-sources">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
<span>{{localize "VAGABOND.Favor"}}: {{#each favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if hinderSources.length}}
|
||||
<div class="hinder-sources">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
<span>{{localize "VAGABOND.Hinder"}}: {{#each hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@ -53,12 +53,12 @@
|
||||
<div class="target-info">
|
||||
<div class="difficulty">
|
||||
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||
<span class="value">{{difficulty}}</span>
|
||||
<span class="value">{{this.difficulty}}</span>
|
||||
</div>
|
||||
{{#if (lt critThreshold 20)}}
|
||||
{{#if (lt this.critThreshold 20)}}
|
||||
<div class="crit-threshold">
|
||||
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
|
||||
<span class="value">{{critThreshold}}+</span>
|
||||
<span class="value">{{this.critThreshold}}+</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
126
templates/dialog/attack-roll.hbs
Normal file
126
templates/dialog/attack-roll.hbs
Normal file
@ -0,0 +1,126 @@
|
||||
{{!-- Attack Roll Dialog Template --}}
|
||||
{{!-- Extends roll-dialog-base with attack-specific content --}}
|
||||
|
||||
<div class="roll-dialog-content">
|
||||
{{!-- Automatic Favor/Hinder from Active Effects --}}
|
||||
{{#if hasAutoFavor}}
|
||||
<div class="auto-favor-hinder favor">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
<span>{{localize "VAGABOND.AutoFavor"}}: {{#each autoFavorHinder.favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if hasAutoHinder}}
|
||||
<div class="auto-favor-hinder hinder">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
<span>{{localize "VAGABOND.AutoHinder"}}: {{#each autoFavorHinder.hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Weapon Selection --}}
|
||||
<div class="weapon-selection">
|
||||
<label for="weaponId">{{localize "VAGABOND.Weapon"}}</label>
|
||||
<select name="weaponId">
|
||||
{{#each rollSpecific.weapons}}
|
||||
<option value="{{this.id}}" {{#if this.selected}}selected{{/if}}>
|
||||
{{this.name}}{{#if this.isUnarmed}} ({{localize "VAGABOND.Fist"}}){{else unless this.equipped}} ({{localize "VAGABOND.Unequipped"}}){{/if}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{!-- Attack Info (shown when weapon selected) --}}
|
||||
{{#if rollSpecific.weapon}}
|
||||
<div class="attack-info">
|
||||
<div class="attack-type">
|
||||
<span class="label">{{localize "VAGABOND.AttackType"}}:</span>
|
||||
<span class="value">{{rollSpecific.attackLabel}}</span>
|
||||
</div>
|
||||
<div class="attack-stat">
|
||||
<span class="label">{{localize "VAGABOND.Stat"}}:</span>
|
||||
<span class="value">{{rollSpecific.statLabel}} ({{rollSpecific.statValue}})</span>
|
||||
</div>
|
||||
<div class="attack-difficulty">
|
||||
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
|
||||
</div>
|
||||
{{#if (lt rollSpecific.critThreshold 20)}}
|
||||
<div class="attack-crit">
|
||||
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
|
||||
<span class="value crit">{{rollSpecific.critThreshold}}+</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{!-- Damage Preview --}}
|
||||
<div class="damage-preview">
|
||||
<div class="damage-formula">
|
||||
<span class="label">{{localize "VAGABOND.Damage"}}:</span>
|
||||
<span class="value">{{rollSpecific.damageFormula}}</span>
|
||||
<span class="damage-type">({{rollSpecific.damageTypeLabel}})</span>
|
||||
</div>
|
||||
{{#if rollSpecific.properties.length}}
|
||||
<div class="weapon-properties">
|
||||
{{#each rollSpecific.propertyLabels}}
|
||||
<span class="property-tag">{{this}}</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{!-- Versatile Weapon Toggle --}}
|
||||
{{#if rollSpecific.isVersatile}}
|
||||
<div class="versatile-toggle">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="twoHanded" {{#if rollSpecific.twoHanded}}checked{{/if}}>
|
||||
<span>{{localize "VAGABOND.TwoHandedGrip"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{!-- Favor/Hinder Toggles --}}
|
||||
<div class="favor-hinder-section">
|
||||
<label>{{localize "VAGABOND.FavorHinder"}}</label>
|
||||
<div class="favor-hinder-toggles">
|
||||
<button type="button" class="favor-btn {{#if (eq config.favorHinder 1)}}active{{/if}}" data-favor="1">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
{{localize "VAGABOND.Favor"}}
|
||||
</button>
|
||||
<button type="button" class="hinder-btn {{#if (eq config.favorHinder -1)}}active{{/if}}" data-favor="-1">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
{{localize "VAGABOND.Hinder"}}
|
||||
</button>
|
||||
</div>
|
||||
{{#if (gt netFavorHinder 0)}}
|
||||
<div class="net-favor-hinder favor">
|
||||
<i class="fa-solid fa-dice-d6"></i> +d6 {{localize "VAGABOND.Favor"}}
|
||||
</div>
|
||||
{{else if (lt netFavorHinder 0)}}
|
||||
<div class="net-favor-hinder hinder">
|
||||
<i class="fa-solid fa-dice-d6"></i> -d6 {{localize "VAGABOND.Hinder"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{!-- Situational Modifier --}}
|
||||
<div class="modifier-section">
|
||||
<label>{{localize "VAGABOND.SituationalModifier"}}</label>
|
||||
<div class="modifier-presets">
|
||||
<button type="button" class="modifier-preset" data-modifier="-5">-5</button>
|
||||
<button type="button" class="modifier-preset" data-modifier="-1">-1</button>
|
||||
<button type="button" class="modifier-preset" data-modifier="1">+1</button>
|
||||
<button type="button" class="modifier-preset" data-modifier="5">+5</button>
|
||||
</div>
|
||||
<div class="modifier-input">
|
||||
<input type="number" name="modifier" value="{{config.modifier}}" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Roll Button --}}
|
||||
<div class="dialog-buttons">
|
||||
<button type="submit" class="roll-btn" {{#unless rollSpecific.weapon}}disabled{{/unless}}>
|
||||
<i class="fa-solid fa-dice-d20"></i>
|
||||
{{localize "VAGABOND.RollAttack"}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
130
templates/dialog/save-roll.hbs
Normal file
130
templates/dialog/save-roll.hbs
Normal file
@ -0,0 +1,130 @@
|
||||
{{!-- Save Roll Dialog Template --}}
|
||||
{{!-- Extends roll-dialog-base with save-specific content --}}
|
||||
|
||||
<div class="roll-dialog-content">
|
||||
{{!-- Automatic Favor/Hinder from Active Effects --}}
|
||||
{{#if autoFavorHinder.favorSources.length}}
|
||||
<div class="auto-favor-hinder favor">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
<span>{{localize "VAGABOND.AutoFavor"}}: {{#each autoFavorHinder.favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if autoFavorHinder.hinderSources.length}}
|
||||
<div class="auto-favor-hinder hinder">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
<span>{{localize "VAGABOND.AutoHinder"}}: {{#each autoFavorHinder.hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Save Type Selection --}}
|
||||
<div class="save-selection">
|
||||
<label for="saveType">{{localize "VAGABOND.SaveType"}}</label>
|
||||
<select name="saveType">
|
||||
<option value="">-- {{localize "VAGABOND.SelectSave"}} --</option>
|
||||
{{#each rollSpecific.saves}}
|
||||
<option value="{{this.id}}" {{#if this.selected}}selected{{/if}}>
|
||||
{{this.label}} ({{this.stats}})
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{!-- Save Info (shown when save type selected) --}}
|
||||
{{#if rollSpecific.saveData}}
|
||||
<div class="save-info">
|
||||
<div class="save-stats">
|
||||
<span class="label">{{localize "VAGABOND.Stats"}}:</span>
|
||||
<span class="value">
|
||||
{{#each rollSpecific.statLabels}}
|
||||
<span class="stat-name">{{this}}</span>{{#unless @last}} + {{/unless}}
|
||||
{{/each}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="save-difficulty">
|
||||
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Defense Options (Block/Dodge for Reflex) --}}
|
||||
{{#if rollSpecific.showDefenseOptions}}
|
||||
<div class="defense-options">
|
||||
<label>{{localize "VAGABOND.DefenseType"}}</label>
|
||||
<div class="defense-buttons">
|
||||
<button type="button" class="defense-btn dodge-btn {{#if (eq rollSpecific.defenseType 'dodge')}}active{{/if}}" data-defense="dodge">
|
||||
<i class="fa-solid fa-person-running"></i>
|
||||
{{localize "VAGABOND.Dodge"}}
|
||||
</button>
|
||||
{{#if rollSpecific.hasShield}}
|
||||
<button type="button" class="defense-btn block-btn {{#if (eq rollSpecific.defenseType 'block')}}active{{/if}}" data-defense="block">
|
||||
<i class="fa-solid fa-shield"></i>
|
||||
{{localize "VAGABOND.Block"}}
|
||||
</button>
|
||||
{{else}}
|
||||
<button type="button" class="defense-btn block-btn" disabled title="{{localize 'VAGABOND.RequiresShield'}}">
|
||||
<i class="fa-solid fa-shield"></i>
|
||||
{{localize "VAGABOND.Block"}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if rollSpecific.defenseType}}
|
||||
<div class="defense-info">
|
||||
{{#if (eq rollSpecific.defenseType 'block')}}
|
||||
<i class="fa-solid fa-info-circle"></i>
|
||||
<span>{{localize "VAGABOND.BlockInfo"}}</span>
|
||||
{{else}}
|
||||
<i class="fa-solid fa-info-circle"></i>
|
||||
<span>{{localize "VAGABOND.DodgeInfo"}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{!-- Favor/Hinder Toggles --}}
|
||||
<div class="favor-hinder-section">
|
||||
<label>{{localize "VAGABOND.FavorHinder"}}</label>
|
||||
<div class="favor-hinder-toggles">
|
||||
<button type="button" class="favor-btn {{#if (eq config.favorHinder 1)}}active{{/if}}" data-action="toggle-favor">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
{{localize "VAGABOND.Favor"}}
|
||||
</button>
|
||||
<button type="button" class="hinder-btn {{#if (eq config.favorHinder -1)}}active{{/if}}" data-action="toggle-hinder">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
{{localize "VAGABOND.Hinder"}}
|
||||
</button>
|
||||
</div>
|
||||
{{#if (gt netFavorHinder 0)}}
|
||||
<div class="net-favor-hinder favor">
|
||||
<i class="fa-solid fa-dice-d6"></i> +d6 {{localize "VAGABOND.Favor"}}
|
||||
</div>
|
||||
{{else if (lt netFavorHinder 0)}}
|
||||
<div class="net-favor-hinder hinder">
|
||||
<i class="fa-solid fa-dice-d6"></i> -d6 {{localize "VAGABOND.Hinder"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{!-- Situational Modifier --}}
|
||||
<div class="modifier-section">
|
||||
<label>{{localize "VAGABOND.SituationalModifier"}}</label>
|
||||
<div class="modifier-presets">
|
||||
<button type="button" class="modifier-preset" data-modifier-preset="-5">-5</button>
|
||||
<button type="button" class="modifier-preset" data-modifier-preset="-1">-1</button>
|
||||
<button type="button" class="modifier-preset" data-modifier-preset="1">+1</button>
|
||||
<button type="button" class="modifier-preset" data-modifier-preset="5">+5</button>
|
||||
</div>
|
||||
<div class="modifier-input">
|
||||
<input type="number" name="modifier" value="{{config.modifier}}" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Roll Button --}}
|
||||
<div class="dialog-buttons">
|
||||
<button type="submit" class="roll-btn" {{#unless rollSpecific.saveData}}disabled{{/unless}}>
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
{{localize "VAGABOND.RollSave"}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Reference in New Issue
Block a user