From 463a130c183e8e3d817fc1fa5fb374f79be53022 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 13 Dec 2025 17:31:15 -0600 Subject: [PATCH] Implement skill check system with roll dialogs and debug tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2.5: Skill Check System Implementation Features: - ApplicationV2-based roll dialogs with HandlebarsApplicationMixin - Base VagabondRollDialog class for shared dialog functionality - SkillCheckDialog for skill checks with auto-calculated difficulty - Favor/Hinder system using Active Effects flags (simplified from schema) - FavorHinderDebug panel for testing flags without actor sheets - Auto-created development macros (Favor/Hinder Debug, Skill Check) - Custom chat cards for skill roll results Technical Changes: - Removed favorHinder from character schema (now uses flags) - Updated getNetFavorHinder() to use flag-based approach - Returns { net, favorSources, hinderSources } for transparency - Universal form styling fixes for Foundry dark theme compatibility - Added Macro to ESLint globals Flag Convention: - flags.vagabond.favor.skills. - flags.vagabond.hinder.skills. - flags.vagabond.favor.attacks - flags.vagabond.hinder.attacks - flags.vagabond.favor.saves. - flags.vagabond.hinder.saves. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .eslintrc.json | 1 + DEVELOPMENT.md | 61 +++ PROJECT_ROADMAP.json | 8 +- lang/en.json | 21 +- module/applications/_module.mjs | 8 + module/applications/base-roll-dialog.mjs | 255 ++++++++++++ module/applications/favor-hinder-debug.mjs | 334 +++++++++++++++ module/applications/skill-check-dialog.mjs | 248 +++++++++++ module/data/actor/character.mjs | 34 +- module/dice/rolls.mjs | 19 +- module/documents/actor.mjs | 104 ++++- module/tests/actor.test.mjs | 92 +++- module/vagabond.mjs | 63 ++- styles/scss/chat/_chat-cards.scss | 202 ++++++++- styles/scss/components/_forms.scss | 7 +- styles/scss/dialogs/_roll-dialog.scss | 380 ++++++++++++++++- templates/chat/skill-roll.hbs | 79 ++++ templates/dialog/favor-hinder-debug.hbs | 153 +++++++ templates/dialog/roll-dialog-base.hbs | 66 +++ templates/dialog/skill-check.hbs | 104 +++++ test_results/2025-12-12-2241.txt | 462 +++++++++++++++++++++ 21 files changed, 2606 insertions(+), 95 deletions(-) create mode 100644 module/applications/_module.mjs create mode 100644 module/applications/base-roll-dialog.mjs create mode 100644 module/applications/favor-hinder-debug.mjs create mode 100644 module/applications/skill-check-dialog.mjs create mode 100644 templates/chat/skill-roll.hbs create mode 100644 templates/dialog/favor-hinder-debug.hbs create mode 100644 templates/dialog/roll-dialog-base.hbs create mode 100644 templates/dialog/skill-check.hbs create mode 100644 test_results/2025-12-12-2241.txt diff --git a/.eslintrc.json b/.eslintrc.json index 9be6bc7..70bdce9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -55,6 +55,7 @@ "Scene": "readonly", "User": "readonly", "Folder": "readonly", + "Macro": "readonly", "Compendium": "readonly", "CompendiumCollection": "readonly", "DocumentSheetConfig": "readonly", diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0a5d2e7..12d957e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -290,6 +290,67 @@ docs: Update README with installation instructions --- +## Architecture Decisions + +### Roll Dialog System (Phase 2.5+) + +**Decision Date:** 2024-12-13 + +| Decision | Choice | Rationale | +| --------------------- | ----------------------------------- | ------------------------------------------------------------------------------------ | +| Application API | **ApplicationV2** | Modern Foundry v13 API, forward-compatible, cleaner lifecycle | +| Dialog Behavior | **Hybrid** | Normal click = dialog, Shift+click = quick roll with defaults | +| Favor/Hinder | **Manual toggles + Active Effects** | GM announces situational favor/hinder; persistent sources use flags | +| Situational Modifiers | **Preset buttons + custom input** | Buttons: -5, -1, +1, +5; plus free-form input field | +| Dialog Structure | **Base class + subclasses** | `VagabondRollDialog` base → `SkillCheckDialog`, `AttackRollDialog`, `SaveRollDialog` | +| Chat Output | **Custom templates** | Rich chat cards with skill name, difficulty, success/fail, crit indicators | + +### Favor/Hinder via Active Effects + +Persistent favor/hinder from class features, perks, or conditions uses Foundry flags set by Active Effects: + +```javascript +// Active Effect change for a perk granting Favor on Performance checks: +{ key: "flags.vagabond.favor.skills.performance", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: true } + +// Active Effect change for Hinder on all attack rolls: +{ key: "flags.vagabond.hinder.attacks", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: true } + +// Active Effect change for Favor on Reflex saves: +{ key: "flags.vagabond.favor.saves.reflex", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: true } +``` + +**Flag Convention:** + +- `flags.vagabond.favor.skills.` - Favor on specific skill checks +- `flags.vagabond.hinder.skills.` - Hinder on specific skill checks +- `flags.vagabond.favor.attacks` - Favor on all attack rolls +- `flags.vagabond.hinder.attacks` - Hinder on all attack rolls +- `flags.vagabond.favor.saves.` - Favor on specific save type +- `flags.vagabond.hinder.saves.` - Hinder on specific save type + +The roll dialog checks these flags and displays any automatic favor/hinder, while still allowing manual override for situational modifiers announced by the GM. + +### File Structure for Dialogs + +``` +module/applications/ +├── _module.mjs # Export barrel +├── base-roll-dialog.mjs # VagabondRollDialog (ApplicationV2 base) +├── skill-check-dialog.mjs # SkillCheckDialog extends VagabondRollDialog +├── attack-roll-dialog.mjs # AttackRollDialog (Phase 2.6) +└── save-roll-dialog.mjs # SaveRollDialog (Phase 2.7) + +templates/dialog/ +├── roll-dialog-base.hbs # Shared dialog structure +└── skill-check.hbs # Skill-specific content + +templates/chat/ +└── skill-roll.hbs # Skill check result card +``` + +--- + ## Resources - [Foundry VTT API Documentation](https://foundryvtt.com/api/) diff --git a/PROJECT_ROADMAP.json b/PROJECT_ROADMAP.json index baef6ae..6db076f 100644 --- a/PROJECT_ROADMAP.json +++ b/PROJECT_ROADMAP.json @@ -239,7 +239,7 @@ "id": "2.1", "name": "Create main system entry point (vagabond.mjs)", "description": "System initialization, hook registration, CONFIG setup, document class registration", - "completed": false, + "completed": true, "tested": false, "priority": "critical", "dependencies": ["1.1", "1.6"] @@ -248,7 +248,7 @@ "id": "2.2", "name": "Implement VagabondActor class", "description": "Extended Actor with prepareData for derived values calculation (HP, Speed, Saves, Skill difficulties)", - "completed": false, + "completed": true, "tested": false, "priority": "critical", "dependencies": ["2.1", "1.2", "1.3"] @@ -257,7 +257,7 @@ "id": "2.3", "name": "Implement VagabondItem class", "description": "Extended Item with type-specific preparation and chat card generation", - "completed": false, + "completed": true, "tested": false, "priority": "critical", "dependencies": ["2.1", "1.6"] @@ -266,7 +266,7 @@ "id": "2.4", "name": "Create dice rolling module", "description": "Core roll functions: d20 checks, damage rolls, exploding dice (d6!), countdown dice, favor/hinder modifiers", - "completed": false, + "completed": true, "tested": false, "priority": "critical", "dependencies": ["2.1"] diff --git a/lang/en.json b/lang/en.json index b4d4793..a524606 100644 --- a/lang/en.json +++ b/lang/en.json @@ -163,6 +163,7 @@ "VAGABOND.Success": "Success", "VAGABOND.Failure": "Failure", "VAGABOND.Critical": "Critical!", + "VAGABOND.Fumble": "Fumble!", "VAGABOND.Damage": "Damage", "VAGABOND.DamageType": "Damage Type", @@ -209,5 +210,23 @@ "VAGABOND.ItemTypeFeature": "Feature", "VAGABOND.ItemTypeWeapon": "Weapon", "VAGABOND.ItemTypeArmor": "Armor", - "VAGABOND.ItemTypeEquipment": "Equipment" + "VAGABOND.ItemTypeEquipment": "Equipment", + + "VAGABOND.RollDialog": "Roll", + "VAGABOND.SkillCheck": "Skill Check", + "VAGABOND.Check": "Check", + "VAGABOND.Skill": "Skill", + "VAGABOND.Stat": "Stat", + "VAGABOND.Training": "Training", + "VAGABOND.Untrained": "Untrained", + "VAGABOND.SelectSkill": "Select Skill...", + "VAGABOND.SelectSkillFirst": "Please select a skill first", + "VAGABOND.FavorHinder": "Favor / Hinder", + "VAGABOND.SituationalModifier": "Situational Modifier", + "VAGABOND.AutoFavor": "Auto Favor", + "VAGABOND.AutoHinder": "Auto Hinder", + "VAGABOND.Formula": "Formula", + + "VAGABOND.SelectActor": "Select Actor", + "VAGABOND.Save": "Save" } diff --git a/module/applications/_module.mjs b/module/applications/_module.mjs new file mode 100644 index 0000000..fd2ef8d --- /dev/null +++ b/module/applications/_module.mjs @@ -0,0 +1,8 @@ +/** + * Application classes for Vagabond RPG + * @module applications + */ + +export { default as VagabondRollDialog } from "./base-roll-dialog.mjs"; +export { default as SkillCheckDialog } from "./skill-check-dialog.mjs"; +export { default as FavorHinderDebug } from "./favor-hinder-debug.mjs"; diff --git a/module/applications/base-roll-dialog.mjs b/module/applications/base-roll-dialog.mjs new file mode 100644 index 0000000..118b4b0 --- /dev/null +++ b/module/applications/base-roll-dialog.mjs @@ -0,0 +1,255 @@ +/** + * Base Roll Dialog for Vagabond RPG + * + * Provides common UI elements for all roll dialogs: + * - Favor/Hinder toggles + * - Situational modifier input (presets + custom) + * - Roll button + * + * Subclasses (SkillCheckDialog, AttackRollDialog, SaveRollDialog) extend this + * to add roll-type-specific configuration. + * + * Uses Foundry VTT v13 ApplicationV2 API. + * + * @extends ApplicationV2 + * @mixes HandlebarsApplicationMixin + */ + +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +export default class VagabondRollDialog extends HandlebarsApplicationMixin(ApplicationV2) { + /** + * @param {VagabondActor} actor - The actor making the roll + * @param {Object} options - Dialog options + * @param {string} [options.title] - Dialog title + * @param {Function} [options.onRoll] - Callback when roll is executed + */ + constructor(actor, options = {}) { + super(options); + this.actor = actor; + this.onRollCallback = options.onRoll || null; + + // Roll configuration state + this.rollConfig = { + favorHinder: 0, // -1, 0, or +1 + modifier: 0, // Situational modifier + autoFavorHinder: { net: 0, favorSources: [], hinderSources: [] }, + }; + } + + /* -------------------------------------------- */ + /* Static Properties */ + /* -------------------------------------------- */ + + /** @override */ + static DEFAULT_OPTIONS = { + id: "vagabond-roll-dialog", + classes: ["vagabond", "roll-dialog"], + tag: "form", + window: { + title: "VAGABOND.RollDialog", + icon: "fa-solid fa-dice-d20", + resizable: false, + }, + position: { + width: 320, + height: "auto", + }, + form: { + handler: VagabondRollDialog.#onSubmit, + submitOnChange: false, + closeOnSubmit: true, + }, + }; + + /** @override */ + static PARTS = { + form: { + template: "systems/vagabond/templates/dialog/roll-dialog-base.hbs", + }, + }; + + /* -------------------------------------------- */ + /* Getters */ + /* -------------------------------------------- */ + + /** + * Get the title for this dialog. + * Subclasses should override this. + * @returns {string} + */ + get title() { + return game.i18n.localize("VAGABOND.RollDialog"); + } + + /** + * Get the net favor/hinder value (manual + automatic). + * @returns {number} -1, 0, or +1 + */ + get netFavorHinder() { + const manual = this.rollConfig.favorHinder; + const auto = this.rollConfig.autoFavorHinder.net; + return Math.clamp(manual + auto, -1, 1); + } + + /* -------------------------------------------- */ + /* Data Preparation */ + /* -------------------------------------------- */ + + /** @override */ + async _prepareContext(options) { + const context = await super._prepareContext(options); + + context.actor = this.actor; + context.config = this.rollConfig; + context.netFavorHinder = this.netFavorHinder; + + // Automatic favor/hinder from Active Effects + context.autoFavorHinder = this.rollConfig.autoFavorHinder; + context.hasAutoFavor = this.rollConfig.autoFavorHinder.favorSources.length > 0; + context.hasAutoHinder = this.rollConfig.autoFavorHinder.hinderSources.length > 0; + + // Modifier presets + context.modifierPresets = [ + { value: -5, label: "-5" }, + { value: -1, label: "-1" }, + { value: 1, label: "+1" }, + { value: 5, label: "+5" }, + ]; + + // Subclass-specific context + context.rollSpecific = await this._prepareRollContext(options); + + return context; + } + + /** + * Prepare roll-type-specific context data. + * Subclasses should override this. + * + * @param {Object} options - Render options + * @returns {Promise} Additional context data + * @protected + */ + async _prepareRollContext(_options) { + return {}; + } + + /* -------------------------------------------- */ + /* Event Handlers */ + /* -------------------------------------------- */ + + /** @override */ + _onRender(context, options) { + super._onRender(context, options); + + // Favor/Hinder toggle buttons + const favorBtn = this.element.querySelector('[data-action="toggle-favor"]'); + const hinderBtn = this.element.querySelector('[data-action="toggle-hinder"]'); + + favorBtn?.addEventListener("click", () => this._onToggleFavor()); + hinderBtn?.addEventListener("click", () => this._onToggleHinder()); + + // Modifier preset buttons + const presetBtns = this.element.querySelectorAll("[data-modifier-preset]"); + for (const btn of presetBtns) { + btn.addEventListener("click", (event) => { + const value = parseInt(event.currentTarget.dataset.modifierPreset, 10); + this._onModifierPreset(value); + }); + } + + // Custom modifier input + const modifierInput = this.element.querySelector('[name="modifier"]'); + modifierInput?.addEventListener("change", (event) => { + this.rollConfig.modifier = parseInt(event.target.value, 10) || 0; + }); + } + + /** + * Toggle favor on/off. + * @private + */ + _onToggleFavor() { + if (this.rollConfig.favorHinder === 1) { + this.rollConfig.favorHinder = 0; + } else { + this.rollConfig.favorHinder = 1; + } + this.render(); + } + + /** + * Toggle hinder on/off. + * @private + */ + _onToggleHinder() { + if (this.rollConfig.favorHinder === -1) { + this.rollConfig.favorHinder = 0; + } else { + this.rollConfig.favorHinder = -1; + } + this.render(); + } + + /** + * Apply a modifier preset. + * @param {number} value - The preset value + * @private + */ + _onModifierPreset(value) { + this.rollConfig.modifier += value; + this.render(); + } + + /** + * Handle form submission (roll button). + * @param {Event} event - The form submission event + * @param {HTMLFormElement} form - The form element + * @param {FormDataExtended} formData - The form data + * @private + */ + static async #onSubmit(event, form, formData) { + // 'this' is the dialog instance + const dialog = this; + const data = foundry.utils.expandObject(formData.object); + + // Update modifier from form + dialog.rollConfig.modifier = parseInt(data.modifier, 10) || 0; + + // Execute the roll + await dialog._executeRoll(); + + // Call the callback if provided + if (dialog.onRollCallback) { + dialog.onRollCallback(dialog.rollConfig); + } + } + + /** + * Execute the roll with current configuration. + * Subclasses must override this. + * + * @returns {Promise} + * @protected + */ + async _executeRoll() { + throw new Error("Subclasses must implement _executeRoll()"); + } + + /* -------------------------------------------- */ + /* Static Methods */ + /* -------------------------------------------- */ + + /** + * Create and render a roll dialog. + * + * @param {VagabondActor} actor - The actor making the roll + * @param {Object} options - Dialog options + * @returns {Promise} The rendered dialog + */ + static async create(actor, options = {}) { + const dialog = new this(actor, options); + return dialog.render(true); + } +} diff --git a/module/applications/favor-hinder-debug.mjs b/module/applications/favor-hinder-debug.mjs new file mode 100644 index 0000000..6d684d3 --- /dev/null +++ b/module/applications/favor-hinder-debug.mjs @@ -0,0 +1,334 @@ +/** + * Favor/Hinder Debug Application for Vagabond RPG + * + * A development/testing tool that allows setting and viewing + * favor/hinder flags on actors. Useful for testing the roll system + * without a full actor sheet implementation. + * + * @extends ApplicationV2 + * @mixes HandlebarsApplicationMixin + */ + +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +export default class FavorHinderDebug extends HandlebarsApplicationMixin(ApplicationV2) { + constructor(options = {}) { + super(options); + + // Default to selected token's actor, or first character actor + this.selectedActorId = this._getDefaultActorId(); + } + + /* -------------------------------------------- */ + /* Static Properties */ + /* -------------------------------------------- */ + + /** @override */ + static DEFAULT_OPTIONS = { + id: "vagabond-favor-hinder-debug", + classes: ["vagabond", "favor-hinder-debug"], + tag: "div", + window: { + title: "Favor/Hinder Debug", + icon: "fa-solid fa-bug", + resizable: true, + }, + position: { + width: 500, + height: "auto", + }, + }; + + /** @override */ + static PARTS = { + main: { + template: "systems/vagabond/templates/dialog/favor-hinder-debug.hbs", + }, + }; + + /* -------------------------------------------- */ + /* Getters */ + /* -------------------------------------------- */ + + /** + * Get the currently selected actor. + * @returns {VagabondActor|null} + */ + get actor() { + return game.actors.get(this.selectedActorId) || null; + } + + /* -------------------------------------------- */ + /* Helper Methods */ + /* -------------------------------------------- */ + + /** + * Get the default actor ID (selected token only, otherwise blank). + * @returns {string|null} + * @private + */ + _getDefaultActorId() { + // Only default to selected token's actor, otherwise blank + const controlled = canvas.tokens?.controlled?.[0]; + if (controlled?.actor?.type === "character") { + return controlled.actor.id; + } + + return null; + } + + /** + * Get all current favor/hinder flags for an actor. + * @param {VagabondActor} actor + * @returns {Object} Organized flag data + * @private + */ + _getActorFlags(actor) { + if (!actor) return { skills: {}, attacks: {}, saves: {} }; + + const flags = { + skills: {}, + attacks: { favor: false, hinder: false }, + saves: {}, + }; + + // Skills + for (const skillId of Object.keys(CONFIG.VAGABOND.skills)) { + flags.skills[skillId] = { + favor: actor.getFlag("vagabond", `favor.skills.${skillId}`) || false, + hinder: actor.getFlag("vagabond", `hinder.skills.${skillId}`) || false, + }; + } + + // Attacks + flags.attacks.favor = actor.getFlag("vagabond", "favor.attacks") || false; + flags.attacks.hinder = actor.getFlag("vagabond", "hinder.attacks") || false; + + // Saves + for (const saveId of Object.keys(CONFIG.VAGABOND.saves)) { + flags.saves[saveId] = { + favor: actor.getFlag("vagabond", `favor.saves.${saveId}`) || false, + hinder: actor.getFlag("vagabond", `hinder.saves.${saveId}`) || false, + }; + } + + return flags; + } + + /* -------------------------------------------- */ + /* Data Preparation */ + /* -------------------------------------------- */ + + /** @override */ + async _prepareContext(_options) { + const context = {}; + + // Get all character actors for dropdown + context.actors = game.actors + .filter((a) => a.type === "character") + .map((a) => ({ + id: a.id, + name: a.name, + selected: a.id === this.selectedActorId, + })); + + context.selectedActorId = this.selectedActorId; + context.actor = this.actor; + + // Get current flags if actor selected + if (this.actor) { + const flags = this._getActorFlags(this.actor); + + // Skills with labels + context.skills = Object.entries(CONFIG.VAGABOND.skills).map(([id, config]) => ({ + id, + label: game.i18n.localize(config.label), + stat: config.stat, + favor: flags.skills[id]?.favor || false, + hinder: flags.skills[id]?.hinder || false, + })); + + // Attacks + context.attacks = { + favor: flags.attacks.favor, + hinder: flags.attacks.hinder, + }; + + // Saves with labels + context.saves = Object.entries(CONFIG.VAGABOND.saves).map(([id, config]) => ({ + id, + label: game.i18n.localize(config.label), + favor: flags.saves[id]?.favor || false, + hinder: flags.saves[id]?.hinder || false, + })); + } + + return context; + } + + /* -------------------------------------------- */ + /* Event Handlers */ + /* -------------------------------------------- */ + + /** @override */ + _onRender(context, options) { + super._onRender(context, options); + + // Actor selection dropdown + const actorSelect = this.element.querySelector('[name="actorId"]'); + actorSelect?.addEventListener("change", (event) => { + this.selectedActorId = event.target.value; + this.render(); + }); + + // Skill checkboxes + const skillCheckboxes = this.element.querySelectorAll(".skill-flag"); + for (const checkbox of skillCheckboxes) { + checkbox.addEventListener("change", (event) => this._onSkillFlagChange(event)); + } + + // Attack checkboxes + const attackCheckboxes = this.element.querySelectorAll(".attack-flag"); + for (const checkbox of attackCheckboxes) { + checkbox.addEventListener("change", (event) => this._onAttackFlagChange(event)); + } + + // Save checkboxes + const saveCheckboxes = this.element.querySelectorAll(".save-flag"); + for (const checkbox of saveCheckboxes) { + checkbox.addEventListener("change", (event) => this._onSaveFlagChange(event)); + } + + // Clear all button + const clearBtn = this.element.querySelector('[data-action="clear-all"]'); + clearBtn?.addEventListener("click", () => this._onClearAll()); + + // Test roll button + const testRollBtn = this.element.querySelector('[data-action="test-roll"]'); + testRollBtn?.addEventListener("click", () => this._onTestRoll()); + } + + /** + * Handle skill flag checkbox change. + * @param {Event} event + * @private + */ + async _onSkillFlagChange(event) { + if (!this.actor) return; + + const checkbox = event.currentTarget; + const skillId = checkbox.dataset.skill; + const flagType = checkbox.dataset.flagType; // "favor" or "hinder" + const isChecked = checkbox.checked; + + const flagPath = `${flagType}.skills.${skillId}`; + + if (isChecked) { + await this.actor.setFlag("vagabond", flagPath, true); + } else { + await this.actor.unsetFlag("vagabond", flagPath); + } + + // Show notification + const skillLabel = game.i18n.localize(CONFIG.VAGABOND.skills[skillId].label); + const action = isChecked ? "added to" : "removed from"; + ui.notifications.info( + `${flagType.charAt(0).toUpperCase() + flagType.slice(1)} ${action} ${this.actor.name} for ${skillLabel}` + ); + } + + /** + * Handle attack flag checkbox change. + * @param {Event} event + * @private + */ + async _onAttackFlagChange(event) { + if (!this.actor) return; + + const checkbox = event.currentTarget; + const flagType = checkbox.dataset.flagType; + const isChecked = checkbox.checked; + + const flagPath = `${flagType}.attacks`; + + if (isChecked) { + await this.actor.setFlag("vagabond", flagPath, true); + } else { + await this.actor.unsetFlag("vagabond", flagPath); + } + + const action = isChecked ? "added to" : "removed from"; + ui.notifications.info(`Attack ${flagType} ${action} ${this.actor.name}`); + } + + /** + * Handle save flag checkbox change. + * @param {Event} event + * @private + */ + async _onSaveFlagChange(event) { + if (!this.actor) return; + + const checkbox = event.currentTarget; + const saveId = checkbox.dataset.save; + const flagType = checkbox.dataset.flagType; + const isChecked = checkbox.checked; + + const flagPath = `${flagType}.saves.${saveId}`; + + if (isChecked) { + await this.actor.setFlag("vagabond", flagPath, true); + } else { + await this.actor.unsetFlag("vagabond", flagPath); + } + + const saveLabel = game.i18n.localize(CONFIG.VAGABOND.saves[saveId].label); + const action = isChecked ? "added to" : "removed from"; + ui.notifications.info( + `${flagType.charAt(0).toUpperCase() + flagType.slice(1)} ${action} ${this.actor.name} for ${saveLabel} save` + ); + } + + /** + * Clear all favor/hinder flags from the selected actor. + * @private + */ + async _onClearAll() { + if (!this.actor) return; + + // Clear all flags by unsetting the root favor/hinder objects + await this.actor.unsetFlag("vagabond", "favor"); + await this.actor.unsetFlag("vagabond", "hinder"); + + ui.notifications.info(`Cleared all favor/hinder flags from ${this.actor.name}`); + this.render(); + } + + /** + * Open a skill check dialog for testing. + * @private + */ + async _onTestRoll() { + if (!this.actor) { + ui.notifications.warn("Select an actor first"); + return; + } + + // Import and open the skill check dialog + const { SkillCheckDialog } = game.vagabond.applications; + SkillCheckDialog.prompt(this.actor); + } + + /* -------------------------------------------- */ + /* Static Methods */ + /* -------------------------------------------- */ + + /** + * Open the debug panel. + * @returns {Promise} + */ + static async open() { + const app = new this(); + return app.render(true); + } +} diff --git a/module/applications/skill-check-dialog.mjs b/module/applications/skill-check-dialog.mjs new file mode 100644 index 0000000..39eb62c --- /dev/null +++ b/module/applications/skill-check-dialog.mjs @@ -0,0 +1,248 @@ +/** + * 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] || {}; + return { + id, + label: game.i18n.localize(config.label), + stat: config.stat, + trained: skillData.trained || false, + difficulty: skillData.difficulty || 20, + critThreshold: skillData.critThreshold || 20, + selected: id === this.skillId, + }; + }); + + // Selected skill info + context.selectedSkill = this.skillId; + 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 + const statKey = CONFIG.VAGABOND?.skills?.[this.skillId]?.stat; + if (statKey) { + context.statLabel = game.i18n.localize(CONFIG.VAGABOND?.stats?.[statKey]?.label || statKey); + context.statValue = this.actor.system.stats?.[statKey]?.value || 0; + } + } + + 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 + const result = await skillCheck(this.actor, this.skillId, { + 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} + * @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} + */ + 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} + */ + 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; + } +} diff --git a/module/data/actor/character.mjs b/module/data/actor/character.mjs index 82a138a..a0bfe9c 100644 --- a/module/data/actor/character.mjs +++ b/module/data/actor/character.mjs @@ -367,32 +367,14 @@ export default class CharacterData extends VagabondActorBase { }), }), - // Favor/Hinder tracking (d20 +/- d6 modifiers) - // Cancel each other 1-for-1, don't stack - favorHinder: new fields.SchemaField({ - favor: new fields.ArrayField( - new fields.SchemaField({ - source: new fields.StringField({ required: true }), // "Flanking", "Virtuoso", etc. - appliesTo: new fields.ArrayField(new fields.StringField()), // ["Attack Checks"], ["Reflex Saves"] - duration: new fields.StringField({ - initial: "instant", - choices: ["instant", "until-next-turn", "focus", "continual", "permanent"], - }), - }), - { initial: [] } - ), - hinder: new fields.ArrayField( - new fields.SchemaField({ - source: new fields.StringField({ required: true }), // "Heavy Armor", "Fog spell", etc. - appliesTo: new fields.ArrayField(new fields.StringField()), // ["Dodge Saves"], ["sight-based checks"] - duration: new fields.StringField({ - initial: "instant", - choices: ["instant", "until-next-turn", "focus", "continual", "permanent"], - }), - }), - { initial: [] } - ), - }), + // NOTE: Favor/Hinder is now handled via Active Effects flags instead of a data schema. + // See DEVELOPMENT.md "Favor/Hinder via Active Effects" for the flag convention: + // - flags.vagabond.favor.skills. + // - flags.vagabond.hinder.skills. + // - flags.vagabond.favor.attacks + // - flags.vagabond.hinder.attacks + // - flags.vagabond.favor.saves. + // - flags.vagabond.hinder.saves. // Focus tracking for maintained spells focus: new fields.SchemaField({ diff --git a/module/dice/rolls.mjs b/module/dice/rolls.mjs index 730c6e3..8d677d8 100644 --- a/module/dice/rolls.mjs +++ b/module/dice/rolls.mjs @@ -130,8 +130,9 @@ export async function skillCheck(actor, skillId, options = {}) { const difficulty = skillData.difficulty; const critThreshold = skillData.critThreshold || 20; - // Determine favor/hinder - const favorHinder = options.favorHinder ?? actor.getNetFavorHinder?.(`${skillId} Checks`) ?? 0; + // Determine favor/hinder from Active Effect flags or override + const favorHinderResult = actor.getNetFavorHinder?.({ skillId }) ?? { net: 0 }; + const favorHinder = options.favorHinder ?? favorHinderResult.net; return d20Check({ difficulty, @@ -170,8 +171,9 @@ export async function attackCheck(actor, weapon, options = {}) { // Get crit threshold from attack data const critThreshold = system.attacks?.[attackType]?.critThreshold || 20; - // Determine favor/hinder - const favorHinder = options.favorHinder ?? actor.getNetFavorHinder?.("Attack Checks") ?? 0; + // Determine favor/hinder from Active Effect flags or override + const favorHinderResult = actor.getNetFavorHinder?.({ isAttack: true }) ?? { net: 0 }; + const favorHinder = options.favorHinder ?? favorHinderResult.net; return d20Check({ difficulty, @@ -200,12 +202,9 @@ export async function saveRoll(actor, saveType, difficulty, options = {}) { throw new Error(`Unknown save type: ${saveType}`); } - // Determine favor/hinder based on save type - let rollType = `${saveType.charAt(0).toUpperCase() + saveType.slice(1)} Saves`; - if (options.isBlock) rollType = "Block Saves"; - if (options.isDodge) rollType = "Dodge Saves"; - - const favorHinder = options.favorHinder ?? actor.getNetFavorHinder?.(rollType) ?? 0; + // Determine favor/hinder from Active Effect flags or override + const favorHinderResult = actor.getNetFavorHinder?.({ saveType }) ?? { net: 0 }; + const favorHinder = options.favorHinder ?? favorHinderResult.net; return d20Check({ difficulty, diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs index b0a00fd..ff2fe94 100644 --- a/module/documents/actor.mjs +++ b/module/documents/actor.mjs @@ -464,29 +464,99 @@ export default class VagabondActor extends Actor { /** * Get the net favor/hinder for a specific roll type. - * Favor and Hinder cancel 1-for-1. + * Checks Active Effect flags for persistent favor/hinder sources. + * Favor and Hinder cancel 1-for-1, capped at +1 or -1. * - * @param {string} rollType - The type of roll (e.g., "Attack Checks", "Reflex Saves") - * @returns {number} Net modifier: positive = favor, negative = hinder, 0 = neutral + * Flag convention (set by Active Effects): + * - flags.vagabond.favor.skills. - Favor on specific skill + * - flags.vagabond.hinder.skills. - Hinder on specific skill + * - flags.vagabond.favor.attacks - Favor on attack rolls + * - flags.vagabond.hinder.attacks - Hinder on attack rolls + * - flags.vagabond.favor.saves. - Favor on specific save + * - flags.vagabond.hinder.saves. - Hinder on specific save + * + * @param {Object} options - Options for determining favor/hinder + * @param {string} [options.skillId] - Skill ID for skill checks (e.g., "arcana", "brawl") + * @param {boolean} [options.isAttack] - True if this is an attack roll + * @param {string} [options.saveType] - Save type (e.g., "reflex", "endure", "will") + * @returns {Object} Result with net value and sources + * @returns {number} result.net - Net modifier: +1 (favor), 0 (neutral), -1 (hinder) + * @returns {string[]} result.favorSources - Names of active favor sources + * @returns {string[]} result.hinderSources - Names of active hinder sources */ - getNetFavorHinder(rollType) { - if (this.type !== "character") return 0; + getNetFavorHinder({ skillId = null, isAttack = false, saveType = null } = {}) { + if (this.type !== "character") return { net: 0, favorSources: [], hinderSources: [] }; - const favorHinder = this.system.favorHinder; - if (!favorHinder) return 0; + const favorSources = []; + const hinderSources = []; - // Count favor sources that apply to this roll type - const favorCount = (favorHinder.favor || []).filter( - (f) => !f.appliesTo?.length || f.appliesTo.includes(rollType) - ).length; + // Check skill-specific flags + if (skillId) { + if (this.getFlag("vagabond", `favor.skills.${skillId}`)) { + favorSources.push(this._getFavorHinderSourceName("favor", "skills", skillId)); + } + if (this.getFlag("vagabond", `hinder.skills.${skillId}`)) { + hinderSources.push(this._getFavorHinderSourceName("hinder", "skills", skillId)); + } + } - // Count hinder sources that apply to this roll type - const hinderCount = (favorHinder.hinder || []).filter( - (h) => !h.appliesTo?.length || h.appliesTo.includes(rollType) - ).length; + // Check attack flags + if (isAttack) { + if (this.getFlag("vagabond", "favor.attacks")) { + favorSources.push(this._getFavorHinderSourceName("favor", "attacks")); + } + if (this.getFlag("vagabond", "hinder.attacks")) { + hinderSources.push(this._getFavorHinderSourceName("hinder", "attacks")); + } + } + + // Check save-specific flags + if (saveType) { + if (this.getFlag("vagabond", `favor.saves.${saveType}`)) { + favorSources.push(this._getFavorHinderSourceName("favor", "saves", saveType)); + } + if (this.getFlag("vagabond", `hinder.saves.${saveType}`)) { + hinderSources.push(this._getFavorHinderSourceName("hinder", "saves", saveType)); + } + } // They cancel 1-for-1, max of +1 or -1 - const net = favorCount - hinderCount; - return Math.clamp(net, -1, 1); + const net = Math.clamp(favorSources.length - hinderSources.length, -1, 1); + + return { net, favorSources, hinderSources }; + } + + /** + * Get the source name for a favor/hinder flag by finding the Active Effect that set it. + * + * @param {string} type - "favor" or "hinder" + * @param {string} category - "skills", "attacks", or "saves" + * @param {string} [subtype] - Skill ID or save type + * @returns {string} Source name or generic description + * @private + */ + _getFavorHinderSourceName(type, category, subtype = null) { + const flagKey = subtype + ? `flags.vagabond.${type}.${category}.${subtype}` + : `flags.vagabond.${type}.${category}`; + + // Find the Active Effect that sets this flag + for (const effect of this.effects) { + if (!effect.active) continue; + for (const change of effect.changes) { + if (change.key === flagKey) { + return effect.name || effect.parent?.name || `${type} effect`; + } + } + } + + // Fallback if source not found + const categoryLabel = + category === "skills" + ? `${subtype} checks` + : category === "saves" + ? `${subtype} saves` + : category; + return `${type} on ${categoryLabel}`; } } diff --git a/module/tests/actor.test.mjs b/module/tests/actor.test.mjs index 225c885..39b0a80 100644 --- a/module/tests/actor.test.mjs +++ b/module/tests/actor.test.mjs @@ -260,32 +260,78 @@ export function registerActorTests(quenchRunner) { }); describe("Favor/Hinder System", () => { - it("tracks favor and hinder modifiers separately", async () => { + it("detects favor from Active Effect flags", async () => { /** - * Favor adds +d6 to rolls, Hinder adds -d6. - * They cancel 1-for-1 and don't stack (multiple favors = still +1d6). - * Each entry tracks: source, appliesTo (what rolls), duration. + * Favor/Hinder is now tracked via Active Effect flags instead of data schema. + * Flag convention: flags.vagabond.favor.skills. + * The getNetFavorHinder method checks these flags. */ - await testActor.update({ - "system.favorHinder.favor": [ - { - source: "Flanking", - appliesTo: ["Attack Checks"], - duration: "until-next-turn", - }, - ], - "system.favorHinder.hinder": [ - { - source: "Heavy Armor", - appliesTo: ["Dodge Saves"], - duration: "permanent", - }, - ], - }); + // Set a flag directly (simulating what an Active Effect would do) + await testActor.setFlag("vagabond", "favor.skills.performance", true); - expect(testActor.system.favorHinder.favor.length).to.equal(1); - expect(testActor.system.favorHinder.hinder.length).to.equal(1); - expect(testActor.system.favorHinder.favor[0].source).to.equal("Flanking"); + const result = testActor.getNetFavorHinder({ skillId: "performance" }); + expect(result.net).to.equal(1); + expect(result.favorSources.length).to.equal(1); + + // Clean up + await testActor.unsetFlag("vagabond", "favor.skills.performance"); + }); + + it("detects hinder from Active Effect flags", async () => { + /** + * Hinder flags work the same way as favor flags. + * Flag convention: flags.vagabond.hinder.skills. + */ + await testActor.setFlag("vagabond", "hinder.skills.sneak", true); + + const result = testActor.getNetFavorHinder({ skillId: "sneak" }); + expect(result.net).to.equal(-1); + expect(result.hinderSources.length).to.equal(1); + + // Clean up + await testActor.unsetFlag("vagabond", "hinder.skills.sneak"); + }); + + it("cancels favor and hinder 1-for-1", async () => { + /** + * When both favor and hinder apply to the same roll, they cancel out. + * Net result is clamped to -1, 0, or +1. + */ + await testActor.setFlag("vagabond", "favor.skills.arcana", true); + await testActor.setFlag("vagabond", "hinder.skills.arcana", true); + + const result = testActor.getNetFavorHinder({ skillId: "arcana" }); + expect(result.net).to.equal(0); + + // Clean up + await testActor.unsetFlag("vagabond", "favor.skills.arcana"); + await testActor.unsetFlag("vagabond", "hinder.skills.arcana"); + }); + + it("detects favor/hinder for attack rolls", async () => { + /** + * Attack rolls check flags.vagabond.favor.attacks and hinder.attacks. + */ + await testActor.setFlag("vagabond", "favor.attacks", true); + + const result = testActor.getNetFavorHinder({ isAttack: true }); + expect(result.net).to.equal(1); + + // Clean up + await testActor.unsetFlag("vagabond", "favor.attacks"); + }); + + it("detects favor/hinder for save rolls", async () => { + /** + * Save rolls check flags.vagabond.favor.saves.. + */ + await testActor.setFlag("vagabond", "hinder.saves.reflex", true); + + const result = testActor.getNetFavorHinder({ saveType: "reflex" }); + expect(result.net).to.equal(-1); + + // Clean up + await testActor.unsetFlag("vagabond", "hinder.saves.reflex"); }); }); diff --git a/module/vagabond.mjs b/module/vagabond.mjs index d773d22..e6496f7 100644 --- a/module/vagabond.mjs +++ b/module/vagabond.mjs @@ -22,6 +22,9 @@ import { // Import document classes import { VagabondActor, VagabondItem } from "./documents/_module.mjs"; +// Import application classes +import { VagabondRollDialog, SkillCheckDialog, FavorHinderDebug } from "./applications/_module.mjs"; + // Import sheet classes // import { VagabondCharacterSheet } from "./sheets/actor-sheet.mjs"; // import { VagabondItemSheet } from "./sheets/item-sheet.mjs"; @@ -46,6 +49,15 @@ Hooks.once("init", () => { // Add custom constants for configuration CONFIG.VAGABOND = VAGABOND; + // Expose application classes globally for macro/API access + game.vagabond = { + applications: { + VagabondRollDialog, + SkillCheckDialog, + FavorHinderDebug, + }, + }; + // Register Actor data models CONFIG.Actor.dataModels = { character: CharacterData, @@ -93,7 +105,7 @@ Hooks.once("init", () => { /** * Ready hook - runs when Foundry is fully loaded */ -Hooks.once("ready", () => { +Hooks.once("ready", async () => { // eslint-disable-next-line no-console console.log("Vagabond RPG | System Ready"); @@ -101,9 +113,58 @@ Hooks.once("ready", () => { if (game.user.isGM) { const version = game.system.version; ui.notifications.info(`Vagabond RPG v${version} - System loaded successfully!`); + + // Create development macros if they don't exist + await _createDevMacros(); } }); +/** + * Create development/debug macros if they don't already exist. + * @private + */ +async function _createDevMacros() { + // Favor/Hinder Debug macro + const debugMacroName = "Favor/Hinder Debug"; + const existingMacro = game.macros.find((m) => m.name === debugMacroName); + + if (!existingMacro) { + await Macro.create({ + name: debugMacroName, + type: "script", + img: "icons/svg/bug.svg", + command: "game.vagabond.applications.FavorHinderDebug.open();", + flags: { vagabond: { systemMacro: true } }, + }); + // eslint-disable-next-line no-console + console.log("Vagabond RPG | Created Favor/Hinder Debug macro"); + } + + // Skill Check macro + const skillMacroName = "Skill Check"; + const existingSkillMacro = game.macros.find((m) => m.name === skillMacroName); + + if (!existingSkillMacro) { + await Macro.create({ + name: skillMacroName, + type: "script", + img: "icons/svg/d20.svg", + command: `// Opens skill check dialog for selected token or prompts to select actor +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.SkillCheckDialog.prompt(actor); +}`, + flags: { vagabond: { systemMacro: true } }, + }); + // eslint-disable-next-line no-console + console.log("Vagabond RPG | Created Skill Check macro"); + } +} + /* -------------------------------------------- */ /* Handlebars Helpers */ /* -------------------------------------------- */ diff --git a/styles/scss/chat/_chat-cards.scss b/styles/scss/chat/_chat-cards.scss index bc9bb47..f46f8c8 100644 --- a/styles/scss/chat/_chat-cards.scss +++ b/styles/scss/chat/_chat-cards.scss @@ -1,7 +1,7 @@ // Vagabond RPG - Chat Card Styles // ================================ -// Placeholder - will be expanded in Phase 7 +// Base chat card .vagabond.chat-card { @include panel; overflow: hidden; @@ -13,16 +13,27 @@ background-color: $color-parchment-dark; border-bottom: 1px solid $color-border; - .card-title { + .card-title, + h3 { font-family: $font-family-header; font-size: $font-size-base; font-weight: $font-weight-bold; + margin: 0; } .card-subtitle { font-size: $font-size-sm; color: $color-text-muted; } + + .trained-badge { + font-size: $font-size-xs; + padding: $spacing-1 $spacing-2; + background-color: rgba($color-success, 0.2); + color: $color-success; + border-radius: $radius-full; + font-weight: $font-weight-medium; + } } // Card content @@ -30,27 +41,176 @@ padding: $spacing-3; } - // Roll result + // Roll result (large total display) .roll-result { - @include flex-center; - gap: $spacing-3; + @include flex-column; + align-items: center; + gap: $spacing-2; padding: $spacing-3; + margin: $spacing-2 0; background-color: $color-parchment-light; border-radius: $radius-md; + border: 2px solid $color-border; .roll-total { font-family: $font-family-header; - font-size: $font-size-3xl; + font-size: $font-size-4xl; font-weight: $font-weight-bold; + line-height: 1; } - .roll-formula { - font-size: $font-size-sm; - color: $color-text-muted; + .roll-status { + .status { + display: inline-block; + padding: $spacing-1 $spacing-3; + font-size: $font-size-sm; + font-weight: $font-weight-bold; + text-transform: uppercase; + letter-spacing: 0.1em; + border-radius: $radius-md; + } + + .success { + background-color: rgba($color-success, 0.2); + color: $color-success; + } + + .failure { + background-color: rgba($color-danger, 0.2); + color: $color-danger; + } + + .critical { + background-color: rgba($color-warning, 0.3); + color: $color-warning; + animation: pulse 1s ease-in-out; + } + + .fumble { + background-color: rgba($color-danger, 0.3); + color: $color-danger; + animation: shake 0.5s ease-in-out; + } + } + + // Conditional styling based on result + &.success { + border-color: $color-success; + } + + &.failure { + border-color: $color-danger; + } + + &.critical { + border-color: $color-warning; + background-color: rgba($color-warning, 0.1); + } + + &.fumble { + border-color: $color-danger; + background-color: rgba($color-danger, 0.1); } } - // Result status + // Roll details + .roll-details { + @include flex-column; + gap: $spacing-2; + padding: $spacing-2; + background-color: $color-parchment-dark; + border-radius: $radius-md; + font-size: $font-size-sm; + + .roll-formula { + @include flex-between; + + .label { + color: $color-text-muted; + } + + .value { + font-family: $font-family-mono; + } + } + + .roll-breakdown { + @include flex-center; + gap: $spacing-3; + padding-top: $spacing-2; + border-top: 1px solid $color-border; + + span { + @include flex-center; + gap: $spacing-1; + } + + .d20-result { + font-family: $font-family-mono; + font-weight: $font-weight-bold; + } + + .favor-die { + font-family: $font-family-mono; + + &.favor { + color: $color-success; + } + + &.hinder { + color: $color-danger; + } + } + + .modifier { + font-family: $font-family-mono; + color: $color-text-secondary; + } + } + } + + // Target info (difficulty, crit threshold) + .target-info { + @include grid(2, $spacing-2); + margin-top: $spacing-2; + padding: $spacing-2; + font-size: $font-size-sm; + + > div { + @include flex-between; + } + + .label { + color: $color-text-muted; + } + + .value { + font-weight: $font-weight-medium; + } + } + + // Favor/Hinder sources + .favor-sources, + .hinder-sources { + @include flex-center; + gap: $spacing-2; + margin-top: $spacing-2; + padding: $spacing-2; + font-size: $font-size-sm; + border-radius: $radius-md; + } + + .favor-sources { + background-color: rgba($color-success, 0.1); + color: $color-success; + } + + .hinder-sources { + background-color: rgba($color-danger, 0.1); + color: $color-danger; + } + + // Result status (legacy) .result-status { @include flex-center; padding: $spacing-2; @@ -117,6 +277,13 @@ } } +// Skill roll card specific +.vagabond.chat-card.skill-roll { + .skill-name { + margin: 0; + } +} + // Spell card specific .vagabond.chat-card.spell-card { .spell-effect { @@ -142,7 +309,7 @@ } } -// Animation +// Animations @keyframes pulse { 0%, 100% { @@ -152,3 +319,16 @@ transform: scale(1.05); } } + +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-5px); + } + 75% { + transform: translateX(5px); + } +} diff --git a/styles/scss/components/_forms.scss b/styles/scss/components/_forms.scss index 9860ead..1e67afa 100644 --- a/styles/scss/components/_forms.scss +++ b/styles/scss/components/_forms.scss @@ -37,12 +37,17 @@ select { @include input-base; width: 100%; + height: 2.5rem; // Fixed height for consistent visibility + padding: $spacing-2 $spacing-8 $spacing-2 $spacing-3; cursor: pointer; appearance: none; + background-color: $color-parchment-light !important; + color: $color-text-primary !important; + font-size: $font-size-base; + line-height: 1.5; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%232c2416' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right $spacing-3 center; - padding-right: $spacing-8; } // Checkbox diff --git a/styles/scss/dialogs/_roll-dialog.scss b/styles/scss/dialogs/_roll-dialog.scss index 1e19946..36b9b69 100644 --- a/styles/scss/dialogs/_roll-dialog.scss +++ b/styles/scss/dialogs/_roll-dialog.scss @@ -1,7 +1,211 @@ // Vagabond RPG - Roll Dialog Styles // ================================== -// Placeholder - will be expanded in Phase 7 +// ApplicationV2 Roll Dialog Base +.vagabond.roll-dialog { + // Force light background on the entire window + background-color: $color-parchment; + color: $color-text-primary; + + .roll-dialog-content { + @include flex-column; + gap: $spacing-3; + padding: $spacing-4; + background-color: $color-parchment; + color: $color-text-primary; + } + + // Automatic favor/hinder from Active Effects + .auto-favor-hinder { + @include flex-center; + gap: $spacing-2; + padding: $spacing-2 $spacing-3; + border-radius: $radius-md; + font-size: $font-size-sm; + + i { + font-size: $font-size-base; + } + + &.favor { + background-color: rgba($color-success, 0.15); + color: $color-success; + border: 1px solid $color-success; + } + + &.hinder { + background-color: rgba($color-danger, 0.15); + color: $color-danger; + border: 1px solid $color-danger; + } + } + + // Skill selection + .skill-selection { + @include flex-column; + gap: $spacing-2; + + label { + font-weight: $font-weight-semibold; + } + } + + // Skill info panel + .skill-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; + + &.trained { + color: $color-success; + } + + &.untrained { + color: $color-text-muted; + } + + &.difficulty { + font-family: $font-family-header; + font-size: $font-size-lg; + font-weight: $font-weight-bold; + } + + &.crit { + color: $color-warning; + } + } + } + + // Favor/Hinder toggle section + .favor-hinder-section { + @include flex-column; + gap: $spacing-2; + + > label { + font-weight: $font-weight-semibold; + } + + .favor-hinder-toggles { + display: flex; + gap: $spacing-2; + + button { + @include button-secondary; + flex: 1; + gap: $spacing-2; + + &.favor-btn { + &.active, + &:hover { + background-color: rgba($color-success, 0.15); + border-color: $color-success; + color: $color-success; + } + } + + &.hinder-btn { + &.active, + &:hover { + background-color: rgba($color-danger, 0.15); + border-color: $color-danger; + color: $color-danger; + } + } + } + } + + .net-favor-hinder { + @include flex-center; + padding: $spacing-1 $spacing-2; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + border-radius: $radius-md; + + &.favor { + background-color: rgba($color-success, 0.15); + color: $color-success; + } + + &.hinder { + background-color: rgba($color-danger, 0.15); + color: $color-danger; + } + } + } + + // Situational modifier section + .modifier-section { + @include flex-column; + gap: $spacing-2; + + > label { + font-weight: $font-weight-semibold; + } + + .modifier-presets { + display: flex; + gap: $spacing-2; + + .modifier-preset { + @include button-secondary; + flex: 1; + padding: $spacing-1 $spacing-2; + font-family: $font-family-mono; + font-size: $font-size-sm; + + &:hover { + background-color: $color-parchment-dark; + } + } + } + + .modifier-input { + input { + @include input-base; + width: 100%; + text-align: center; + font-family: $font-family-mono; + } + } + } + + // Roll button + .dialog-buttons { + margin-top: $spacing-2; + + .roll-btn { + @include button-primary; + width: 100%; + padding: $spacing-3; + font-size: $font-size-lg; + gap: $spacing-2; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + } +} + +// Skill check dialog specific +.vagabond.skill-check-dialog { + // Additional skill-specific styles if needed +} + +// Legacy dialog styles (for backward compatibility) .vagabond.dialog.roll-dialog { .dialog-content { padding: $spacing-4; @@ -152,3 +356,177 @@ } } } + +// Favor/Hinder Debug Panel +.vagabond.favor-hinder-debug { + // Force light background on the entire window content + // Using !important to override Foundry's dark theme styles + background-color: $color-parchment !important; + color: $color-text-primary !important; + + * { + color: $color-text-primary; + } + + // Enable scrolling on the window content + .window-content { + overflow-y: auto !important; + max-height: 80vh; + @include custom-scrollbar; + } + + .favor-hinder-debug-content { + @include flex-column; + gap: $spacing-4; + padding: $spacing-4; + background-color: $color-parchment !important; + } + + .actor-selection { + @include flex-column; + gap: $spacing-2; + + label { + font-weight: $font-weight-semibold; + color: $color-text-primary; + } + } + + .debug-panels { + @include flex-column; + gap: $spacing-4; + } + + .debug-panel { + @include panel; + padding: $spacing-3; + background-color: $color-parchment-light; + + h3 { + @include flex-center; + justify-content: flex-start; + gap: $spacing-2; + margin: 0 0 $spacing-3 0; + padding-bottom: $spacing-2; + border-bottom: 1px solid $color-border; + font-size: $font-size-base; + color: $color-text-primary; + + i { + color: $color-accent-primary; + } + } + } + + .flag-table { + width: 100%; + border-collapse: collapse; + background-color: $color-parchment-light; + + th, + td { + padding: $spacing-2; + text-align: left; + border-bottom: 1px solid $color-border-light; + color: $color-text-primary; + } + + th { + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: $color-text-secondary; + background-color: $color-parchment-dark; + + &.center { + text-align: center; + } + } + + td { + background-color: $color-parchment-light !important; + + &.center { + text-align: center; + } + + &.skill-name { + @include flex-between; + } + } + + // Alternating row colors with good contrast + tbody tr:nth-child(even) td { + background-color: $color-parchment !important; + } + + .stat-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; + } + + input[type="checkbox"] { + width: 1.25rem; + height: 1.25rem; + cursor: pointer; + accent-color: $color-accent-primary; + } + + tbody tr:hover td { + background-color: $color-parchment-dark; + } + } + + .debug-actions { + display: flex; + gap: $spacing-3; + padding-top: $spacing-3; + border-top: 1px solid $color-border; + + button { + @include button-base; + flex: 1; + gap: $spacing-2; + } + + .test-roll-btn { + @include button-primary; + } + + .clear-btn { + @include button-secondary; + color: $color-danger; + border-color: $color-danger; + + &:hover:not(:disabled) { + background-color: rgba($color-danger, 0.1); + } + } + } + + .no-actor-message { + @include flex-column; + align-items: center; + gap: $spacing-3; + padding: $spacing-6; + text-align: center; + color: $color-text-muted; + + i { + font-size: $font-size-4xl; + opacity: 0.5; + } + + p { + margin: 0; + } + + .hint { + font-size: $font-size-sm; + font-style: italic; + } + } +} diff --git a/templates/chat/skill-roll.hbs b/templates/chat/skill-roll.hbs new file mode 100644 index 0000000..30f66f8 --- /dev/null +++ b/templates/chat/skill-roll.hbs @@ -0,0 +1,79 @@ +{{!-- Skill Check Chat Card Template --}} +{{!-- Displays skill check results with roll details, success/fail, and modifiers --}} + +
+ {{!-- Header --}} +
+

{{skillLabel}}

+ {{#if trained}} + {{localize "VAGABOND.Trained"}} + {{/if}} +
+ + {{!-- Roll Result --}} +
+
{{total}}
+
+ {{#if isCrit}} + {{localize "VAGABOND.Critical"}} + {{else if isFumble}} + {{localize "VAGABOND.Fumble"}} + {{else if success}} + {{localize "VAGABOND.Success"}} + {{else}} + {{localize "VAGABOND.Failure"}} + {{/if}} +
+
+ + {{!-- Roll Details --}} +
+
+ {{localize "VAGABOND.Formula"}}: + {{formula}} +
+
+ + {{d20Result}} + + {{#if favorDie}} + + {{favorDie}} + + {{/if}} + {{#if modifier}} + + {{#if (gt modifier 0)}}+{{/if}}{{modifier}} + + {{/if}} +
+
+ + {{!-- Target Info --}} +
+
+ {{localize "VAGABOND.Difficulty"}}: + {{difficulty}} +
+ {{#if (lt critThreshold 20)}} +
+ {{localize "VAGABOND.CritThreshold"}}: + {{critThreshold}}+ +
+ {{/if}} +
+ + {{!-- Favor/Hinder Sources --}} + {{#if favorSources.length}} +
+ + {{localize "VAGABOND.Favor"}}: {{#each favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} + {{#if hinderSources.length}} +
+ + {{localize "VAGABOND.Hinder"}}: {{#each hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} +
diff --git a/templates/dialog/favor-hinder-debug.hbs b/templates/dialog/favor-hinder-debug.hbs new file mode 100644 index 0000000..e75ab1a --- /dev/null +++ b/templates/dialog/favor-hinder-debug.hbs @@ -0,0 +1,153 @@ +{{!-- Favor/Hinder Debug Panel Template --}} +{{!-- Development tool for testing favor/hinder flags on actors --}} + +
+ {{!-- Actor Selection --}} +
+ + +
+ + {{#if actor}} +
+ {{!-- Skills Panel --}} +
+

+ + {{localize "VAGABOND.Skills"}} +

+ + + + + + + + + + {{#each skills}} + + + + + + {{/each}} + +
{{localize "VAGABOND.Skill"}}{{localize "VAGABOND.Favor"}}{{localize "VAGABOND.Hinder"}}
+ {{this.label}} + {{this.stat}} + + + + +
+
+ + {{!-- Attacks Panel --}} +
+

+ + {{localize "VAGABOND.Attacks"}} +

+ + + + + + + + + + + + + + + +
Type{{localize "VAGABOND.Favor"}}{{localize "VAGABOND.Hinder"}}
All Attacks + + + +
+
+ + {{!-- Saves Panel --}} +
+

+ + {{localize "VAGABOND.Saves"}} +

+ + + + + + + + + + {{#each saves}} + + + + + + {{/each}} + +
{{localize "VAGABOND.Save"}}{{localize "VAGABOND.Favor"}}{{localize "VAGABOND.Hinder"}}
{{this.label}} + + + +
+
+
+ + {{!-- Action Buttons --}} +
+ + +
+ + {{else}} +
+ +

Select an actor to manage favor/hinder flags.

+

You can also select a token on the canvas before opening this panel.

+
+ {{/if}} +
diff --git a/templates/dialog/roll-dialog-base.hbs b/templates/dialog/roll-dialog-base.hbs new file mode 100644 index 0000000..2c6d52f --- /dev/null +++ b/templates/dialog/roll-dialog-base.hbs @@ -0,0 +1,66 @@ +{{!-- Base Roll Dialog Template --}} +{{!-- Provides common UI for all roll dialogs: favor/hinder, modifiers, roll button --}} + +
+ {{!-- Automatic Favor/Hinder from Active Effects --}} + {{#if hasAutoFavor}} +
+ + {{localize "VAGABOND.AutoFavor"}}: {{#each autoFavorHinder.favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} + {{#if hasAutoHinder}} +
+ + {{localize "VAGABOND.AutoHinder"}}: {{#each autoFavorHinder.hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} + + {{!-- Roll-specific content (provided by subclass) --}} +
+ {{> @partial-block}} +
+ + {{!-- Favor/Hinder Toggles --}} +
+ +
+ + +
+ {{#if (gt netFavorHinder 0)}} +
+d6 {{localize "VAGABOND.Favor"}}
+ {{else if (lt netFavorHinder 0)}} +
-d6 {{localize "VAGABOND.Hinder"}}
+ {{/if}} +
+ + {{!-- Situational Modifier --}} +
+ +
+ {{#each modifierPresets}} + + {{/each}} +
+
+ +
+
+ + {{!-- Roll Button --}} +
+ +
+
diff --git a/templates/dialog/skill-check.hbs b/templates/dialog/skill-check.hbs new file mode 100644 index 0000000..306382a --- /dev/null +++ b/templates/dialog/skill-check.hbs @@ -0,0 +1,104 @@ +{{!-- Skill Check Dialog Template --}} +{{!-- Extends roll-dialog-base with skill-specific information --}} + +
+ {{!-- Automatic Favor/Hinder from Active Effects --}} + {{#if hasAutoFavor}} +
+ + {{localize "VAGABOND.AutoFavor"}}: {{#each autoFavorHinder.favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} + {{#if hasAutoHinder}} +
+ + {{localize "VAGABOND.AutoHinder"}}: {{#each autoFavorHinder.hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{/if}} + + {{!-- Skill Selection --}} +
+ + +
+ + {{!-- Skill Info (if skill selected) --}} + {{#if rollSpecific.selectedSkill}} +
+
+ {{localize "VAGABOND.Stat"}}: + {{rollSpecific.statLabel}} ({{rollSpecific.statValue}}) +
+
+ {{localize "VAGABOND.Training"}}: + + {{#if rollSpecific.trained}} + {{localize "VAGABOND.Trained"}} + {{else}} + {{localize "VAGABOND.Untrained"}} + {{/if}} + +
+
+ {{localize "VAGABOND.Difficulty"}}: + {{rollSpecific.difficulty}} +
+ {{#if (lt rollSpecific.critThreshold 20)}} +
+ {{localize "VAGABOND.CritThreshold"}}: + {{rollSpecific.critThreshold}}+ +
+ {{/if}} +
+ {{/if}} + + {{!-- Favor/Hinder Toggles --}} +
+ +
+ + +
+ {{#if (gt netFavorHinder 0)}} +
+d6 {{localize "VAGABOND.Favor"}}
+ {{else if (lt netFavorHinder 0)}} +
-d6 {{localize "VAGABOND.Hinder"}}
+ {{/if}} +
+ + {{!-- Situational Modifier --}} +
+ +
+ {{#each modifierPresets}} + + {{/each}} +
+
+ +
+
+ + {{!-- Roll Button --}} +
+ +
+
diff --git a/test_results/2025-12-12-2241.txt b/test_results/2025-12-12-2241.txt new file mode 100644 index 0000000..aac0958 --- /dev/null +++ b/test_results/2025-12-12-2241.txt @@ -0,0 +1,462 @@ +(PASS) Test Complete: adds +d6 when favorHinder is positive +Object { test: {…} } +quench-reporter.ts:150:14 +(PASS) Test Complete: subtracts d6 when favorHinder is negative +Object { test: {…} } +quench-reporter.ts:150:14 +(PASS) Test Complete: has no extra die when favorHinder is 0 +Object { test: {…} } +quench-reporter.ts:150:14 +Suite: Flat Modifiers +Object { suite: {…} } +quench-reporter.ts:105:15 +(PASS) Test Complete: applies positive modifiers to the roll +Object { test: {…} } +quench-reporter.ts:150:14 +(PASS) Test Complete: applies negative modifiers to the roll +Object { test: {…} } +quench-reporter.ts:150:14 +Vagabond: Skill Check System quench-reporter.ts:103:15 +Suite: Skill Check Rolls +Object { suite: {…} } +quench-reporter.ts:105:15 +Error: VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +(FAIL) Test Complete: uses correct difficulty for trained skills +Object { test: {…}, err: TypeError } +quench-reporter.ts:170:14 +Error: VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +(FAIL) Test Complete: uses correct difficulty for untrained skills +Object { test: {…}, err: TypeError } +quench-reporter.ts:170:14 +Error: VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +(FAIL) Test Complete: uses skill-specific crit threshold +Object { test: {…}, err: TypeError } +quench-reporter.ts:170:14 +Error: VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +(PASS) Test Complete: throws error for unknown skill +Object { test: {…} } +quench-reporter.ts:150:14 +Vagabond: Attack Check System quench-reporter.ts:103:15 +Suite: Attack Check Rolls +Object { suite: {…} } +quench-reporter.ts:105:15 +Error: VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +Error: VagabondItem validation errors: + type: "weapon" is not a valid type for the Item Document class + system: + equippedHand: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseItem http://localhost:30000/scripts/foundry.mjs:15503 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Item http://localhost:30000/scripts/foundry.mjs:45138 + VagabondItem http://localhost:30000/systems/vagabond/module/documents/item.mjs:15 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +(FAIL) Test Complete: calculates difficulty from attack stat +Object { test: {…}, err: TypeError } +quench-reporter.ts:170:14 +Error: VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +Error: VagabondItem validation errors: + type: "weapon" is not a valid type for the Item Document class + system: + equippedHand: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseItem http://localhost:30000/scripts/foundry.mjs:15503 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Item http://localhost:30000/scripts/foundry.mjs:45138 + VagabondItem http://localhost:30000/systems/vagabond/module/documents/item.mjs:15 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +(FAIL) Test Complete: uses attack-specific crit threshold +Object { test: {…}, err: TypeError } +quench-reporter.ts:170:14 +Vagabond: Save Roll System quench-reporter.ts:103:15 +Suite: Save Rolls +Object { suite: {…} } +quench-reporter.ts:105:15 +Error: VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +(FAIL) Test Complete: rolls against provided difficulty +Object { test: {…}, err: TypeError } +quench-reporter.ts:170:14 +Error: VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +(FAIL) Test Complete: saves do not crit (threshold stays 20) +Object { test: {…}, err: TypeError } +quench-reporter.ts:170:14 +Vagabond: Damage Roll System quench-reporter.ts:103:15 +Suite: Damage Rolls +Object { suite: {…} } +quench-reporter.ts:105:15 +(PASS) Test Complete: evaluates damage formula +Object { test: {…} } +quench-reporter.ts:150:14 +(PASS) Test Complete: doubles dice on critical hit +Object { test: {…} } +quench-reporter.ts:150:14 +(PASS) Test Complete: does not double modifiers on crit +Object { test: {…} } +quench-reporter.ts:150:14 +Suite: doubleDice Helper +Object { suite: {…} } +quench-reporter.ts:105:15 +(PASS) Test Complete: doubles dice count in formula +Object { test: {…} } +quench-reporter.ts:150:14 +(PASS) Test Complete: preserves modifiers when doubling dice +Object { test: {…} } +quench-reporter.ts:150:14 +(PASS) Test Complete: handles multiple dice types +Object { test: {…} } +quench-reporter.ts:150:14 +Vagabond: Countdown Dice System quench-reporter.ts:103:15 +Suite: Countdown Dice +Object { suite: {…} } +quench-reporter.ts:105:15 +(PASS) Test Complete: rolls the specified die size +Object { test: {…} } +quench-reporter.ts:150:14 +(PASS) Test Complete: continues on high rolls (3-6 on d6) +Object { test: {…} } +quench-reporter.ts:150:14 +(PASS) Test Complete: shrinks die on low rolls (1-2) +Object { test: {…} } +quench-reporter.ts:150:14 +(PASS) Test Complete: ends effect when d4 rolls 1-2 +Object { test: {…} } +quench-reporter.ts:150:14 +(PASS) Test Complete: returns ended state for die size 0 +Object { test: {…} } +quench-reporter.ts:150:14 +Vagabond: Morale Check System quench-reporter.ts:103:15 +Suite: Morale Checks +Object { suite: {…} } +quench-reporter.ts:105:15 +Error: VagabondActor validation errors: + type: "npc" is not a valid type for the Actor Document class + system: + moraleStatus: + lastTrigger: may not be a blank string + lastResult: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +(FAIL) Test Complete: rolls 2d6 against morale score +Object { test: {…}, err: TypeError } +quench-reporter.ts:170:14 +Error: VagabondActor validation errors: + type: "npc" is not a valid type for the Actor Document class + system: + moraleStatus: + lastTrigger: may not be a blank string + lastResult: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +(FAIL) Test Complete: passes when roll <= morale +Object { test: {…}, err: TypeError } +quench-reporter.ts:170:14 +Error: VagabondActor validation errors: + type: "npc" is not a valid type for the Actor Document class + system: + moraleStatus: + lastTrigger: may not be a blank string + lastResult: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +(FAIL) Test Complete: fails when roll > morale +Object { test: {…}, err: TypeError } +quench-reporter.ts:170:14 +Error: VagabondActor validation errors: + type: "npc" is not a valid type for the Actor Document class + system: + moraleStatus: + lastTrigger: may not be a blank string + lastResult: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +Error: VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string + DataModelValidationError http://localhost:30000/scripts/foundry.mjs:5221 + asError http://localhost:30000/scripts/foundry.mjs:5141 + validate http://localhost:30000/scripts/foundry.mjs:11743 + DataModel http://localhost:30000/scripts/foundry.mjs:11419 + Document http://localhost:30000/scripts/foundry.mjs:12005 + BaseActor http://localhost:30000/scripts/foundry.mjs:14207 + ClientDocument http://localhost:30000/scripts/foundry.mjs:32362 + Actor http://localhost:30000/scripts/foundry.mjs:41217 + VagabondActor http://localhost:30000/systems/vagabond/module/documents/actor.mjs:16 + #preCreateDocumentArray http://localhost:30000/scripts/foundry.mjs:58608 + _createDocuments http://localhost:30000/scripts/foundry.mjs:58570 + create http://localhost:30000/scripts/foundry.mjs:58201 +foundry.mjs:23855:30 +(FAIL) Test Complete: throws error for non-NPC actors +Object { test: {…}, err: TypeError } +quench-reporter.ts:170:14 +QUENCH | TEST RUN COMPLETE +Object { stats: {…} } +quench-reporter.ts:185:14 +VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string 11 foundry.mjs:115132:18 +VagabondActor validation errors: + type: "npc" is not a valid type for the Actor Document class + system: + moraleStatus: + lastTrigger: may not be a blank string + lastResult: may not be a blank string 9 foundry.mjs:115132:18 +VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string 5 foundry.mjs:115132:18 +VagabondItem validation errors: + type: "weapon" is not a valid type for the Item Document class + system: + equippedHand: may not be a blank string foundry.mjs:115132:18 +VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string foundry.mjs:115132:18 +VagabondItem validation errors: + type: "weapon" is not a valid type for the Item Document class + system: + equippedHand: may not be a blank string foundry.mjs:115132:18 +VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string 2 foundry.mjs:115132:18 +VagabondActor validation errors: + type: "npc" is not a valid type for the Actor Document class + system: + moraleStatus: + lastTrigger: may not be a blank string + lastResult: may not be a blank string 4 foundry.mjs:115132:18 +VagabondActor validation errors: + type: "character" is not a valid type for the Actor Document class + system: + combat: + currentZone: may not be a blank string + death: + deathCause: may not be a blank string foundry.mjs:115132:18