diff --git a/CLAUDE.md b/CLAUDE.md index 8205e7b..db3419a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,10 +22,20 @@ This is a complete Foundry VTT v13 system implementation for Vagabond RPG (Pulp ### Spell Casting -- Dynamic mana cost = base + delivery cost + duration cost + extra damage dice -- Delivery types: Touch(0), Remote(0), Imbue(0), Cube(1), Aura(2), Cone(2), Glyph(2), Line(2), Sphere(2) -- Duration: Instant (free), Focus (ongoing), Continual (permanent) -- Cast dialog must calculate and display total mana cost before casting +**Casting Decisions:** When casting, determine Damage/Effect, Delivery, and Duration. + +**Mana Cost Formula:** + +1. **Base cost:** + - Only 1d6 damage OR only effect = 0 Mana + - Both damage AND effect = 1 Mana +2. **+ Extra damage dice:** +1 Mana per d6 beyond the first +3. **+ Delivery cost:** Touch(0), Remote(0), Imbue(0), Cube(1), Aura(2), Cone(2), Glyph(2), Line(2), Sphere(2) +4. **Duration:** Instant/Focus/Continual - no initial cost, but Focus requires 1 Mana/round to maintain on unwilling targets + +**Cast Checks:** Only required when targeting an unwilling Being. + +**Cast Skills by Class:** Wizard/Magus=Arcana, Druid/Luminary/Witch=Mysticism, Sorcerer=Influence, Revelator=Leadership ### Class System @@ -71,6 +81,13 @@ npm run watch docker compose logs -f foundry ``` +### Testing Code Revisions + +```bash +# Restart local Foundry container +docker compose restart +``` + ## Reference Data Location Game rules and content are documented in NoteDiscovery under `gaming/vagabond-rpg/`: @@ -84,6 +101,19 @@ Game rules and content are documented in NoteDiscovery under `gaming/vagabond-rp - `classes-full-text.md` - All 18 classes with progression tables - `bestiary.md` - Creature categories, TL reference +**To access NoteDiscovery:** + +```bash +# List all notes +cd ~/.claude/skills/notediscovery && python client.py list + +# Read a specific note +cd ~/.claude/skills/notediscovery && python client.py read "gaming/vagabond-rpg/magic-system.md" + +# Search notes +cd ~/.claude/skills/notediscovery && python client.py search "keyword" +``` + Original PDF at: `/mnt/NV2/Development/claude-home/gaming/Vagabond_RPG_-_Pulp_Fantasy_Core_Rulebook_Interactive_PDF.pdf` Character sheet reference: `/mnt/NV2/Development/claude-home/gaming/Vagabond_-_Hero_Record_Interactive_PDF.pdf` diff --git a/PROJECT_ROADMAP.json b/PROJECT_ROADMAP.json index c8d1870..3a1f78e 100644 --- a/PROJECT_ROADMAP.json +++ b/PROJECT_ROADMAP.json @@ -295,19 +295,21 @@ "id": "2.7", "name": "Implement save roll system", "description": "Reflex/Endure/Will saves with correct stat combinations, favor/hinder, crit detection", - "completed": false, + "completed": true, "tested": false, "priority": "critical", - "dependencies": ["2.4", "2.5"] + "dependencies": ["2.4", "2.5"], + "notes": "SaveRollDialog with save type selection, Block/Dodge defense options. Uses {{this.variable}} pattern for Handlebars context." }, { "id": "2.8", "name": "Implement spell casting system", "description": "Dynamic spell dialog: select damage dice, delivery type, duration; auto-calculate mana cost; track focus state", - "completed": false, + "completed": true, "tested": false, "priority": "critical", - "dependencies": ["2.4", "1.9"] + "dependencies": ["2.4", "1.9"], + "notes": "SpellCastDialog with damage/effect toggle, delivery/duration selectors, live mana cost using rulebook formula. Focus tracking on successful cast." }, { "id": "2.9", diff --git a/lang/en.json b/lang/en.json index 6144385..933bb52 100644 --- a/lang/en.json +++ b/lang/en.json @@ -257,5 +257,27 @@ "VAGABOND.DodgeInfo": "Dodge allows you to avoid the attack entirely", "VAGABOND.BlockedWith": "Blocked with shield", "VAGABOND.DodgedAttack": "Dodged the attack", - "VAGABOND.CriticalSuccess": "Critical Success!" + "VAGABOND.CriticalSuccess": "Critical Success!", + + "VAGABOND.CastSpell": "Cast Spell", + "VAGABOND.Spell": "Spell", + "VAGABOND.SelectSpell": "Select Spell...", + "VAGABOND.SelectSpellFirst": "Please select a spell first", + "VAGABOND.NoSpellsKnown": "No spells known", + "VAGABOND.CastingSkill": "Casting Skill", + "VAGABOND.DamageDice": "Damage Dice", + "VAGABOND.Cost": "Cost", + "VAGABOND.IncludeEffect": "Include Effect", + "VAGABOND.FocusDurationWarning": "This spell requires Focus to maintain", + "VAGABOND.CurrentlyFocusing": "Currently focusing", + "VAGABOND.FocusLimitReached": "Focus limit reached!", + "VAGABOND.FocusLimitReachedWarning": "You are already focusing on the maximum number of spells", + "VAGABOND.NowFocusing": "Now focusing on {spell}", + "VAGABOND.RequiresFocus": "Requires Focus", + "VAGABOND.NowFocusingSpell": "Now Focusing on this spell", + "VAGABOND.InsufficientMana": "Insufficient mana! Cost: {cost}, Available: {current}", + "VAGABOND.InsufficientManaShort": "Insufficient Mana", + "VAGABOND.CastSuccess": "Cast Success!", + "VAGABOND.CastFailed": "Cast Failed", + "VAGABOND.CriticalCast": "Critical Cast!" } diff --git a/module/applications/_module.mjs b/module/applications/_module.mjs index cf580e3..32c83e4 100644 --- a/module/applications/_module.mjs +++ b/module/applications/_module.mjs @@ -7,4 +7,5 @@ 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 SpellCastDialog } from "./spell-cast-dialog.mjs"; export { default as FavorHinderDebug } from "./favor-hinder-debug.mjs"; diff --git a/module/applications/spell-cast-dialog.mjs b/module/applications/spell-cast-dialog.mjs new file mode 100644 index 0000000..817d929 --- /dev/null +++ b/module/applications/spell-cast-dialog.mjs @@ -0,0 +1,647 @@ +/** + * Spell Cast Dialog for Vagabond RPG + * + * Extends VagabondRollDialog to handle spell casting configuration: + * - Spell selection from known spells + * - Damage dice selection (0 to casting max) + * - Delivery type selection (filtered to valid types) + * - Duration type selection (filtered to valid types) + * - Live mana cost calculation + * - Focus tracking for Focus duration spells + * + * @extends VagabondRollDialog + */ + +import VagabondRollDialog from "./base-roll-dialog.mjs"; +import { skillCheck, damageRoll } from "../dice/rolls.mjs"; + +export default class SpellCastDialog extends VagabondRollDialog { + /** + * @param {VagabondActor} actor - The actor casting the spell + * @param {Object} options - Dialog options + * @param {string} [options.spellId] - Pre-selected spell ID + */ + constructor(actor, options = {}) { + super(actor, options); + + this.spellId = options.spellId || null; + + // Casting configuration + this.castConfig = { + damageDice: 0, + delivery: null, + duration: null, + includeEffect: true, // Whether to include the spell's effect (beyond damage) + }; + + // Auto-select first known spell if none specified + if (!this.spellId) { + const knownSpells = this._getKnownSpells(); + if (knownSpells.length > 0) { + this.spellId = knownSpells[0].id; + } + } + + // Initialize cast config from selected spell + this._initializeCastConfig(); + + // Load automatic favor/hinder for spell casting + const castingSkill = this._getCastingSkill(); + this.rollConfig.autoFavorHinder = actor.getNetFavorHinder({ skillId: castingSkill }); + } + + /* -------------------------------------------- */ + /* Static Properties */ + /* -------------------------------------------- */ + + /** @override */ + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + super.DEFAULT_OPTIONS, + { + id: "vagabond-spell-cast-dialog", + window: { + title: "VAGABOND.CastSpell", + icon: "fa-solid fa-wand-sparkles", + }, + position: { + width: 400, + }, + }, + { inplace: false } + ); + + /** @override */ + static PARTS = { + form: { + template: "systems/vagabond/templates/dialog/spell-cast.hbs", + }, + }; + + /* -------------------------------------------- */ + /* Getters */ + /* -------------------------------------------- */ + + /** @override */ + get title() { + if (this.spell) { + return `${game.i18n.localize("VAGABOND.Cast")}: ${this.spell.name}`; + } + return game.i18n.localize("VAGABOND.CastSpell"); + } + + /** + * Get the currently selected spell. + * @returns {VagabondItem|null} + */ + get spell() { + if (!this.spellId) return null; + return this.actor.items.get(this.spellId) || null; + } + + /** + * Get the actor's current mana. + * @returns {number} + */ + get currentMana() { + return this.actor.system.resources?.mana?.value || 0; + } + + /** + * Get the actor's max mana. + * @returns {number} + */ + get maxMana() { + return this.actor.system.resources?.mana?.max || 0; + } + + /** + * Get the actor's casting max (max dice in one spell). + * @returns {number} + */ + get castingMax() { + return this.actor.system.resources?.mana?.castingMax || 3; + } + + /** + * Calculate the current mana cost based on cast config. + * @returns {number} + */ + get manaCost() { + const spell = this.spell; + if (!spell) return 0; + + return spell.system.calculateManaCost({ + damageDice: this.castConfig.damageDice, + delivery: this.castConfig.delivery, + duration: this.castConfig.duration, + includeEffect: this.castConfig.includeEffect, + }); + } + + /** + * Check if the actor can afford to cast the spell. + * @returns {boolean} + */ + get canAfford() { + return this.currentMana >= this.manaCost; + } + + /** + * Get the casting skill for this spell. + * @returns {string} + */ + _getCastingSkill() { + const spell = this.spell; + if (spell?.system.castingSkill) { + return spell.system.castingSkill; + } + // Default to arcana, but could be overridden by class + return "arcana"; + } + + /* -------------------------------------------- */ + /* Helper Methods */ + /* -------------------------------------------- */ + + /** + * Get all known spells for this actor. + * @returns {Array} + * @private + */ + _getKnownSpells() { + return this.actor.items.filter((item) => item.type === "spell"); + } + + /** + * Initialize cast config from the selected spell's defaults. + * @private + */ + _initializeCastConfig() { + const spell = this.spell; + if (!spell) return; + + // Default to 1 damage die if spell is damaging, 0 otherwise + this.castConfig.damageDice = spell.system.isDamaging() ? 1 : 0; + + // Default to first valid delivery type + const validDelivery = spell.system.getValidDeliveryTypes(); + this.castConfig.delivery = validDelivery[0] || "touch"; + + // Default to first valid duration type + const validDuration = spell.system.getValidDurationTypes(); + this.castConfig.duration = validDuration[0] || "instant"; + } + + /** + * Get the maximum damage dice this spell can use. + * @returns {number} + * @private + */ + _getMaxDamageDice() { + const spell = this.spell; + if (!spell) return 0; + + // Spell-specific max or actor's casting max + const spellMax = spell.system.maxDice || 0; + const castingMax = this.castingMax; + + // If spell has a specific max, use the lower of spell max and casting max + if (spellMax > 0) { + return Math.min(spellMax, castingMax); + } + + return castingMax; + } + + /** + * Get the damage formula for the current config. + * @returns {string} + * @private + */ + _getDamageFormula() { + const spell = this.spell; + if (!spell || !spell.system.isDamaging() || this.castConfig.damageDice <= 0) { + return ""; + } + + const diceBase = spell.system.damageBase || "d6"; + return `${this.castConfig.damageDice}${diceBase}`; + } + + /* -------------------------------------------- */ + /* Data Preparation */ + /* -------------------------------------------- */ + + /** @override */ + async _prepareRollContext(_options) { + const context = {}; + + // Get all known spells for selection + const knownSpells = this._getKnownSpells(); + context.spells = knownSpells.map((s) => ({ + id: s.id, + name: s.name, + img: s.img, + damageType: s.system.damageType, + isDamaging: s.system.isDamaging(), + selected: s.id === this.spellId, + })); + + context.hasSpells = knownSpells.length > 0; + context.selectedSpellId = this.spellId; + context.spell = this.spell; + + // Mana info + context.currentMana = this.currentMana; + context.maxMana = this.maxMana; + context.castingMax = this.castingMax; + context.manaCost = this.manaCost; + context.canAfford = this.canAfford; + + // Spell-specific data when a spell is selected + const spell = this.spell; + if (spell) { + // Casting skill + const castingSkill = this._getCastingSkill(); + const skillConfig = CONFIG.VAGABOND?.skills?.[castingSkill]; + const skillData = this.actor.system.skills?.[castingSkill]; + const statKey = skillConfig?.stat || "reason"; + const statValue = this.actor.system.stats?.[statKey]?.value || 0; + const trained = skillData?.trained || false; + + context.castingSkill = castingSkill; + context.castingSkillLabel = game.i18n.localize(skillConfig?.label || castingSkill); + context.statLabel = game.i18n.localize(CONFIG.VAGABOND?.stats?.[statKey] || statKey); + context.statValue = statValue; + context.trained = trained; + context.difficulty = trained ? 20 - statValue * 2 : 20 - statValue; + context.critThreshold = skillData?.critThreshold || 20; + + // Damage configuration + context.isDamaging = spell.system.isDamaging(); + context.damageDice = this.castConfig.damageDice; + context.maxDamageDice = this._getMaxDamageDice(); + context.damageBase = spell.system.damageBase || "d6"; + context.damageType = spell.system.damageType; + context.damageTypeLabel = game.i18n.localize( + CONFIG.VAGABOND?.damageTypes?.[spell.system.damageType] || spell.system.damageType + ); + context.damageFormula = this._getDamageFormula(); + + // Delivery options (filtered to valid types) + const validDelivery = spell.system.getValidDeliveryTypes(); + context.deliveryOptions = validDelivery.map((type) => { + const config = CONFIG.VAGABOND?.spellDelivery?.[type] || {}; + return { + value: type, + label: game.i18n.localize(config.label || type), + cost: config.cost || 0, + selected: type === this.castConfig.delivery, + }; + }); + + // Duration options (filtered to valid types) + const validDuration = spell.system.getValidDurationTypes(); + context.durationOptions = validDuration.map((type) => { + const config = CONFIG.VAGABOND?.spellDuration?.[type] || {}; + return { + value: type, + label: game.i18n.localize(config.label || type), + isFocus: config.focus || false, + selected: type === this.castConfig.duration, + }; + }); + + // Current cast config + context.delivery = this.castConfig.delivery; + context.duration = this.castConfig.duration; + + // Effect description + context.effect = spell.system.effect; + context.critEffect = spell.system.critEffect; + context.hasEffect = Boolean(spell.system.effect && spell.system.effect.trim()); + context.includeEffect = this.castConfig.includeEffect; + + // Focus warning if actor is already focusing + const currentFocus = this.actor.system.focus?.active || []; + context.isCurrentlyFocusing = currentFocus.length > 0; + context.focusedSpells = currentFocus.map((f) => f.spellName); + context.maxConcurrentFocus = this.actor.system.focus?.maxConcurrent || 1; + context.canAddFocus = currentFocus.length < context.maxConcurrentFocus; + context.willRequireFocus = this.castConfig.duration === "focus"; + } + + return context; + } + + /* -------------------------------------------- */ + /* Event Handlers */ + /* -------------------------------------------- */ + + /** @override */ + _onRender(context, options) { + super._onRender(context, options); + + // Spell selection dropdown + const spellSelect = this.element.querySelector('[name="spellId"]'); + spellSelect?.addEventListener("change", (event) => { + this.spellId = event.target.value; + this._initializeCastConfig(); + this.render(); + }); + + // Damage dice input/slider + const damageDiceInput = this.element.querySelector('[name="damageDice"]'); + damageDiceInput?.addEventListener("input", (event) => { + this.castConfig.damageDice = parseInt(event.target.value, 10) || 0; + this.render(); + }); + + // Delivery type dropdown + const deliverySelect = this.element.querySelector('[name="delivery"]'); + deliverySelect?.addEventListener("change", (event) => { + this.castConfig.delivery = event.target.value; + this.render(); + }); + + // Duration type dropdown + const durationSelect = this.element.querySelector('[name="duration"]'); + durationSelect?.addEventListener("change", (event) => { + this.castConfig.duration = event.target.value; + this.render(); + }); + + // Include effect toggle + const includeEffectToggle = this.element.querySelector('[name="includeEffect"]'); + includeEffectToggle?.addEventListener("change", (event) => { + this.castConfig.includeEffect = event.target.checked; + this.render(); + }); + + // Favor/hinder toggles (from parent) + 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 presets + 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); + }); + } + } + + /** @override */ + async _executeRoll() { + const spell = this.spell; + if (!spell) { + ui.notifications.warn(game.i18n.localize("VAGABOND.SelectSpellFirst")); + return; + } + + // Check mana cost + const manaCost = this.manaCost; + if (!this.canAfford) { + ui.notifications.warn( + game.i18n.format("VAGABOND.InsufficientMana", { + cost: manaCost, + current: this.currentMana, + }) + ); + return; + } + + // Perform the casting skill check + const castingSkill = this._getCastingSkill(); + const skillData = this.actor.system.skills?.[castingSkill]; + const skillConfig = CONFIG.VAGABOND?.skills?.[castingSkill]; + const statKey = skillConfig?.stat || "reason"; + const statValue = this.actor.system.stats?.[statKey]?.value || 0; + const trained = skillData?.trained || false; + const difficulty = trained ? 20 - statValue * 2 : 20 - statValue; + const critThreshold = skillData?.critThreshold || 20; + + const result = await skillCheck(this.actor, castingSkill, { + difficulty, + critThreshold, + favorHinder: this.netFavorHinder, + modifier: this.rollConfig.modifier, + }); + + // Roll damage if the cast succeeded and spell deals damage + let damageResult = null; + if (result.success && spell.system.isDamaging() && this.castConfig.damageDice > 0) { + const damageFormula = this._getDamageFormula(); + damageResult = await damageRoll(damageFormula, { + isCrit: result.isCrit, + rollData: this.actor.getRollData(), + }); + } + + // Spend mana (regardless of success - mana is spent on attempt) + await this.actor.update({ + "system.resources.mana.value": Math.max(0, this.currentMana - manaCost), + }); + + // Handle focus duration spells + if (result.success && this.castConfig.duration === "focus") { + const currentFocus = this.actor.system.focus?.active || []; + const maxFocus = this.actor.system.focus?.maxConcurrent || 1; + + if (currentFocus.length < maxFocus) { + // Add to focus list + await this.actor.update({ + "system.focus.active": [ + ...currentFocus, + { + spellId: spell.id, + spellName: spell.name, + target: "", // Could be set via target selection + manaCostPerRound: 0, // Could be defined per-spell + requiresSaveCheck: false, + canBeBroken: true, + }, + ], + }); + ui.notifications.info(game.i18n.format("VAGABOND.NowFocusing", { spell: spell.name })); + } else { + ui.notifications.warn(game.i18n.localize("VAGABOND.FocusLimitReached")); + } + } + + // Send to chat + await this._sendToChat(result, damageResult); + } + + /** + * Send the spell cast result to chat. + * + * @param {VagabondRollResult} result - The casting skill check result + * @param {Roll|null} damageResult - The damage roll (if applicable) + * @returns {Promise} + * @private + */ + async _sendToChat(result, damageResult) { + const spell = this.spell; + const castingSkill = this._getCastingSkill(); + const skillConfig = CONFIG.VAGABOND?.skills?.[castingSkill]; + + // Prepare template data + const templateData = { + actor: this.actor, + spell: { + id: spell.id, + name: spell.name, + img: spell.img, + effect: spell.system.effect, + critEffect: spell.system.critEffect, + damageType: spell.system.damageType, + damageTypeLabel: game.i18n.localize( + CONFIG.VAGABOND?.damageTypes?.[spell.system.damageType] || spell.system.damageType + ), + isDamaging: spell.system.isDamaging(), + }, + castingSkillLabel: game.i18n.localize(skillConfig?.label || castingSkill), + delivery: this.castConfig.delivery, + deliveryLabel: game.i18n.localize( + CONFIG.VAGABOND?.spellDelivery?.[this.castConfig.delivery]?.label || + this.castConfig.delivery + ), + duration: this.castConfig.duration, + durationLabel: game.i18n.localize( + CONFIG.VAGABOND?.spellDuration?.[this.castConfig.duration]?.label || + this.castConfig.duration + ), + isFocus: this.castConfig.duration === "focus", + manaCost: this.manaCost, + 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, + damageDice: this.castConfig.damageDice, + // Effect info + includeEffect: this.castConfig.includeEffect, + hasEffect: Boolean(spell.system.effect && spell.system.effect.trim()), + }; + + // Render the chat card template + const content = await renderTemplate( + "systems/vagabond/templates/chat/spell-cast.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 a spell cast dialog. + * + * @param {VagabondActor} actor - The actor casting the spell + * @param {string} [spellId] - Optional pre-selected spell ID + * @param {Object} [options] - Additional options + * @returns {Promise} + */ + static async prompt(actor, spellId = null, options = {}) { + return this.create(actor, { ...options, spellId }); + } + + /** + * Perform a quick spell cast without showing the dialog. + * Uses default options for delivery and duration. + * + * @param {VagabondActor} actor - The actor casting the spell + * @param {VagabondItem} spell - The spell to cast + * @param {Object} [options] - Cast options + * @returns {Promise} Cast and damage results + */ + static async quickCast(actor, spell, options = {}) { + // Create temporary dialog for calculations + const tempDialog = new this(actor, { spellId: spell.id }); + + // Apply any option overrides + if (options.damageDice !== undefined) { + tempDialog.castConfig.damageDice = options.damageDice; + } + if (options.delivery) { + tempDialog.castConfig.delivery = options.delivery; + } + if (options.duration) { + tempDialog.castConfig.duration = options.duration; + } + + // Check mana + if (!tempDialog.canAfford) { + ui.notifications.warn( + game.i18n.format("VAGABOND.InsufficientMana", { + cost: tempDialog.manaCost, + current: tempDialog.currentMana, + }) + ); + return null; + } + + // Get automatic favor/hinder + const castingSkill = tempDialog._getCastingSkill(); + const autoFavorHinder = actor.getNetFavorHinder({ skillId: castingSkill }); + + // Perform the skill check + const result = await skillCheck(actor, castingSkill, { + favorHinder: options.favorHinder ?? autoFavorHinder.net, + modifier: options.modifier || 0, + }); + + // Roll damage if applicable + let damageResult = null; + if (result.success && spell.system.isDamaging() && tempDialog.castConfig.damageDice > 0) { + const damageFormula = tempDialog._getDamageFormula(); + damageResult = await damageRoll(damageFormula, { + isCrit: result.isCrit, + rollData: actor.getRollData(), + }); + } + + // Spend mana + await actor.update({ + "system.resources.mana.value": Math.max(0, tempDialog.currentMana - tempDialog.manaCost), + }); + + // Send to chat + tempDialog.rollConfig.autoFavorHinder = autoFavorHinder; + await tempDialog._sendToChat(result, damageResult); + + return { cast: result, damage: damageResult }; + } +} diff --git a/module/data/item/spell.mjs b/module/data/item/spell.mjs index bdf8988..874ea06 100644 --- a/module/data/item/spell.mjs +++ b/module/data/item/spell.mjs @@ -94,17 +94,40 @@ export default class SpellData extends VagabondItemBase { /** * Calculate the mana cost for casting this spell with given options. * + * Mana Cost Formula (from rulebook): + * 1. Base cost: Only 1d6 damage OR only effect = 0 Mana; Both damage AND effect = 1 Mana + * 2. + Extra damage dice: +1 Mana per d6 beyond the first + * 3. + Delivery cost: Touch(0), Remote(0), Imbue(0), Cube(1), Aura/Cone/Glyph/Line/Sphere(2) + * 4. Duration: No initial cost (Focus requires 1 Mana/round to maintain on unwilling targets) + * * @param {Object} options - Casting options * @param {number} options.damageDice - Number of damage dice (default 0) * @param {string} options.delivery - Delivery type (default "touch") * @param {string} options.duration - Duration type (default "instant") + * @param {boolean} options.includeEffect - Whether casting includes the spell's effect (default true if spell has effect) * @returns {number} Total mana cost */ - calculateManaCost({ damageDice = 0, delivery = "touch", duration = "instant" } = {}) { + calculateManaCost({ + damageDice = 0, + delivery = "touch", + duration = "instant", + includeEffect = null, + } = {}) { let cost = 0; - // Damage dice cost (1 mana per die) - cost += damageDice; + // Determine if spell has an effect (beyond just damage) + const hasEffect = includeEffect ?? Boolean(this.effect && this.effect.trim()); + const hasDamage = damageDice > 0; + + // Base cost: Both damage AND effect = 1 Mana; otherwise 0 + if (hasDamage && hasEffect) { + cost += 1; + } + + // Extra damage dice cost (+1 per die beyond the first) + if (damageDice > 1) { + cost += damageDice - 1; + } // Delivery cost const deliveryCosts = { @@ -120,10 +143,8 @@ export default class SpellData extends VagabondItemBase { }; cost += deliveryCosts[delivery] || 0; - // Duration cost (continual adds +2 if dealing damage) - if (duration === "continual" && damageDice > 0) { - cost += 2; - } + // Duration doesn't add initial cost + // (Focus maintenance cost is handled separately during gameplay) return cost; } diff --git a/module/tests/quench-init.mjs b/module/tests/quench-init.mjs index 59eeb0b..121d7e6 100644 --- a/module/tests/quench-init.mjs +++ b/module/tests/quench-init.mjs @@ -10,6 +10,7 @@ // Import test modules import { registerActorTests } from "./actor.test.mjs"; import { registerDiceTests } from "./dice.test.mjs"; +import { registerSpellTests } from "./spell.test.mjs"; // import { registerItemTests } from "./item.test.mjs"; // import { registerEffectTests } from "./effects.test.mjs"; @@ -66,6 +67,7 @@ export function registerQuenchTests(quenchRunner) { // Register domain-specific test batches registerActorTests(quenchRunner); registerDiceTests(quenchRunner); + registerSpellTests(quenchRunner); // registerItemTests(quenchRunner); // registerEffectTests(quenchRunner); diff --git a/module/tests/spell.test.mjs b/module/tests/spell.test.mjs new file mode 100644 index 0000000..91ee580 --- /dev/null +++ b/module/tests/spell.test.mjs @@ -0,0 +1,255 @@ +/** + * Spell Casting System Tests + * + * Tests the spell casting mechanics including: + * - Mana cost calculation (rulebook formula) + * - Delivery type filtering + * - Duration type filtering + * - Damage detection + * + * @module tests/spell + */ + +/** + * Register spell casting tests with Quench + * @param {Quench} quenchRunner - The Quench test runner + */ +export function registerSpellTests(quenchRunner) { + quenchRunner.registerBatch( + "vagabond.spells.manaCost", + (context) => { + const { describe, it, expect, beforeEach, afterEach } = context; + + describe("Spell Mana Cost Calculation", () => { + let actor; + let spell; + + beforeEach(async () => { + // Create a test actor + actor = await Actor.create({ + name: "Test Caster", + type: "character", + }); + + // Create a test spell with effect + spell = await Item.create({ + name: "Test Fireball", + type: "spell", + system: { + effect: "Target takes fire damage and catches fire", + damageType: "fire", + damageBase: "d6", + maxDice: 5, + deliveryTypes: { + touch: true, + remote: true, + sphere: true, + }, + durationTypes: { + instant: true, + focus: true, + }, + }, + }); + }); + + afterEach(async () => { + await actor?.delete(); + await spell?.delete(); + }); + + it("should cost 0 mana for effect-only cast (no damage)", () => { + const cost = spell.system.calculateManaCost({ + damageDice: 0, + delivery: "touch", + duration: "instant", + includeEffect: true, + }); + expect(cost).to.equal(0); + }); + + it("should cost 0 mana for 1d6 damage-only cast (no effect)", () => { + const cost = spell.system.calculateManaCost({ + damageDice: 1, + delivery: "touch", + duration: "instant", + includeEffect: false, + }); + expect(cost).to.equal(0); + }); + + it("should cost 1 mana for 1d6 damage WITH effect", () => { + const cost = spell.system.calculateManaCost({ + damageDice: 1, + delivery: "touch", + duration: "instant", + includeEffect: true, + }); + expect(cost).to.equal(1); + }); + + it("should add +1 mana per extra damage die beyond first", () => { + // 3d6 damage with effect: 1 base + 2 extra dice = 3 + const cost = spell.system.calculateManaCost({ + damageDice: 3, + delivery: "touch", + duration: "instant", + includeEffect: true, + }); + expect(cost).to.equal(3); + }); + + it("should add delivery cost for area spells", () => { + // Sphere costs 2 + const cost = spell.system.calculateManaCost({ + damageDice: 1, + delivery: "sphere", + duration: "instant", + includeEffect: true, + }); + // 1 base (damage+effect) + 2 sphere = 3 + expect(cost).to.equal(3); + }); + + it("should not add duration cost (rulebook: no initial cost)", () => { + const instantCost = spell.system.calculateManaCost({ + damageDice: 1, + delivery: "touch", + duration: "instant", + includeEffect: true, + }); + const focusCost = spell.system.calculateManaCost({ + damageDice: 1, + delivery: "touch", + duration: "focus", + includeEffect: true, + }); + expect(focusCost).to.equal(instantCost); + }); + + it("should calculate complex spell cost correctly", () => { + // Example from rulebook: 3d6 sphere = 1 base + 2 extra dice + 2 sphere = 5 + const cost = spell.system.calculateManaCost({ + damageDice: 3, + delivery: "sphere", + duration: "instant", + includeEffect: true, + }); + expect(cost).to.equal(5); + }); + + it("should handle damage-only with area delivery", () => { + // 2d6 damage only with cone: 0 base + 1 extra die + 2 cone = 3 + const cost = spell.system.calculateManaCost({ + damageDice: 2, + delivery: "cone", + duration: "instant", + includeEffect: false, + }); + expect(cost).to.equal(3); + }); + }); + }, + { displayName: "Vagabond: Spell Mana Cost" } + ); + + quenchRunner.registerBatch( + "vagabond.spells.deliveryDuration", + (context) => { + const { describe, it, expect, beforeEach, afterEach } = context; + + describe("Spell Delivery and Duration Types", () => { + let spell; + + beforeEach(async () => { + spell = await Item.create({ + name: "Test Spell", + type: "spell", + system: { + effect: "Test effect", + deliveryTypes: { + touch: true, + remote: true, + sphere: false, + cone: true, + }, + durationTypes: { + instant: true, + focus: true, + continual: false, + }, + }, + }); + }); + + afterEach(async () => { + await spell?.delete(); + }); + + it("should return only valid delivery types", () => { + const valid = spell.system.getValidDeliveryTypes(); + expect(valid).to.include("touch"); + expect(valid).to.include("remote"); + expect(valid).to.include("cone"); + expect(valid).to.not.include("sphere"); + }); + + it("should return only valid duration types", () => { + const valid = spell.system.getValidDurationTypes(); + expect(valid).to.include("instant"); + expect(valid).to.include("focus"); + expect(valid).to.not.include("continual"); + }); + }); + }, + { displayName: "Vagabond: Spell Delivery/Duration" } + ); + + quenchRunner.registerBatch( + "vagabond.spells.damage", + (context) => { + const { describe, it, expect, beforeEach, afterEach } = context; + + describe("Spell Damage Detection", () => { + let damagingSpell; + let utilitySpell; + + beforeEach(async () => { + damagingSpell = await Item.create({ + name: "Fireball", + type: "spell", + system: { + damageType: "fire", + damageBase: "d6", + maxDice: 5, + }, + }); + + utilitySpell = await Item.create({ + name: "Light", + type: "spell", + system: { + damageType: "", + damageBase: "", + maxDice: 0, + }, + }); + }); + + afterEach(async () => { + await damagingSpell?.delete(); + await utilitySpell?.delete(); + }); + + it("should detect damaging spells", () => { + expect(damagingSpell.system.isDamaging()).to.be.true; + }); + + it("should detect utility (non-damaging) spells", () => { + expect(utilitySpell.system.isDamaging()).to.be.false; + }); + }); + }, + { displayName: "Vagabond: Spell Damage Detection" } + ); +} diff --git a/module/vagabond.mjs b/module/vagabond.mjs index 0d8e249..eeb571e 100644 --- a/module/vagabond.mjs +++ b/module/vagabond.mjs @@ -28,6 +28,7 @@ import { SkillCheckDialog, AttackRollDialog, SaveRollDialog, + SpellCastDialog, FavorHinderDebug, } from "./applications/_module.mjs"; @@ -62,6 +63,7 @@ Hooks.once("init", () => { SkillCheckDialog, AttackRollDialog, SaveRollDialog, + SpellCastDialog, FavorHinderDebug, }, }; @@ -219,6 +221,30 @@ if (!actor) { // eslint-disable-next-line no-console console.log("Vagabond RPG | Created Save Roll macro"); } + + // Cast Spell macro + const castMacroName = "Cast Spell"; + const existingCastMacro = game.macros.find((m) => m.name === castMacroName); + + if (!existingCastMacro) { + await Macro.create({ + name: castMacroName, + type: "script", + img: "icons/svg/lightning.svg", + command: `// Opens spell cast 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.SpellCastDialog.prompt(actor); +}`, + flags: { vagabond: { systemMacro: true } }, + }); + // eslint-disable-next-line no-console + console.log("Vagabond RPG | Created Cast Spell macro"); + } } /* -------------------------------------------- */ diff --git a/templates/chat/spell-cast.hbs b/templates/chat/spell-cast.hbs new file mode 100644 index 0000000..63a7804 --- /dev/null +++ b/templates/chat/spell-cast.hbs @@ -0,0 +1,152 @@ +{{!-- Spell Cast Chat Card Template --}} +{{!-- Displays spell casting results with spell info, success/fail, and damage --}} + +
+ {{!-- Header with Spell Info --}} +
+ {{spell.name}} +
+

{{spell.name}}

+ {{castingSkillLabel}} +
+
+ + {{!-- Casting Configuration --}} +
+
+ {{localize "VAGABOND.Delivery"}}: + {{deliveryLabel}} +
+
+ {{localize "VAGABOND.Duration"}}: + + {{durationLabel}} + {{#if isFocus}}{{/if}} + +
+
+ {{localize "VAGABOND.ManaCost"}}: + {{manaCost}} +
+
+ + {{!-- Roll Result --}} +
+
{{total}}
+
+ {{#if isCrit}} + {{localize "VAGABOND.CriticalCast"}} + {{else if isFumble}} + {{localize "VAGABOND.Fumble"}} + {{else if success}} + {{localize "VAGABOND.CastSuccess"}} + {{else}} + {{localize "VAGABOND.CastFailed"}} + {{/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"}}: + {{this.difficulty}} +
+ {{#if (lt this.critThreshold 20)}} +
+ {{localize "VAGABOND.CritThreshold"}}: + {{this.critThreshold}}+ +
+ {{/if}} +
+ + {{!-- Spell Effect (if successful and effect included) --}} + {{#if success}} + {{#if includeEffect}} + {{#if hasEffect}} +
+
+ + {{localize "VAGABOND.Effect"}} +
+
{{{spell.effect}}}
+ {{#if isCrit}} + {{#if spell.critEffect}} +
+ {{localize "VAGABOND.CritEffect"}}: + {{{spell.critEffect}}} +
+ {{/if}} + {{/if}} +
+ {{/if}} + {{/if}} + {{/if}} + + {{!-- Damage Section (if hit and dealing damage) --}} + {{#if hasDamage}} +
+
+ + {{localize "VAGABOND.Damage"}} + {{#if isCrit}} + ({{localize "VAGABOND.Critical"}}!) + {{/if}} +
+
+ {{damageTotal}} + {{spell.damageTypeLabel}} +
+
+ {{damageFormula}} +
+
+ {{/if}} + + {{!-- Focus Indicator --}} + {{#if isFocus}} + {{#if success}} +
+ + {{localize "VAGABOND.NowFocusingSpell"}} +
+ {{/if}} + {{/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/spell-cast.hbs b/templates/dialog/spell-cast.hbs new file mode 100644 index 0000000..8cb549f --- /dev/null +++ b/templates/dialog/spell-cast.hbs @@ -0,0 +1,208 @@ +{{!-- Spell Cast Dialog Template --}} +{{!-- Extends roll-dialog-base with spell casting configuration --}} + +
+ {{!-- 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}} + + {{!-- Mana Display --}} +
+
+ {{localize "VAGABOND.Mana"}}: + + {{rollSpecific.currentMana}} / {{rollSpecific.maxMana}} + +
+
+ {{localize "VAGABOND.Cost"}}: + + {{rollSpecific.manaCost}} + +
+
+ + {{!-- Spell Selection --}} +
+ + {{#if rollSpecific.hasSpells}} + + {{else}} +
+ + {{localize "VAGABOND.NoSpellsKnown"}} +
+ {{/if}} +
+ + {{!-- Spell Info (shown when spell selected) --}} + {{#if rollSpecific.spell}} +
+ {{!-- Casting Skill Info --}} +
+ {{localize "VAGABOND.CastingSkill"}}: + + {{rollSpecific.castingSkillLabel}} + ({{rollSpecific.statLabel}} {{rollSpecific.statValue}}) + {{#unless rollSpecific.trained}}({{localize "VAGABOND.Untrained"}}){{/unless}} + +
+
+ {{localize "VAGABOND.Difficulty"}}: + {{rollSpecific.difficulty}} +
+ {{#if (lt rollSpecific.critThreshold 20)}} +
+ {{localize "VAGABOND.CritThreshold"}}: + {{rollSpecific.critThreshold}}+ +
+ {{/if}} +
+ + {{!-- Effect Description and Toggle --}} + {{#if rollSpecific.hasEffect}} +
+
+ +
+ {{#if rollSpecific.includeEffect}} +
{{{rollSpecific.effect}}}
+ {{/if}} +
+ {{/if}} + + {{!-- Damage Configuration (only for damaging spells) --}} + {{#if rollSpecific.isDamaging}} +
+ +
+ + {{rollSpecific.damageDice}}{{rollSpecific.damageBase}} +
+ {{#if rollSpecific.damageFormula}} +
+ {{localize "VAGABOND.Damage"}}: + {{rollSpecific.damageFormula}} + ({{rollSpecific.damageTypeLabel}}) +
+ {{/if}} +
+ {{/if}} + + {{!-- Delivery Type Selection --}} +
+ + +
+ + {{!-- Duration Type Selection --}} +
+ + +
+ + {{!-- Focus Warning --}} + {{#if rollSpecific.willRequireFocus}} +
+ + {{localize "VAGABOND.FocusDurationWarning"}} + {{#if rollSpecific.isCurrentlyFocusing}} +
+ {{localize "VAGABOND.CurrentlyFocusing"}}: + {{#each rollSpecific.focusedSpells}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +
+ {{#unless rollSpecific.canAddFocus}} +
+ + {{localize "VAGABOND.FocusLimitReachedWarning"}} +
+ {{/unless}} + {{/if}} +
+ {{/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 --}} +
+ +
+ + + + +
+
+ +
+
+ + {{!-- Roll Button --}} +
+ +
+