Implement spell casting system with rulebook-accurate mana costs
- Add SpellCastDialog with delivery/duration/damage configuration - Fix mana cost calculation to match rulebook formula: - Effect-only or 1d6 damage-only = 0 mana - Both damage AND effect = 1 mana base - +1 per extra damage die beyond first - +delivery cost (Touch/Remote/Imbue=0, Cube=1, Area=2) - Duration has no initial cost (Focus requires maintenance) - Add "Include Effect" toggle for damage vs effect choice - Create spell cast chat card template - Add 20+ i18n strings for spell casting UI - Create comprehensive Quench tests for mana calculation - Add Cast Spell macro for testing - Update CLAUDE.md with NoteDiscovery access instructions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
27a5f481aa
commit
6247004b58
38
CLAUDE.md
38
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`
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
24
lang/en.json
24
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!"
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
647
module/applications/spell-cast-dialog.mjs
Normal file
647
module/applications/spell-cast-dialog.mjs
Normal file
@ -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<VagabondItem>}
|
||||
* @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<ChatMessage>}
|
||||
* @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<SpellCastDialog>}
|
||||
*/
|
||||
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<Object>} 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 };
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
255
module/tests/spell.test.mjs
Normal file
255
module/tests/spell.test.mjs
Normal file
@ -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" }
|
||||
);
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
152
templates/chat/spell-cast.hbs
Normal file
152
templates/chat/spell-cast.hbs
Normal file
@ -0,0 +1,152 @@
|
||||
{{!-- Spell Cast Chat Card Template --}}
|
||||
{{!-- Displays spell casting results with spell info, success/fail, and damage --}}
|
||||
|
||||
<div class="vagabond chat-card spell-cast">
|
||||
{{!-- Header with Spell Info --}}
|
||||
<header class="card-header">
|
||||
<img src="{{spell.img}}" alt="{{spell.name}}" class="spell-icon">
|
||||
<div class="header-text">
|
||||
<h3 class="spell-name">{{spell.name}}</h3>
|
||||
<span class="casting-skill-badge">{{castingSkillLabel}}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{!-- Casting Configuration --}}
|
||||
<div class="cast-config">
|
||||
<div class="config-item delivery">
|
||||
<span class="label">{{localize "VAGABOND.Delivery"}}:</span>
|
||||
<span class="value">{{deliveryLabel}}</span>
|
||||
</div>
|
||||
<div class="config-item duration">
|
||||
<span class="label">{{localize "VAGABOND.Duration"}}:</span>
|
||||
<span class="value">
|
||||
{{durationLabel}}
|
||||
{{#if isFocus}}<i class="fa-solid fa-bullseye" title="{{localize 'VAGABOND.RequiresFocus'}}"></i>{{/if}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-item mana-cost">
|
||||
<span class="label">{{localize "VAGABOND.ManaCost"}}:</span>
|
||||
<span class="value">{{manaCost}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Roll Result --}}
|
||||
<div class="roll-result {{#if isCrit}}critical{{else if isFumble}}fumble{{else if success}}success{{else}}failure{{/if}}">
|
||||
<div class="roll-total">{{total}}</div>
|
||||
<div class="roll-status">
|
||||
{{#if isCrit}}
|
||||
<span class="status critical">{{localize "VAGABOND.CriticalCast"}}</span>
|
||||
{{else if isFumble}}
|
||||
<span class="status fumble">{{localize "VAGABOND.Fumble"}}</span>
|
||||
{{else if success}}
|
||||
<span class="status success">{{localize "VAGABOND.CastSuccess"}}</span>
|
||||
{{else}}
|
||||
<span class="status failure">{{localize "VAGABOND.CastFailed"}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Roll Details --}}
|
||||
<div class="roll-details">
|
||||
<div class="roll-formula">
|
||||
<span class="label">{{localize "VAGABOND.Formula"}}:</span>
|
||||
<span class="value">{{formula}}</span>
|
||||
</div>
|
||||
<div class="roll-breakdown">
|
||||
<span class="d20-result">
|
||||
<i class="fa-solid fa-dice-d20"></i> {{d20Result}}
|
||||
</span>
|
||||
{{#if favorDie}}
|
||||
<span class="favor-die {{#if (gt netFavorHinder 0)}}favor{{else}}hinder{{/if}}">
|
||||
<i class="fa-solid fa-dice-d6"></i> {{favorDie}}
|
||||
</span>
|
||||
{{/if}}
|
||||
{{#if modifier}}
|
||||
<span class="modifier">
|
||||
{{#if (gt modifier 0)}}+{{/if}}{{modifier}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Target Info --}}
|
||||
<div class="target-info">
|
||||
<div class="difficulty">
|
||||
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||
<span class="value">{{this.difficulty}}</span>
|
||||
</div>
|
||||
{{#if (lt this.critThreshold 20)}}
|
||||
<div class="crit-threshold">
|
||||
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
|
||||
<span class="value">{{this.critThreshold}}+</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{!-- Spell Effect (if successful and effect included) --}}
|
||||
{{#if success}}
|
||||
{{#if includeEffect}}
|
||||
{{#if hasEffect}}
|
||||
<div class="spell-effect-section">
|
||||
<div class="effect-header">
|
||||
<i class="fa-solid fa-sparkles"></i>
|
||||
<span>{{localize "VAGABOND.Effect"}}</span>
|
||||
</div>
|
||||
<div class="effect-text">{{{spell.effect}}}</div>
|
||||
{{#if isCrit}}
|
||||
{{#if spell.critEffect}}
|
||||
<div class="crit-effect">
|
||||
<span class="crit-label">{{localize "VAGABOND.CritEffect"}}:</span>
|
||||
<span class="crit-text">{{{spell.critEffect}}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{!-- Damage Section (if hit and dealing damage) --}}
|
||||
{{#if hasDamage}}
|
||||
<div class="damage-section {{#if isCrit}}critical{{/if}}">
|
||||
<div class="damage-header">
|
||||
<i class="fa-solid fa-burst"></i>
|
||||
<span>{{localize "VAGABOND.Damage"}}</span>
|
||||
{{#if isCrit}}
|
||||
<span class="crit-label">({{localize "VAGABOND.Critical"}}!)</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="damage-result">
|
||||
<span class="damage-total">{{damageTotal}}</span>
|
||||
<span class="damage-type">{{spell.damageTypeLabel}}</span>
|
||||
</div>
|
||||
<div class="damage-formula">
|
||||
{{damageFormula}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Focus Indicator --}}
|
||||
{{#if isFocus}}
|
||||
{{#if success}}
|
||||
<div class="focus-indicator">
|
||||
<i class="fa-solid fa-bullseye"></i>
|
||||
<span>{{localize "VAGABOND.NowFocusingSpell"}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{!-- Favor/Hinder Sources --}}
|
||||
{{#if favorSources.length}}
|
||||
<div class="favor-sources">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
<span>{{localize "VAGABOND.Favor"}}: {{#each favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if hinderSources.length}}
|
||||
<div class="hinder-sources">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
<span>{{localize "VAGABOND.Hinder"}}: {{#each hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
208
templates/dialog/spell-cast.hbs
Normal file
208
templates/dialog/spell-cast.hbs
Normal file
@ -0,0 +1,208 @@
|
||||
{{!-- Spell Cast Dialog Template --}}
|
||||
{{!-- Extends roll-dialog-base with spell casting configuration --}}
|
||||
|
||||
<div class="roll-dialog-content spell-cast-dialog">
|
||||
{{!-- Automatic Favor/Hinder from Active Effects --}}
|
||||
{{#if hasAutoFavor}}
|
||||
<div class="auto-favor-hinder favor">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
<span>{{localize "VAGABOND.AutoFavor"}}: {{#each autoFavorHinder.favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if hasAutoHinder}}
|
||||
<div class="auto-favor-hinder hinder">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
<span>{{localize "VAGABOND.AutoHinder"}}: {{#each autoFavorHinder.hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Mana Display --}}
|
||||
<div class="mana-display">
|
||||
<div class="mana-current">
|
||||
<span class="label">{{localize "VAGABOND.Mana"}}:</span>
|
||||
<span class="value {{#unless rollSpecific.canAfford}}insufficient{{/unless}}">
|
||||
{{rollSpecific.currentMana}} / {{rollSpecific.maxMana}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mana-cost">
|
||||
<span class="label">{{localize "VAGABOND.Cost"}}:</span>
|
||||
<span class="value {{#unless rollSpecific.canAfford}}insufficient{{/unless}}">
|
||||
{{rollSpecific.manaCost}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Spell Selection --}}
|
||||
<div class="spell-selection">
|
||||
<label for="spellId">{{localize "VAGABOND.Spell"}}</label>
|
||||
{{#if rollSpecific.hasSpells}}
|
||||
<select name="spellId">
|
||||
{{#each rollSpecific.spells}}
|
||||
<option value="{{this.id}}" {{#if this.selected}}selected{{/if}}>
|
||||
{{this.name}}{{#if this.isDamaging}} ({{this.damageType}}){{/if}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
{{else}}
|
||||
<div class="no-spells-warning">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
{{localize "VAGABOND.NoSpellsKnown"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{!-- Spell Info (shown when spell selected) --}}
|
||||
{{#if rollSpecific.spell}}
|
||||
<div class="spell-info">
|
||||
{{!-- Casting Skill Info --}}
|
||||
<div class="casting-skill">
|
||||
<span class="label">{{localize "VAGABOND.CastingSkill"}}:</span>
|
||||
<span class="value">
|
||||
{{rollSpecific.castingSkillLabel}}
|
||||
({{rollSpecific.statLabel}} {{rollSpecific.statValue}})
|
||||
{{#unless rollSpecific.trained}}<span class="untrained">({{localize "VAGABOND.Untrained"}})</span>{{/unless}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="casting-difficulty">
|
||||
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
|
||||
</div>
|
||||
{{#if (lt rollSpecific.critThreshold 20)}}
|
||||
<div class="casting-crit">
|
||||
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
|
||||
<span class="value crit">{{rollSpecific.critThreshold}}+</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{!-- Effect Description and Toggle --}}
|
||||
{{#if rollSpecific.hasEffect}}
|
||||
<div class="spell-effect">
|
||||
<div class="effect-header">
|
||||
<label class="checkbox-label include-effect-toggle">
|
||||
<input type="checkbox" name="includeEffect" {{#if rollSpecific.includeEffect}}checked{{/if}}>
|
||||
<span>{{localize "VAGABOND.IncludeEffect"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
{{#if rollSpecific.includeEffect}}
|
||||
<div class="effect-text">{{{rollSpecific.effect}}}</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Damage Configuration (only for damaging spells) --}}
|
||||
{{#if rollSpecific.isDamaging}}
|
||||
<div class="damage-config">
|
||||
<label for="damageDice">{{localize "VAGABOND.DamageDice"}}</label>
|
||||
<div class="damage-dice-input">
|
||||
<input type="range"
|
||||
name="damageDice"
|
||||
min="0"
|
||||
max="{{rollSpecific.maxDamageDice}}"
|
||||
value="{{rollSpecific.damageDice}}">
|
||||
<span class="dice-count">{{rollSpecific.damageDice}}{{rollSpecific.damageBase}}</span>
|
||||
</div>
|
||||
{{#if rollSpecific.damageFormula}}
|
||||
<div class="damage-preview">
|
||||
<span class="label">{{localize "VAGABOND.Damage"}}:</span>
|
||||
<span class="value">{{rollSpecific.damageFormula}}</span>
|
||||
<span class="damage-type">({{rollSpecific.damageTypeLabel}})</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Delivery Type Selection --}}
|
||||
<div class="delivery-selection">
|
||||
<label for="delivery">{{localize "VAGABOND.Delivery"}}</label>
|
||||
<select name="delivery">
|
||||
{{#each rollSpecific.deliveryOptions}}
|
||||
<option value="{{this.value}}" {{#if this.selected}}selected{{/if}}>
|
||||
{{this.label}}{{#if this.cost}} (+{{this.cost}} {{localize "VAGABOND.Mana"}}){{/if}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{!-- Duration Type Selection --}}
|
||||
<div class="duration-selection">
|
||||
<label for="duration">{{localize "VAGABOND.Duration"}}</label>
|
||||
<select name="duration">
|
||||
{{#each rollSpecific.durationOptions}}
|
||||
<option value="{{this.value}}" {{#if this.selected}}selected{{/if}}>
|
||||
{{this.label}}{{#if this.isFocus}} <i class="fa-solid fa-bullseye"></i>{{/if}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{!-- Focus Warning --}}
|
||||
{{#if rollSpecific.willRequireFocus}}
|
||||
<div class="focus-warning">
|
||||
<i class="fa-solid fa-bullseye"></i>
|
||||
<span>{{localize "VAGABOND.FocusDurationWarning"}}</span>
|
||||
{{#if rollSpecific.isCurrentlyFocusing}}
|
||||
<div class="current-focus">
|
||||
{{localize "VAGABOND.CurrentlyFocusing"}}:
|
||||
{{#each rollSpecific.focusedSpells}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}
|
||||
</div>
|
||||
{{#unless rollSpecific.canAddFocus}}
|
||||
<div class="focus-limit-warning">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
{{localize "VAGABOND.FocusLimitReachedWarning"}}
|
||||
</div>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{!-- Favor/Hinder Toggles --}}
|
||||
<div class="favor-hinder-section">
|
||||
<label>{{localize "VAGABOND.FavorHinder"}}</label>
|
||||
<div class="favor-hinder-toggles">
|
||||
<button type="button" class="favor-btn {{#if (eq config.favorHinder 1)}}active{{/if}}" data-action="toggle-favor">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
{{localize "VAGABOND.Favor"}}
|
||||
</button>
|
||||
<button type="button" class="hinder-btn {{#if (eq config.favorHinder -1)}}active{{/if}}" data-action="toggle-hinder">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
{{localize "VAGABOND.Hinder"}}
|
||||
</button>
|
||||
</div>
|
||||
{{#if (gt netFavorHinder 0)}}
|
||||
<div class="net-favor-hinder favor">
|
||||
<i class="fa-solid fa-dice-d6"></i> +d6 {{localize "VAGABOND.Favor"}}
|
||||
</div>
|
||||
{{else if (lt netFavorHinder 0)}}
|
||||
<div class="net-favor-hinder hinder">
|
||||
<i class="fa-solid fa-dice-d6"></i> -d6 {{localize "VAGABOND.Hinder"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{!-- Situational Modifier --}}
|
||||
<div class="modifier-section">
|
||||
<label>{{localize "VAGABOND.SituationalModifier"}}</label>
|
||||
<div class="modifier-presets">
|
||||
<button type="button" class="modifier-preset" data-modifier-preset="-5">-5</button>
|
||||
<button type="button" class="modifier-preset" data-modifier-preset="-1">-1</button>
|
||||
<button type="button" class="modifier-preset" data-modifier-preset="1">+1</button>
|
||||
<button type="button" class="modifier-preset" data-modifier-preset="5">+5</button>
|
||||
</div>
|
||||
<div class="modifier-input">
|
||||
<input type="number" name="modifier" value="{{config.modifier}}" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Roll Button --}}
|
||||
<div class="dialog-buttons">
|
||||
<button type="submit" class="roll-btn" {{#unless rollSpecific.spell}}disabled{{/unless}} {{#unless rollSpecific.canAfford}}disabled{{/unless}}>
|
||||
<i class="fa-solid fa-wand-sparkles"></i>
|
||||
{{localize "VAGABOND.CastSpell"}}
|
||||
{{#unless rollSpecific.canAfford}}
|
||||
<span class="insufficient-mana-label">({{localize "VAGABOND.InsufficientManaShort"}})</span>
|
||||
{{/unless}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Reference in New Issue
Block a user