@carmaclouds/core 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cache/CacheManager.d.ts.map +1 -0
- package/dist/cache/CacheManager.js +131 -0
- package/dist/cache/CacheManager.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/ir/index.d.ts +11 -0
- package/dist/ir/index.d.ts.map +1 -0
- package/dist/ir/index.js +9 -0
- package/dist/ir/index.js.map +1 -0
- package/dist/ir/normalize.d.ts +10 -0
- package/dist/ir/normalize.d.ts.map +1 -0
- package/dist/ir/normalize.js +207 -0
- package/dist/ir/normalize.js.map +1 -0
- package/dist/ir/persistence.d.ts +26 -0
- package/dist/ir/persistence.d.ts.map +1 -0
- package/dist/ir/persistence.js +21 -0
- package/dist/ir/persistence.js.map +1 -0
- package/dist/ir/sync.d.ts +12 -0
- package/dist/ir/sync.d.ts.map +1 -0
- package/dist/ir/sync.js +36 -0
- package/dist/ir/sync.js.map +1 -0
- package/dist/ir/types.d.ts +143 -0
- package/dist/ir/types.d.ts.map +1 -0
- package/dist/ir/types.js +13 -0
- package/dist/ir/types.js.map +1 -0
- package/dist/ir/views/dnd5e.d.ts +40 -0
- package/dist/ir/views/dnd5e.d.ts.map +1 -0
- package/dist/ir/views/dnd5e.js +50 -0
- package/dist/ir/views/dnd5e.js.map +1 -0
- package/dist/render/character.d.ts +19 -0
- package/dist/render/character.d.ts.map +1 -0
- package/dist/render/character.js +156 -0
- package/dist/render/character.js.map +1 -0
- package/dist/render/h.d.ts +27 -0
- package/dist/render/h.d.ts.map +1 -0
- package/dist/render/h.js +64 -0
- package/dist/render/h.js.map +1 -0
- package/dist/render/index.d.ts +11 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +8 -0
- package/dist/render/index.js.map +1 -0
- package/dist/render/mount.d.ts +31 -0
- package/dist/render/mount.d.ts.map +1 -0
- package/dist/render/mount.js +63 -0
- package/dist/render/mount.js.map +1 -0
- package/dist/supabase/fields.d.ts.map +1 -0
- package/dist/supabase/fields.js +120 -0
- package/dist/supabase/fields.js.map +1 -0
- package/dist/types/character.d.ts.map +1 -0
- package/dist/types/character.js +5 -0
- package/dist/types/character.js.map +1 -0
- package/package.json +73 -0
- package/src/browser.js +51 -0
- package/src/cache/CacheManager.ts +174 -0
- package/src/common/browser-polyfill.js +319 -0
- package/src/common/debug.js +123 -0
- package/src/common/html-utils.js +134 -0
- package/src/common/theme-manager.js +265 -0
- package/src/index.ts +25 -0
- package/src/ir/__fixtures__/dnd5e-character.json +75962 -0
- package/src/ir/__fixtures__/non-dnd-character.json +14218 -0
- package/src/ir/index.ts +10 -0
- package/src/ir/normalize.ts +245 -0
- package/src/ir/persistence.ts +37 -0
- package/src/ir/sync.ts +49 -0
- package/src/ir/types.ts +161 -0
- package/src/ir/views/dnd5e.ts +94 -0
- package/src/lib/indexeddb-cache.js +320 -0
- package/src/modules/action-announcements.js +102 -0
- package/src/modules/action-display.js +1557 -0
- package/src/modules/action-executor.js +860 -0
- package/src/modules/action-filters.js +167 -0
- package/src/modules/action-options.js +117 -0
- package/src/modules/card-creator.js +142 -0
- package/src/modules/character-portrait.js +169 -0
- package/src/modules/character-trait-popups.js +959 -0
- package/src/modules/character-traits.js +814 -0
- package/src/modules/class-feature-edge-cases.js +1320 -0
- package/src/modules/color-utils.js +69 -0
- package/src/modules/combat-maneuver-edge-cases.js +660 -0
- package/src/modules/companions-manager.js +178 -0
- package/src/modules/concentration-tracker.js +178 -0
- package/src/modules/data-manager.js +514 -0
- package/src/modules/dice-roller.js +719 -0
- package/src/modules/effects-manager.js +743 -0
- package/src/modules/feature-modals.js +1264 -0
- package/src/modules/formula-resolver.js +444 -0
- package/src/modules/gm-mode.js +184 -0
- package/src/modules/health-modals.js +399 -0
- package/src/modules/hp-management.js +752 -0
- package/src/modules/inventory-manager.js +242 -0
- package/src/modules/macro-system.js +825 -0
- package/src/modules/notification-system.js +92 -0
- package/src/modules/racial-feature-edge-cases.js +746 -0
- package/src/modules/resource-manager.js +775 -0
- package/src/modules/sheet-builder.js +654 -0
- package/src/modules/spell-action-modals.js +583 -0
- package/src/modules/spell-cards.js +602 -0
- package/src/modules/spell-casting.js +723 -0
- package/src/modules/spell-display.js +314 -0
- package/src/modules/spell-edge-cases.js +509 -0
- package/src/modules/spell-macros.js +201 -0
- package/src/modules/spell-modals.js +1221 -0
- package/src/modules/spell-slots.js +224 -0
- package/src/modules/status-bar-bridge.js +101 -0
- package/src/modules/ui-utilities.js +284 -0
- package/src/modules/warlock-invocations.js +219 -0
- package/src/modules/window-management.js +211 -0
- package/src/render/character.ts +234 -0
- package/src/render/h.ts +74 -0
- package/src/render/index.ts +10 -0
- package/src/render/mount.ts +94 -0
- package/src/supabase/client.js +1383 -0
- package/src/supabase/config.js +60 -0
- package/src/supabase/fields.ts +129 -0
- package/src/types/character.ts +85 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spell Casting Module
|
|
3
|
+
*
|
|
4
|
+
* Handles all spell casting logic including:
|
|
5
|
+
* - Main casting function with slot management
|
|
6
|
+
* - Resource usage (spell slots, class resources, Pact Magic)
|
|
7
|
+
* - Concentration tracking
|
|
8
|
+
* - Metamagic cost calculation
|
|
9
|
+
* - Spell announcements to chat
|
|
10
|
+
* - Spell recovery mechanics
|
|
11
|
+
*
|
|
12
|
+
* Loaded as a plain script (no ES6 modules) to export to globalThis.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
(function() {
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
// ===== CORE CASTING FUNCTIONS =====
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Main spell casting function
|
|
22
|
+
* Handles slot consumption, resource management, and executes afterCast callback
|
|
23
|
+
*
|
|
24
|
+
* @param {object} spell - Spell object
|
|
25
|
+
* @param {number} index - Spell index
|
|
26
|
+
* @param {function} afterCast - Callback to execute after casting (receives spell and slot)
|
|
27
|
+
* @param {number|string|null} selectedSlotLevel - Level to cast at (or "pact:X" for Pact Magic)
|
|
28
|
+
* @param {Array} selectedMetamagic - Array of metamagic options
|
|
29
|
+
* @param {boolean} skipSlotConsumption - Whether to skip consuming a slot (concentration recast)
|
|
30
|
+
* @param {boolean} skipAnnouncement - Whether to skip announcing the cast
|
|
31
|
+
*/
|
|
32
|
+
function castSpell(spell, index, afterCast = null, selectedSlotLevel = null, selectedMetamagic = [], skipSlotConsumption = false, skipAnnouncement = false) {
|
|
33
|
+
const debug = window.debug || console;
|
|
34
|
+
debug.log('✨ Attempting to cast:', spell.name, spell, 'at level:', selectedSlotLevel, 'with metamagic:', selectedMetamagic, 'skipSlot:', skipSlotConsumption, 'skipAnnouncement:', skipAnnouncement);
|
|
35
|
+
|
|
36
|
+
if (!characterData) {
|
|
37
|
+
if (typeof showNotification === 'function') {
|
|
38
|
+
showNotification('❌ Character data not available', 'error');
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check if spell is from a magic item (doesn't consume spell slots)
|
|
44
|
+
const isMagicItemSpell = spell.source && (
|
|
45
|
+
spell.source.toLowerCase().includes('amulet') ||
|
|
46
|
+
spell.source.toLowerCase().includes('ring') ||
|
|
47
|
+
spell.source.toLowerCase().includes('wand') ||
|
|
48
|
+
spell.source.toLowerCase().includes('staff') ||
|
|
49
|
+
spell.source.toLowerCase().includes('rod') ||
|
|
50
|
+
spell.source.toLowerCase().includes('cloak') ||
|
|
51
|
+
spell.source.toLowerCase().includes('boots') ||
|
|
52
|
+
spell.source.toLowerCase().includes('bracers') ||
|
|
53
|
+
spell.source.toLowerCase().includes('gauntlets') ||
|
|
54
|
+
spell.source.toLowerCase().includes('helm') ||
|
|
55
|
+
spell.source.toLowerCase().includes('armor') ||
|
|
56
|
+
spell.source.toLowerCase().includes('weapon') ||
|
|
57
|
+
spell.source.toLowerCase().includes('talisman') ||
|
|
58
|
+
spell.source.toLowerCase().includes('orb') ||
|
|
59
|
+
spell.source.toLowerCase().includes('scroll') ||
|
|
60
|
+
spell.source.toLowerCase().includes('potion')
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Check if spell doesn't require spell slots (DiceCloud toggle or resource consumption)
|
|
64
|
+
const isFreeSpell = (spell.castWithoutSpellSlots === true) || (
|
|
65
|
+
spell.resources &&
|
|
66
|
+
spell.resources.itemsConsumed &&
|
|
67
|
+
spell.resources.itemsConsumed.length > 0
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Cantrips (level 0), magic item spells, free spells, or concentration recast don't need slots
|
|
71
|
+
if (!spell.level || spell.level === 0 || spell.level === '0' || isMagicItemSpell || isFreeSpell || skipSlotConsumption) {
|
|
72
|
+
const reason = skipSlotConsumption ? 'concentration recast' : (isMagicItemSpell ? 'magic item' : (isFreeSpell ? 'free spell' : 'cantrip'));
|
|
73
|
+
debug.log(`✨ Casting ${reason} (no spell slot needed)`);
|
|
74
|
+
|
|
75
|
+
// Mark action economy as used based on casting time
|
|
76
|
+
if (!skipSlotConsumption && typeof window.markActionEconomyUsed === 'function') {
|
|
77
|
+
const castingTime = (spell.castingTime || '').toLowerCase();
|
|
78
|
+
if (castingTime.includes('bonus')) {
|
|
79
|
+
window.markActionEconomyUsed('bonus');
|
|
80
|
+
} else if (castingTime.includes('reaction')) {
|
|
81
|
+
window.markActionEconomyUsed('reaction');
|
|
82
|
+
} else {
|
|
83
|
+
window.markActionEconomyUsed('action');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!skipAnnouncement && typeof announceSpellCast === 'function') {
|
|
88
|
+
announceSpellCast(spell, skipSlotConsumption ? 'concentration recast (no slot)' : ((isMagicItemSpell || isFreeSpell) ? `${spell.source} (no slot)` : null));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (typeof showNotification === 'function') {
|
|
92
|
+
showNotification(`✨ ${skipSlotConsumption ? 'Using' : 'Cast'} ${spell.name}!`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Handle concentration
|
|
96
|
+
if (spell.concentration && !skipSlotConsumption && typeof setConcentration === 'function') {
|
|
97
|
+
setConcentration(spell.name);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Track reuseable spells
|
|
101
|
+
const shouldTrackAsReusable = typeof isReuseableSpell === 'function' && isReuseableSpell(spell.name, characterData);
|
|
102
|
+
if (shouldTrackAsReusable && !skipSlotConsumption) {
|
|
103
|
+
const castSpellsKey = `castSpells_${characterData.name}`;
|
|
104
|
+
const castSpells = JSON.parse(localStorage.getItem(castSpellsKey) || '[]');
|
|
105
|
+
if (!castSpells.includes(spell.name)) {
|
|
106
|
+
castSpells.push(spell.name);
|
|
107
|
+
localStorage.setItem(castSpellsKey, JSON.stringify(castSpells));
|
|
108
|
+
debug.log(`✅ Tracked reuseable spell: ${spell.name}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Execute afterCast with a fake slot for magic items and free spells
|
|
113
|
+
if (afterCast && typeof afterCast === 'function') {
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
const fakeSlotLevel = skipSlotConsumption && selectedSlotLevel ? selectedSlotLevel : spell.level;
|
|
116
|
+
const fakeSlot = ((isMagicItemSpell || isFreeSpell || skipSlotConsumption) && fakeSlotLevel) ? { level: parseInt(fakeSlotLevel) } : null;
|
|
117
|
+
afterCast(spell, fakeSlot);
|
|
118
|
+
}, 300);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const spellLevel = parseInt(spell.level);
|
|
124
|
+
|
|
125
|
+
// If slot level was selected in modal, use it directly
|
|
126
|
+
if (selectedSlotLevel !== null) {
|
|
127
|
+
const slotsObject = characterData.spellSlots || characterData;
|
|
128
|
+
|
|
129
|
+
// Check if this is a Pact Magic slot (format: "pact:${level}")
|
|
130
|
+
const isPactMagicSlot = typeof selectedSlotLevel === 'string' && selectedSlotLevel.startsWith('pact:');
|
|
131
|
+
let actualLevel, slotVar, currentSlots, slotLabel;
|
|
132
|
+
|
|
133
|
+
if (isPactMagicSlot) {
|
|
134
|
+
// Parse pact magic slot level
|
|
135
|
+
actualLevel = parseInt(selectedSlotLevel.split(':')[1]);
|
|
136
|
+
slotVar = 'pactMagicSlots';
|
|
137
|
+
currentSlots = slotsObject.pactMagicSlots ?? characterData.otherVariables?.pactMagicSlots ?? 0;
|
|
138
|
+
// Pact Magic always casts at the pact slot level - no "upcasting" terminology
|
|
139
|
+
slotLabel = `Pact Magic (level ${actualLevel})`;
|
|
140
|
+
debug.log(`🔮 Using Pact Magic slot at level ${actualLevel}, current=${currentSlots}`);
|
|
141
|
+
} else {
|
|
142
|
+
// Regular spell slot
|
|
143
|
+
actualLevel = parseInt(selectedSlotLevel);
|
|
144
|
+
slotVar = `level${actualLevel}SpellSlots`;
|
|
145
|
+
currentSlots = slotsObject[slotVar] || 0;
|
|
146
|
+
const isUpcast = actualLevel > spellLevel;
|
|
147
|
+
slotLabel = isUpcast ? `level ${actualLevel} slot (upcast from ${spellLevel})` : `level ${actualLevel} slot`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (currentSlots <= 0 && typeof showNotification === 'function') {
|
|
151
|
+
showNotification(`❌ No ${slotLabel} remaining!`, 'error');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Consume the slot
|
|
156
|
+
if (currentSlots > 0) {
|
|
157
|
+
if (isPactMagicSlot) {
|
|
158
|
+
if (slotsObject.pactMagicSlots !== undefined) {
|
|
159
|
+
slotsObject.pactMagicSlots = currentSlots - 1;
|
|
160
|
+
}
|
|
161
|
+
if (characterData.otherVariables?.pactMagicSlots !== undefined) {
|
|
162
|
+
characterData.otherVariables.pactMagicSlots = currentSlots - 1;
|
|
163
|
+
}
|
|
164
|
+
debug.log(`🔮 Consumed Pact Magic slot: ${currentSlots} -> ${currentSlots - 1}`);
|
|
165
|
+
} else {
|
|
166
|
+
slotsObject[slotVar] = currentSlots - 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (typeof saveCharacterData === 'function') {
|
|
170
|
+
saveCharacterData();
|
|
171
|
+
}
|
|
172
|
+
if (typeof buildSheet === 'function') {
|
|
173
|
+
buildSheet(characterData);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Apply metamagic costs
|
|
178
|
+
if (selectedMetamagic && selectedMetamagic.length > 0) {
|
|
179
|
+
debug.log('Metamagic selected:', selectedMetamagic);
|
|
180
|
+
// Metamagic point deduction is handled elsewhere
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Update selectedSlotLevel to actual level for formula resolution
|
|
184
|
+
selectedSlotLevel = actualLevel;
|
|
185
|
+
|
|
186
|
+
if (!skipAnnouncement && typeof announceSpellCast === 'function') {
|
|
187
|
+
announceSpellCast(spell, slotLabel);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (typeof showNotification === 'function') {
|
|
191
|
+
showNotification(`✨ Cast ${spell.name} using ${slotLabel}!`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Handle concentration
|
|
195
|
+
if (spell.concentration && typeof setConcentration === 'function') {
|
|
196
|
+
setConcentration(spell.name);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Track reuseable spells
|
|
200
|
+
const shouldTrackAsReusable = typeof isReuseableSpell === 'function' && isReuseableSpell(spell.name, characterData);
|
|
201
|
+
if (shouldTrackAsReusable) {
|
|
202
|
+
const castSpellsKey = `castSpells_${characterData.name}`;
|
|
203
|
+
const castSpells = JSON.parse(localStorage.getItem(castSpellsKey) || '[]');
|
|
204
|
+
if (!castSpells.includes(spell.name)) {
|
|
205
|
+
castSpells.push(spell.name);
|
|
206
|
+
localStorage.setItem(castSpellsKey, JSON.stringify(castSpells));
|
|
207
|
+
debug.log(`✅ Tracked reuseable spell: ${spell.name}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Execute afterCast
|
|
212
|
+
if (afterCast && typeof afterCast === 'function') {
|
|
213
|
+
setTimeout(() => {
|
|
214
|
+
afterCast(spell, { level: selectedSlotLevel });
|
|
215
|
+
}, 300);
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// No slot level selected - check for Divine Smite or show upcast choice
|
|
221
|
+
if (spell.name.toLowerCase().includes('divine smite') && typeof showDivineSmiteModal === 'function') {
|
|
222
|
+
debug.log(`⚡ Divine Smite spell detected, showing custom modal`);
|
|
223
|
+
showDivineSmiteModal(spell);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (typeof showUpcastChoice === 'function') {
|
|
228
|
+
showUpcastChoice(spell, spellLevel, afterCast);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Cast spell with a specific slot
|
|
234
|
+
* @param {object} spell - Spell object
|
|
235
|
+
* @param {object} slot - Slot object with level, current, max, slotVar, noSlotUsed
|
|
236
|
+
* @param {Array} metamagicOptions - Selected metamagic options
|
|
237
|
+
* @param {function} afterCast - Callback after casting
|
|
238
|
+
*/
|
|
239
|
+
function castWithSlot(spell, slot, metamagicOptions = [], afterCast = null) {
|
|
240
|
+
const debug = window.debug || console;
|
|
241
|
+
|
|
242
|
+
debug.log(`🎯 castWithSlot called for "${spell.name}"`, {
|
|
243
|
+
slotLevel: slot.level,
|
|
244
|
+
slotVar: slot.slotVar,
|
|
245
|
+
currentSlots: slot.current,
|
|
246
|
+
maxSlots: slot.max,
|
|
247
|
+
noSlotUsed: slot.noSlotUsed,
|
|
248
|
+
isPactMagic: slot.isPactMagic
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Deduct spell slot (unless casting without a slot)
|
|
252
|
+
if (!slot.noSlotUsed && slot.slotVar) {
|
|
253
|
+
const beforeDecrement = characterData.spellSlots[slot.slotVar];
|
|
254
|
+
characterData.spellSlots[slot.slotVar] = slot.current - 1;
|
|
255
|
+
const afterDecrement = characterData.spellSlots[slot.slotVar];
|
|
256
|
+
|
|
257
|
+
debug.log(`✅ Decremented ${slot.slotVar}: ${beforeDecrement} → ${afterDecrement}`);
|
|
258
|
+
|
|
259
|
+
// Also update otherVariables for Pact Magic to keep in sync
|
|
260
|
+
if (slot.isPactMagic && characterData.otherVariables?.pactMagicSlots !== undefined) {
|
|
261
|
+
characterData.otherVariables.pactMagicSlots = slot.current - 1;
|
|
262
|
+
debug.log(`✅ Also decremented pactMagicSlots in otherVariables`);
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
debug.warn(`⚠️ Spell slot NOT decremented (noSlotUsed=${slot.noSlotUsed}, slotVar=${slot.slotVar})`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Deduct sorcery points for metamagic
|
|
269
|
+
let totalMetamagicCost = 0;
|
|
270
|
+
let metamagicNames = [];
|
|
271
|
+
|
|
272
|
+
if (metamagicOptions && metamagicOptions.length > 0 && typeof getSorceryPointsResource === 'function') {
|
|
273
|
+
const sorceryPoints = getSorceryPointsResource();
|
|
274
|
+
if (sorceryPoints) {
|
|
275
|
+
metamagicOptions.forEach(meta => {
|
|
276
|
+
totalMetamagicCost += meta.cost;
|
|
277
|
+
metamagicNames.push(meta.name);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Deduct sorcery points
|
|
281
|
+
sorceryPoints.current = Math.max(0, sorceryPoints.current - totalMetamagicCost);
|
|
282
|
+
debug.log(`✨ Used ${totalMetamagicCost} sorcery points for metamagic. Remaining: ${sorceryPoints.current}/${sorceryPoints.max}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (typeof saveCharacterData === 'function') {
|
|
287
|
+
saveCharacterData();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Mark action economy as used based on casting time
|
|
291
|
+
if (typeof window.markActionEconomyUsed === 'function') {
|
|
292
|
+
const castingTime = (spell.castingTime || '').toLowerCase();
|
|
293
|
+
if (castingTime.includes('bonus')) {
|
|
294
|
+
window.markActionEconomyUsed('bonus');
|
|
295
|
+
} else if (castingTime.includes('reaction')) {
|
|
296
|
+
window.markActionEconomyUsed('reaction');
|
|
297
|
+
} else {
|
|
298
|
+
window.markActionEconomyUsed('action');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let resourceText;
|
|
303
|
+
let notificationText;
|
|
304
|
+
|
|
305
|
+
if (slot.noSlotUsed) {
|
|
306
|
+
resourceText = `Level ${slot.level} (NO SLOT USED - slot not decremented)`;
|
|
307
|
+
notificationText = `✨ Cast ${spell.name}! (no spell slot decremented)`;
|
|
308
|
+
debug.log(`⚠️ Cast without slot - no slot decremented`);
|
|
309
|
+
} else if (slot.isPactMagic) {
|
|
310
|
+
resourceText = `Pact Magic (Level ${slot.level})`;
|
|
311
|
+
debug.log(`✅ Used Pact Magic slot. Remaining: ${characterData.spellSlots[slot.slotVar]}/${slot.max}`);
|
|
312
|
+
notificationText = `✨ Cast ${spell.name}! (${characterData.spellSlots[slot.slotVar]}/${slot.max} Pact slots left)`;
|
|
313
|
+
} else if (slot.level > parseInt(spell.level)) {
|
|
314
|
+
resourceText = `Level ${slot.level} slot (upcast from ${spell.level})`;
|
|
315
|
+
debug.log(`✅ Used spell slot. Remaining: ${characterData.spellSlots[slot.slotVar]}/${slot.max}`);
|
|
316
|
+
notificationText = `✨ Cast ${spell.name}! (${characterData.spellSlots[slot.slotVar]}/${slot.max} slots left)`;
|
|
317
|
+
} else {
|
|
318
|
+
resourceText = `Level ${slot.level} slot`;
|
|
319
|
+
debug.log(`✅ Used spell slot. Remaining: ${characterData.spellSlots[slot.slotVar]}/${slot.max}`);
|
|
320
|
+
notificationText = `✨ Cast ${spell.name}! (${characterData.spellSlots[slot.slotVar]}/${slot.max} slots left)`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Add metamagic to resource text
|
|
324
|
+
if (metamagicNames.length > 0) {
|
|
325
|
+
resourceText += ` + ${metamagicNames.join(', ')} (${totalMetamagicCost} SP)`;
|
|
326
|
+
const sorceryPoints = getSorceryPointsResource();
|
|
327
|
+
notificationText += ` with ${metamagicNames.join(', ')}! (${sorceryPoints.current}/${sorceryPoints.max} SP left)`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (typeof announceSpellCast === 'function') {
|
|
331
|
+
announceSpellCast(spell, resourceText);
|
|
332
|
+
}
|
|
333
|
+
if (typeof showNotification === 'function') {
|
|
334
|
+
showNotification(notificationText);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Handle concentration
|
|
338
|
+
if (spell.concentration && typeof setConcentration === 'function') {
|
|
339
|
+
setConcentration(spell.name);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Track reuseable spells
|
|
343
|
+
const shouldTrackAsReusable = typeof isReuseableSpell === 'function' && isReuseableSpell(spell.name, characterData);
|
|
344
|
+
if (shouldTrackAsReusable) {
|
|
345
|
+
const castSpellsKey = `castSpells_${characterData.name}`;
|
|
346
|
+
const castSpells = JSON.parse(localStorage.getItem(castSpellsKey) || '[]');
|
|
347
|
+
if (!castSpells.includes(spell.name)) {
|
|
348
|
+
castSpells.push(spell.name);
|
|
349
|
+
localStorage.setItem(castSpellsKey, JSON.stringify(castSpells));
|
|
350
|
+
debug.log(`✅ Tracked reuseable spell: ${spell.name}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Update the display
|
|
355
|
+
if (typeof buildSheet === 'function') {
|
|
356
|
+
buildSheet(characterData);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Execute after-cast callback
|
|
360
|
+
if (afterCast && typeof afterCast === 'function') {
|
|
361
|
+
setTimeout(() => {
|
|
362
|
+
afterCast(spell, slot);
|
|
363
|
+
}, 300);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Use a class resource to cast a spell
|
|
369
|
+
* @param {object} resource - Resource object with name, current, max, varName
|
|
370
|
+
* @param {object} spell - Spell object
|
|
371
|
+
* @returns {boolean} Whether the resource was successfully used
|
|
372
|
+
*/
|
|
373
|
+
function useClassResource(resource, spell) {
|
|
374
|
+
if (resource.current <= 0) {
|
|
375
|
+
if (typeof showNotification === 'function') {
|
|
376
|
+
showNotification(`❌ No ${resource.name} remaining!`, 'error');
|
|
377
|
+
}
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
characterData.otherVariables[resource.varName] = resource.current - 1;
|
|
382
|
+
|
|
383
|
+
if (typeof saveCharacterData === 'function') {
|
|
384
|
+
saveCharacterData();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const debug = window.debug || console;
|
|
388
|
+
debug.log(`✅ Used ${resource.name}. Remaining: ${characterData.otherVariables[resource.varName]}/${resource.max}`);
|
|
389
|
+
|
|
390
|
+
if (typeof showNotification === 'function') {
|
|
391
|
+
showNotification(`✨ Cast ${spell.name}! (${characterData.otherVariables[resource.varName]}/${resource.max} ${resource.name} left)`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Handle concentration
|
|
395
|
+
if (spell.concentration && typeof setConcentration === 'function') {
|
|
396
|
+
setConcentration(spell.name);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Track reuseable spells
|
|
400
|
+
const shouldTrackAsReusable = typeof isReuseableSpell === 'function' && isReuseableSpell(spell.name, characterData);
|
|
401
|
+
if (shouldTrackAsReusable) {
|
|
402
|
+
const castSpellsKey = `castSpells_${characterData.name}`;
|
|
403
|
+
const castSpells = JSON.parse(localStorage.getItem(castSpellsKey) || '[]');
|
|
404
|
+
if (!castSpells.includes(spell.name)) {
|
|
405
|
+
castSpells.push(spell.name);
|
|
406
|
+
localStorage.setItem(castSpellsKey, JSON.stringify(castSpells));
|
|
407
|
+
debug.log(`✅ Tracked reuseable spell: ${spell.name}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (typeof buildSheet === 'function') {
|
|
412
|
+
buildSheet(characterData);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Detect available class resources that can be used for spell casting
|
|
420
|
+
* @param {object} spell - Spell object
|
|
421
|
+
* @returns {Array} Array of available class resources
|
|
422
|
+
*/
|
|
423
|
+
function detectClassResources(spell) {
|
|
424
|
+
if (typeof executorDetectClassResources === 'function') {
|
|
425
|
+
return executorDetectClassResources(characterData);
|
|
426
|
+
}
|
|
427
|
+
return [];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ===== SPELL ANNOUNCEMENTS =====
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Announce spell description to chat
|
|
434
|
+
* @param {object} spell - Spell object
|
|
435
|
+
* @param {number|null} castLevel - Level the spell is being cast at
|
|
436
|
+
*/
|
|
437
|
+
function announceSpellDescription(spell, castLevel = null) {
|
|
438
|
+
// Resolve DiceCloud inline calculations ({#spellList.dc}, {max(slotLevel,1)},
|
|
439
|
+
// etc.) before sending so the Roll20 chat card shows real numbers, not raw
|
|
440
|
+
// template text. The sheet display resolves separately; this is the chat path.
|
|
441
|
+
const rv = (t) => (t && typeof resolveVariablesInFormula === 'function')
|
|
442
|
+
? resolveVariablesInFormula(String(t))
|
|
443
|
+
: t;
|
|
444
|
+
const resolvedSpell = {
|
|
445
|
+
...spell,
|
|
446
|
+
summary: rv(spell.summary),
|
|
447
|
+
description: rv(spell.description),
|
|
448
|
+
range: rv(spell.range),
|
|
449
|
+
duration: rv(spell.duration),
|
|
450
|
+
castingTime: rv(spell.castingTime),
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const messageData = {
|
|
454
|
+
action: 'announceSpell',
|
|
455
|
+
spellName: spell.name,
|
|
456
|
+
characterName: characterData.name,
|
|
457
|
+
color: characterData.notificationColor,
|
|
458
|
+
spellData: resolvedSpell,
|
|
459
|
+
castLevel: castLevel
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const debug = window.debug || console;
|
|
463
|
+
|
|
464
|
+
sendToRoll20(messageData);
|
|
465
|
+
debug.log('✅ Spell data sent to Roll20');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Announce spell cast with resource usage
|
|
470
|
+
* @param {object} spell - Spell object
|
|
471
|
+
* @param {string|null} resourceUsed - Description of resource used
|
|
472
|
+
*/
|
|
473
|
+
function announceSpellCast(spell, resourceUsed) {
|
|
474
|
+
const debug = window.debug || console;
|
|
475
|
+
|
|
476
|
+
// Check if spell has damage rolls (buttons will be shown)
|
|
477
|
+
const hasDamageRolls = spell.damageRolls && spell.damageRolls.length > 0;
|
|
478
|
+
|
|
479
|
+
// Build the announcement message
|
|
480
|
+
const colorBanner = typeof getColoredBanner === 'function' ? getColoredBanner(characterData) : '';
|
|
481
|
+
let message = `&{template:default} {{name=${colorBanner}${characterData.name} casts ${spell.name}!}}`;
|
|
482
|
+
|
|
483
|
+
// Add resource usage if specified
|
|
484
|
+
if (resourceUsed) {
|
|
485
|
+
message += ` {{Resource Used=${resourceUsed}}}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const messageData = {
|
|
489
|
+
action: 'announceSpell',
|
|
490
|
+
spellName: spell.name,
|
|
491
|
+
characterName: characterData.name,
|
|
492
|
+
message: message,
|
|
493
|
+
color: characterData.notificationColor
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
// Send announcement to Roll20
|
|
497
|
+
sendToRoll20(messageData);
|
|
498
|
+
debug.log('✅ Spell announcement sent to Roll20');
|
|
499
|
+
|
|
500
|
+
// Only auto-roll if there are NO damage rolls (no buttons)
|
|
501
|
+
// If there are damage rolls, the modal will handle rolling when buttons are clicked
|
|
502
|
+
if (spell.formula && typeof roll === 'function' && !hasDamageRolls) {
|
|
503
|
+
debug.log('✨ Auto-rolling spell formula (no damage rolls - no buttons)', spell.name);
|
|
504
|
+
setTimeout(() => {
|
|
505
|
+
roll(spell.name, spell.formula);
|
|
506
|
+
}, 500);
|
|
507
|
+
} else if (hasDamageRolls) {
|
|
508
|
+
debug.log('✨ Spell has damage rolls - skipping auto-roll, modal buttons will handle it', spell.name);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ===== SPELL HELPERS =====
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Get spellcasting ability modifier based on character class
|
|
516
|
+
* @returns {number} Spellcasting ability modifier
|
|
517
|
+
*/
|
|
518
|
+
function getSpellcastingAbilityMod() {
|
|
519
|
+
if (!characterData || !characterData.abilityMods) {
|
|
520
|
+
return 0;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const charClass = (characterData.class || '').toLowerCase();
|
|
524
|
+
|
|
525
|
+
// Map classes to their spellcasting abilities
|
|
526
|
+
// Wisdom-based: Cleric, Druid, Ranger, Monk
|
|
527
|
+
if (charClass.includes('cleric') || charClass.includes('druid') ||
|
|
528
|
+
charClass.includes('ranger') || charClass.includes('monk')) {
|
|
529
|
+
return characterData.abilityMods.wisdomMod || 0;
|
|
530
|
+
}
|
|
531
|
+
// Intelligence-based: Wizard, Artificer, Eldritch Knight, Arcane Trickster
|
|
532
|
+
else if (charClass.includes('wizard') || charClass.includes('artificer') ||
|
|
533
|
+
charClass.includes('eldritch knight') || charClass.includes('arcane trickster')) {
|
|
534
|
+
return characterData.abilityMods.intelligenceMod || 0;
|
|
535
|
+
}
|
|
536
|
+
// Charisma-based: Sorcerer, Bard, Warlock, Paladin
|
|
537
|
+
else if (charClass.includes('sorcerer') || charClass.includes('bard') ||
|
|
538
|
+
charClass.includes('warlock') || charClass.includes('paladin')) {
|
|
539
|
+
return characterData.abilityMods.charismaMod || 0;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Default to highest mental stat
|
|
543
|
+
const intMod = characterData.abilityMods.intelligenceMod || 0;
|
|
544
|
+
const wisMod = characterData.abilityMods.wisdomMod || 0;
|
|
545
|
+
const chaMod = characterData.abilityMods.charismaMod || 0;
|
|
546
|
+
return Math.max(intMod, wisMod, chaMod);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Calculate spell attack bonus
|
|
551
|
+
* @returns {number} Spell attack bonus
|
|
552
|
+
*/
|
|
553
|
+
function getSpellAttackBonus() {
|
|
554
|
+
const spellMod = getSpellcastingAbilityMod();
|
|
555
|
+
const profBonus = characterData.proficiencyBonus || 0;
|
|
556
|
+
return spellMod + profBonus;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Calculate metamagic cost for a given metamagic option and spell level
|
|
561
|
+
* @param {string} metamagicName - Name of the metamagic
|
|
562
|
+
* @param {number} spellLevel - Level of the spell
|
|
563
|
+
* @returns {number} Cost in sorcery points
|
|
564
|
+
*/
|
|
565
|
+
function calculateMetamagicCost(metamagicName, spellLevel) {
|
|
566
|
+
// Use executor function if available
|
|
567
|
+
if (typeof executorCalculateMetamagicCost === 'function') {
|
|
568
|
+
return executorCalculateMetamagicCost(metamagicName, spellLevel);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Fallback implementation
|
|
572
|
+
switch (metamagicName) {
|
|
573
|
+
case 'Twinned Spell':
|
|
574
|
+
return spellLevel === 0 ? 1 : spellLevel; // Cantrips cost 1, leveled spells cost spell level
|
|
575
|
+
case 'Heightened Spell':
|
|
576
|
+
return 3;
|
|
577
|
+
case 'Quickened Spell':
|
|
578
|
+
return 2;
|
|
579
|
+
case 'Careful Spell':
|
|
580
|
+
case 'Distant Spell':
|
|
581
|
+
case 'Extended Spell':
|
|
582
|
+
case 'Subtle Spell':
|
|
583
|
+
return 1;
|
|
584
|
+
case 'Empowered Spell':
|
|
585
|
+
return 1;
|
|
586
|
+
default:
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Get available metamagic options for character
|
|
593
|
+
* @returns {Array} Array of available metamagic options
|
|
594
|
+
*/
|
|
595
|
+
function getAvailableMetamagic() {
|
|
596
|
+
// Call the global getAvailableMetamagic from action-executor.js
|
|
597
|
+
if (typeof window.getAvailableMetamagic === 'function') {
|
|
598
|
+
return window.getAvailableMetamagic(characterData);
|
|
599
|
+
}
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Handle recover spell slot (Arcane Recovery, etc.)
|
|
605
|
+
* @param {object} action - Action object
|
|
606
|
+
*/
|
|
607
|
+
function handleRecoverSpellSlot(action) {
|
|
608
|
+
// Calculate max recoverable level from proficiency bonus
|
|
609
|
+
const profBonus = characterData.proficiencyBonus || 2;
|
|
610
|
+
const maxLevel = Math.ceil(profBonus / 2);
|
|
611
|
+
|
|
612
|
+
const debug = window.debug || console;
|
|
613
|
+
debug.log(`🔮 Recover Spell Slot: proficiencyBonus=${profBonus}, maxLevel=${maxLevel}`);
|
|
614
|
+
|
|
615
|
+
// Find available spell slots of eligible levels
|
|
616
|
+
const eligibleSlots = [];
|
|
617
|
+
for (let level = 1; level <= maxLevel && level <= 9; level++) {
|
|
618
|
+
const slotKey = `level${level}SpellSlots`;
|
|
619
|
+
const maxKey = `level${level}SpellSlotsMax`;
|
|
620
|
+
|
|
621
|
+
if (characterData[slotKey] !== undefined && characterData[maxKey] !== undefined) {
|
|
622
|
+
const current = characterData[slotKey];
|
|
623
|
+
const max = characterData[maxKey];
|
|
624
|
+
|
|
625
|
+
if (current < max) {
|
|
626
|
+
eligibleSlots.push({ level, current, max, slotKey, maxKey });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (eligibleSlots.length === 0) {
|
|
632
|
+
if (typeof showNotification === 'function') {
|
|
633
|
+
showNotification(`❌ No spell slots to recover (max level: ${maxLevel})`, 'error');
|
|
634
|
+
}
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// If only one eligible slot, recover it automatically
|
|
639
|
+
if (eligibleSlots.length === 1) {
|
|
640
|
+
recoverSpellSlot(eligibleSlots[0], action, maxLevel);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// If multiple slots, let user choose
|
|
645
|
+
let message = `Recover Spell Slot (max level: ${maxLevel})\n\nChoose which spell slot to recover:\n\n`;
|
|
646
|
+
eligibleSlots.forEach((slot, index) => {
|
|
647
|
+
message += `${index + 1}. Level ${slot.level}: ${slot.current}/${slot.max}\n`;
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const choice = prompt(message);
|
|
651
|
+
if (choice === null) return; // Cancelled
|
|
652
|
+
|
|
653
|
+
const choiceIndex = parseInt(choice) - 1;
|
|
654
|
+
if (isNaN(choiceIndex) || choiceIndex < 0 || choiceIndex >= eligibleSlots.length) {
|
|
655
|
+
if (typeof showNotification === 'function') {
|
|
656
|
+
showNotification('❌ Invalid choice', 'error');
|
|
657
|
+
}
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
recoverSpellSlot(eligibleSlots[choiceIndex], action, maxLevel);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Recover a spell slot
|
|
666
|
+
* @param {object} slot - Slot object with level, current, max, slotKey
|
|
667
|
+
* @param {object} action - Action object
|
|
668
|
+
* @param {number} maxLevel - Maximum level that can be recovered
|
|
669
|
+
*/
|
|
670
|
+
function recoverSpellSlot(slot, action, maxLevel) {
|
|
671
|
+
// Increment the spell slot
|
|
672
|
+
characterData[slot.slotKey] = Math.min(characterData[slot.slotKey] + 1, characterData[slot.maxKey]);
|
|
673
|
+
|
|
674
|
+
if (typeof saveCharacterData === 'function') {
|
|
675
|
+
saveCharacterData();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Create description with resolved formula
|
|
679
|
+
const description = `You expend a use of your Channel Divinity to fuel your spells. As a bonus action, you touch your holy symbol, utter a prayer, and regain one expended spell slot, the level of which can be no higher than ${maxLevel}.`;
|
|
680
|
+
|
|
681
|
+
// Announce the action
|
|
682
|
+
if (typeof announceAction === 'function') {
|
|
683
|
+
announceAction({
|
|
684
|
+
name: action.name,
|
|
685
|
+
description: description,
|
|
686
|
+
actionType: action.actionType || 'bonus'
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (typeof showNotification === 'function') {
|
|
691
|
+
showNotification(`🔮 Recovered Level ${slot.level} Spell Slot (${characterData[slot.slotKey]}/${characterData[slot.maxKey]})`, 'success');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Refresh display
|
|
695
|
+
if (typeof buildSheet === 'function') {
|
|
696
|
+
buildSheet(characterData);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Export functions to globalThis
|
|
701
|
+
Object.assign(globalThis, {
|
|
702
|
+
// Core casting
|
|
703
|
+
castSpell,
|
|
704
|
+
castWithSlot,
|
|
705
|
+
useClassResource,
|
|
706
|
+
detectClassResources,
|
|
707
|
+
|
|
708
|
+
// Announcements
|
|
709
|
+
announceSpellDescription,
|
|
710
|
+
announceSpellCast,
|
|
711
|
+
|
|
712
|
+
// Helpers
|
|
713
|
+
getSpellcastingAbilityMod,
|
|
714
|
+
getSpellAttackBonus,
|
|
715
|
+
calculateMetamagicCost,
|
|
716
|
+
getAvailableMetamagic,
|
|
717
|
+
handleRecoverSpellSlot,
|
|
718
|
+
recoverSpellSlot
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
console.log('✅ Spell Casting module loaded');
|
|
722
|
+
|
|
723
|
+
})();
|