@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.
Files changed (118) hide show
  1. package/dist/cache/CacheManager.d.ts.map +1 -0
  2. package/dist/cache/CacheManager.js +131 -0
  3. package/dist/cache/CacheManager.js.map +1 -0
  4. package/dist/index.d.ts +18 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +22 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/ir/index.d.ts +11 -0
  9. package/dist/ir/index.d.ts.map +1 -0
  10. package/dist/ir/index.js +9 -0
  11. package/dist/ir/index.js.map +1 -0
  12. package/dist/ir/normalize.d.ts +10 -0
  13. package/dist/ir/normalize.d.ts.map +1 -0
  14. package/dist/ir/normalize.js +207 -0
  15. package/dist/ir/normalize.js.map +1 -0
  16. package/dist/ir/persistence.d.ts +26 -0
  17. package/dist/ir/persistence.d.ts.map +1 -0
  18. package/dist/ir/persistence.js +21 -0
  19. package/dist/ir/persistence.js.map +1 -0
  20. package/dist/ir/sync.d.ts +12 -0
  21. package/dist/ir/sync.d.ts.map +1 -0
  22. package/dist/ir/sync.js +36 -0
  23. package/dist/ir/sync.js.map +1 -0
  24. package/dist/ir/types.d.ts +143 -0
  25. package/dist/ir/types.d.ts.map +1 -0
  26. package/dist/ir/types.js +13 -0
  27. package/dist/ir/types.js.map +1 -0
  28. package/dist/ir/views/dnd5e.d.ts +40 -0
  29. package/dist/ir/views/dnd5e.d.ts.map +1 -0
  30. package/dist/ir/views/dnd5e.js +50 -0
  31. package/dist/ir/views/dnd5e.js.map +1 -0
  32. package/dist/render/character.d.ts +19 -0
  33. package/dist/render/character.d.ts.map +1 -0
  34. package/dist/render/character.js +156 -0
  35. package/dist/render/character.js.map +1 -0
  36. package/dist/render/h.d.ts +27 -0
  37. package/dist/render/h.d.ts.map +1 -0
  38. package/dist/render/h.js +64 -0
  39. package/dist/render/h.js.map +1 -0
  40. package/dist/render/index.d.ts +11 -0
  41. package/dist/render/index.d.ts.map +1 -0
  42. package/dist/render/index.js +8 -0
  43. package/dist/render/index.js.map +1 -0
  44. package/dist/render/mount.d.ts +31 -0
  45. package/dist/render/mount.d.ts.map +1 -0
  46. package/dist/render/mount.js +63 -0
  47. package/dist/render/mount.js.map +1 -0
  48. package/dist/supabase/fields.d.ts.map +1 -0
  49. package/dist/supabase/fields.js +120 -0
  50. package/dist/supabase/fields.js.map +1 -0
  51. package/dist/types/character.d.ts.map +1 -0
  52. package/dist/types/character.js +5 -0
  53. package/dist/types/character.js.map +1 -0
  54. package/package.json +73 -0
  55. package/src/browser.js +51 -0
  56. package/src/cache/CacheManager.ts +174 -0
  57. package/src/common/browser-polyfill.js +319 -0
  58. package/src/common/debug.js +123 -0
  59. package/src/common/html-utils.js +134 -0
  60. package/src/common/theme-manager.js +265 -0
  61. package/src/index.ts +25 -0
  62. package/src/ir/__fixtures__/dnd5e-character.json +75962 -0
  63. package/src/ir/__fixtures__/non-dnd-character.json +14218 -0
  64. package/src/ir/index.ts +10 -0
  65. package/src/ir/normalize.ts +245 -0
  66. package/src/ir/persistence.ts +37 -0
  67. package/src/ir/sync.ts +49 -0
  68. package/src/ir/types.ts +161 -0
  69. package/src/ir/views/dnd5e.ts +94 -0
  70. package/src/lib/indexeddb-cache.js +320 -0
  71. package/src/modules/action-announcements.js +102 -0
  72. package/src/modules/action-display.js +1557 -0
  73. package/src/modules/action-executor.js +860 -0
  74. package/src/modules/action-filters.js +167 -0
  75. package/src/modules/action-options.js +117 -0
  76. package/src/modules/card-creator.js +142 -0
  77. package/src/modules/character-portrait.js +169 -0
  78. package/src/modules/character-trait-popups.js +959 -0
  79. package/src/modules/character-traits.js +814 -0
  80. package/src/modules/class-feature-edge-cases.js +1320 -0
  81. package/src/modules/color-utils.js +69 -0
  82. package/src/modules/combat-maneuver-edge-cases.js +660 -0
  83. package/src/modules/companions-manager.js +178 -0
  84. package/src/modules/concentration-tracker.js +178 -0
  85. package/src/modules/data-manager.js +514 -0
  86. package/src/modules/dice-roller.js +719 -0
  87. package/src/modules/effects-manager.js +743 -0
  88. package/src/modules/feature-modals.js +1264 -0
  89. package/src/modules/formula-resolver.js +444 -0
  90. package/src/modules/gm-mode.js +184 -0
  91. package/src/modules/health-modals.js +399 -0
  92. package/src/modules/hp-management.js +752 -0
  93. package/src/modules/inventory-manager.js +242 -0
  94. package/src/modules/macro-system.js +825 -0
  95. package/src/modules/notification-system.js +92 -0
  96. package/src/modules/racial-feature-edge-cases.js +746 -0
  97. package/src/modules/resource-manager.js +775 -0
  98. package/src/modules/sheet-builder.js +654 -0
  99. package/src/modules/spell-action-modals.js +583 -0
  100. package/src/modules/spell-cards.js +602 -0
  101. package/src/modules/spell-casting.js +723 -0
  102. package/src/modules/spell-display.js +314 -0
  103. package/src/modules/spell-edge-cases.js +509 -0
  104. package/src/modules/spell-macros.js +201 -0
  105. package/src/modules/spell-modals.js +1221 -0
  106. package/src/modules/spell-slots.js +224 -0
  107. package/src/modules/status-bar-bridge.js +101 -0
  108. package/src/modules/ui-utilities.js +284 -0
  109. package/src/modules/warlock-invocations.js +219 -0
  110. package/src/modules/window-management.js +211 -0
  111. package/src/render/character.ts +234 -0
  112. package/src/render/h.ts +74 -0
  113. package/src/render/index.ts +10 -0
  114. package/src/render/mount.ts +94 -0
  115. package/src/supabase/client.js +1383 -0
  116. package/src/supabase/config.js +60 -0
  117. package/src/supabase/fields.ts +129 -0
  118. 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
+ })();