@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,1221 @@
1
+ /**
2
+ * Spell Modals Module
3
+ *
4
+ * Handles modal dialogs for spell casting interactions.
5
+ * - Main spell casting modal with options
6
+ * - Upcast selection modal
7
+ * - Resource choice modal
8
+ * - Option click handlers
9
+ *
10
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
11
+ */
12
+
13
+ (function() {
14
+ 'use strict';
15
+
16
+ /**
17
+ * Show spell casting modal with options
18
+ * @param {object} spell - Spell object
19
+ * @param {number} spellIndex - Spell index
20
+ * @param {Array} options - Array of spell options
21
+ * @param {boolean} descriptionAnnounced - Whether spell description was already announced
22
+ */
23
+ function showSpellModal(spell, spellIndex, options, descriptionAnnounced = false) {
24
+ // Get theme-aware colors
25
+ const colors = getPopupThemeColors();
26
+
27
+ // Check for custom macros
28
+ const customMacros = getCustomMacros(spell.name);
29
+ const hasCustomMacros = customMacros && customMacros.buttons && customMacros.buttons.length > 0;
30
+
31
+ // Create modal overlay
32
+ const overlay = document.createElement('div');
33
+ overlay.className = 'spell-modal-overlay';
34
+ overlay.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 10000;';
35
+
36
+ // Create modal content
37
+ const modal = document.createElement('div');
38
+ modal.className = 'spell-modal';
39
+ modal.style.cssText = `background: ${colors.background}; padding: 24px; border-radius: 8px; max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 4px 20px rgba(0,0,0,0.3);`;
40
+
41
+ // Modal header
42
+ const header = document.createElement('div');
43
+ header.style.cssText = `margin-bottom: 16px; padding-bottom: 12px; border-bottom: 2px solid ${colors.border};`;
44
+
45
+ // Format spell level text
46
+ let levelText = '';
47
+ if (spell.level === 0) {
48
+ levelText = `<div style="color: ${colors.infoText}; font-size: 14px;">Cantrip</div>`;
49
+ } else if (spell.level) {
50
+ levelText = `<div style="color: ${colors.infoText}; font-size: 14px;">Level ${spell.level} Spell</div>`;
51
+ }
52
+
53
+ header.innerHTML = `
54
+ <h2 style="margin: 0 0 8px 0; color: ${colors.heading};">Cast ${spell.name}</h2>
55
+ ${levelText}
56
+ `;
57
+
58
+ modal.appendChild(header);
59
+
60
+ // Slot selection (for leveled spells)
61
+ let slotSelect = null;
62
+ if (spell.level && spell.level > 0) {
63
+ const slotSection = document.createElement('div');
64
+ slotSection.style.cssText = `margin-bottom: 16px; padding: 12px; background: ${colors.infoBox}; border-radius: 6px;`;
65
+
66
+ const slotLabel = document.createElement('label');
67
+ slotLabel.style.cssText = `display: block; margin-bottom: 8px; font-weight: bold; color: ${colors.text};`;
68
+ slotLabel.textContent = 'Cast at level:';
69
+
70
+ slotSelect = document.createElement('select');
71
+ slotSelect.style.cssText = `width: 100%; padding: 8px; border: 2px solid ${colors.border}; border-radius: 4px; font-size: 14px; background: ${colors.background}; color: ${colors.text};`;
72
+
73
+ // Check for Pact Magic slots (Warlock) - these are SEPARATE from regular spell slots
74
+ // Check both spellSlots and otherVariables since data may come from either source
75
+ // DiceCloud uses various variable names: pactSlot, pactMagicSlots, pactSlotLevelVisible, etc.
76
+ const pactMagicSlotLevel = characterData.spellSlots?.pactMagicSlotLevel ||
77
+ characterData.otherVariables?.pactMagicSlotLevel ||
78
+ characterData.otherVariables?.pactSlotLevelVisible ||
79
+ characterData.otherVariables?.pactSlotLevel ||
80
+ characterData.otherVariables?.slotLevel;
81
+ const pactMagicSlots = characterData.spellSlots?.pactMagicSlots ??
82
+ characterData.otherVariables?.pactMagicSlots ??
83
+ characterData.otherVariables?.pactSlot ?? 0;
84
+ const pactMagicSlotsMax = characterData.spellSlots?.pactMagicSlotsMax ??
85
+ characterData.otherVariables?.pactMagicSlotsMax ??
86
+ characterData.otherVariables?.pactSlotMax ?? 0;
87
+ const hasPactMagic = pactMagicSlotsMax > 0;
88
+ // Default slot level to 1 if we have slots but couldn't detect level
89
+ const effectivePactLevel = pactMagicSlotLevel || (hasPactMagic ? 5 : 0); // Default to max (5) if level unknown
90
+
91
+ debug.log(`🔮 Pact Magic check: level=${pactMagicSlotLevel} (effective=${effectivePactLevel}), slots=${pactMagicSlots}/${pactMagicSlotsMax}, hasPact=${hasPactMagic}`);
92
+
93
+ // Add options for available spell slots (spell level and higher)
94
+ let hasAnySlots = false;
95
+ let hasRegularSlots = false;
96
+ let firstValidOption = null;
97
+
98
+ // First, add Pact Magic slots if available and spell level is compatible
99
+ // Pact Magic slots can cast any spell from level 1 up to the pact slot level
100
+ if (hasPactMagic && spell.level <= effectivePactLevel) {
101
+ hasAnySlots = true;
102
+ const option = document.createElement('option');
103
+ option.value = `pact:${effectivePactLevel}`; // Special format to identify pact slots
104
+ option.textContent = `Level ${effectivePactLevel} - Pact Magic (${pactMagicSlots}/${pactMagicSlotsMax})`;
105
+ option.disabled = pactMagicSlots === 0;
106
+ slotSelect.appendChild(option);
107
+ if (!option.disabled && !firstValidOption) {
108
+ firstValidOption = option;
109
+ }
110
+ debug.log(`🔮 Added Pact Magic slot option: Level ${effectivePactLevel} (${pactMagicSlots}/${pactMagicSlotsMax})`);
111
+ }
112
+
113
+ // Then add regular spell slots (excluding the pact magic level to avoid duplicates)
114
+ for (let level = spell.level; level <= 9; level++) {
115
+ const slotsProp = `level${level}SpellSlots`;
116
+ const maxSlotsProp = `level${level}SpellSlotsMax`;
117
+ let available = characterData.spellSlots?.[slotsProp] || characterData[slotsProp] || 0;
118
+ let max = characterData.spellSlots?.[maxSlotsProp] || characterData[maxSlotsProp] || 0;
119
+
120
+ // If this level has Pact Magic, subtract pact slots from the total (they're counted separately)
121
+ if (hasPactMagic && level === effectivePactLevel) {
122
+ available = Math.max(0, available - pactMagicSlots);
123
+ max = Math.max(0, max - pactMagicSlotsMax);
124
+ }
125
+
126
+ if (max > 0) {
127
+ hasAnySlots = true;
128
+ hasRegularSlots = true;
129
+ const option = document.createElement('option');
130
+ option.value = level; // Regular level number for normal slots
131
+ option.textContent = `Level ${level} (${available}/${max} slots)`;
132
+ option.disabled = available === 0;
133
+ slotSelect.appendChild(option);
134
+ if (!option.disabled && !firstValidOption) {
135
+ firstValidOption = option;
136
+ }
137
+ }
138
+ }
139
+
140
+ // Select the first valid (non-disabled) option
141
+ if (firstValidOption) {
142
+ firstValidOption.selected = true;
143
+ }
144
+
145
+ // If no slots available at all, show a message
146
+ if (!hasAnySlots) {
147
+ const noSlotsOption = document.createElement('option');
148
+ noSlotsOption.value = spell.level;
149
+ noSlotsOption.textContent = 'No spell slots available';
150
+ noSlotsOption.disabled = true;
151
+ noSlotsOption.selected = true;
152
+ slotSelect.appendChild(noSlotsOption);
153
+ }
154
+
155
+ // If ONLY Pact Magic slots exist (no regular spell slots), don't show the dropdown
156
+ // Instead, automatically use the Pact Magic slot level
157
+ if (hasPactMagic && !hasRegularSlots && spell.level <= effectivePactLevel) {
158
+ // Store the auto-selected Pact Magic level on the modal for button handlers to use
159
+ modal.dataset.autoSlotLevel = `pact:${effectivePactLevel}`;
160
+ debug.log(`🔮 Auto-selecting Pact Magic level ${effectivePactLevel} (no regular slots available)`);
161
+ // Don't append the slot selection UI - it's not needed
162
+ } else {
163
+ // Show the dropdown since there are multiple slot options
164
+ slotSection.appendChild(slotLabel);
165
+ slotSection.appendChild(slotSelect);
166
+ modal.appendChild(slotSection);
167
+
168
+ // Store reference to update button labels later
169
+ // (will be set after buttons are created)
170
+ slotSelect.updateButtonLabels = null;
171
+ }
172
+ }
173
+
174
+ // Concentration spell recast option OR special spells that allow reuse without slots
175
+ // (if already concentrating on this spell, or for spells like Spiritual Weapon, Meld into Stone)
176
+ // NOTE: Cantrips (level 0) never use slots, so don't show this checkbox for them
177
+ let skipSlotCheckbox = null;
178
+ const isCantrip = spell.level === 0;
179
+ const isConcentrationRecast = spell.concentration && concentratingSpell === spell.name;
180
+
181
+ // Spells that allow repeated use without consuming slots (non-concentration)
182
+ // Exclude cantrips since they never use slots anyway
183
+ const isReuseableSpellType = !isCantrip && isReuseableSpell(spell.name, characterData);
184
+
185
+ // Check if this spell was already cast (stored in localStorage or session)
186
+ const castSpellsKey = `castSpells_${characterData.name}`;
187
+ const castSpells = JSON.parse(localStorage.getItem(castSpellsKey) || '[]');
188
+ const wasAlreadyCast = castSpells.includes(spell.name);
189
+
190
+ // Show checkbox for concentration recasts OR for all reuseable spells (even on first cast)
191
+ // But NOT for cantrips since they never consume slots
192
+ if (!isCantrip && (isConcentrationRecast || isReuseableSpellType)) {
193
+ const recastSection = document.createElement('div');
194
+ recastSection.style.cssText = 'margin-bottom: 16px; padding: 12px; background: #fff3cd; border-radius: 6px; border: 2px solid #f39c12;';
195
+
196
+ const checkboxContainer = document.createElement('label');
197
+ checkboxContainer.style.cssText = 'display: flex; align-items: center; gap: 8px; cursor: pointer;';
198
+
199
+ skipSlotCheckbox = document.createElement('input');
200
+ skipSlotCheckbox.type = 'checkbox';
201
+ // Default checked if concentration recast OR if reuseable spell was already cast
202
+ skipSlotCheckbox.checked = isConcentrationRecast || wasAlreadyCast;
203
+ skipSlotCheckbox.style.cssText = 'width: 20px; height: 20px;';
204
+
205
+ const checkboxLabel = document.createElement('span');
206
+ checkboxLabel.style.cssText = 'font-weight: bold; color: #856404;';
207
+ if (isConcentrationRecast) {
208
+ checkboxLabel.textContent = '🧠 Already concentrating - don\'t consume spell slot';
209
+ } else if (wasAlreadyCast) {
210
+ checkboxLabel.textContent = '⚔️ Spell already active - don\'t consume spell slot';
211
+ } else {
212
+ checkboxLabel.textContent = '⚔️ Reuse spell effect without consuming slot (first cast required)';
213
+ }
214
+
215
+ checkboxContainer.appendChild(skipSlotCheckbox);
216
+ checkboxContainer.appendChild(checkboxLabel);
217
+ recastSection.appendChild(checkboxContainer);
218
+
219
+ const helpText = document.createElement('div');
220
+ helpText.style.cssText = 'font-size: 0.85em; color: #856404; margin-top: 6px; margin-left: 28px;';
221
+ if (isConcentrationRecast) {
222
+ helpText.textContent = 'You can use this spell\'s effect again while concentrating on it without recasting.';
223
+ } else {
224
+ helpText.textContent = 'You can use this spell\'s effect again while it\'s active without recasting.';
225
+ }
226
+ recastSection.appendChild(helpText);
227
+
228
+ modal.appendChild(recastSection);
229
+
230
+ // If skip slot is checked, disable slot selection
231
+ skipSlotCheckbox.addEventListener('change', () => {
232
+ if (slotSelect) {
233
+ slotSelect.disabled = skipSlotCheckbox.checked;
234
+ slotSelect.style.opacity = skipSlotCheckbox.checked ? '0.5' : '1';
235
+ }
236
+ });
237
+
238
+ // Initialize disabled state
239
+ if (slotSelect && skipSlotCheckbox.checked) {
240
+ slotSelect.disabled = true;
241
+ slotSelect.style.opacity = '0.5';
242
+ }
243
+ }
244
+
245
+ // Metamagic options (if character has metamagic features)
246
+ // Only the 8 official Sorcerer metamagic options from PHB
247
+ const metamagicCheckboxes = [];
248
+ const validMetamagicNames = [
249
+ 'Careful Spell',
250
+ 'Distant Spell',
251
+ 'Empowered Spell',
252
+ 'Extended Spell',
253
+ 'Heightened Spell',
254
+ 'Quickened Spell',
255
+ 'Subtle Spell',
256
+ 'Twinned Spell'
257
+ ];
258
+ const metamagicFeatures = characterData.features ? characterData.features.filter(f =>
259
+ f.name && validMetamagicNames.includes(f.name)
260
+ ) : [];
261
+
262
+ if (metamagicFeatures.length > 0) {
263
+ const metamagicSection = document.createElement('div');
264
+ metamagicSection.style.cssText = `margin-bottom: 16px; padding: 12px; background: ${colors.infoBox}; border-radius: 6px; border: 1px solid ${colors.border};`;
265
+
266
+ const metamagicTitle = document.createElement('div');
267
+ metamagicTitle.style.cssText = `font-weight: bold; margin-bottom: 8px; color: ${colors.text};`;
268
+ metamagicTitle.textContent = 'Metamagic:';
269
+ metamagicSection.appendChild(metamagicTitle);
270
+
271
+ metamagicFeatures.forEach(feature => {
272
+ const checkboxContainer = document.createElement('label');
273
+ checkboxContainer.style.cssText = 'display: flex; align-items: center; gap: 8px; margin-bottom: 4px; cursor: pointer;';
274
+
275
+ const checkbox = document.createElement('input');
276
+ checkbox.type = 'checkbox';
277
+ checkbox.value = feature.name;
278
+ checkbox.style.cssText = 'width: 18px; height: 18px;';
279
+
280
+ const label = document.createElement('span');
281
+ label.textContent = feature.name;
282
+ label.style.cssText = `font-size: 14px; color: ${colors.infoText};`;
283
+
284
+ checkboxContainer.appendChild(checkbox);
285
+ checkboxContainer.appendChild(label);
286
+ metamagicSection.appendChild(checkboxContainer);
287
+
288
+ metamagicCheckboxes.push(checkbox);
289
+ });
290
+
291
+ modal.appendChild(metamagicSection);
292
+ }
293
+
294
+ // Track whether spell has been cast (for attack spells)
295
+ let spellCast = false;
296
+ let usedSlot = null;
297
+
298
+ // Check if spell has both attack and damage options
299
+ const hasAttack = options.some(opt => opt.type === 'attack');
300
+ const hasDamage = options.some(opt => opt.type === 'damage' || opt.type === 'healing');
301
+
302
+ // Options container (spell action buttons)
303
+ const optionsContainer = document.createElement('div');
304
+ optionsContainer.style.cssText = 'display: flex; flex-direction: column; gap: 12px;';
305
+
306
+ // Helper function to get resolved label for an option based on slot level
307
+ function getResolvedLabel(option, selectedSlotLevel) {
308
+ if (option.type === 'attack') {
309
+ return option.label; // Attack doesn't change with slot level
310
+ }
311
+
312
+ // Get the formula for this option
313
+ let formula = option.type === 'lifesteal' ? option.damageFormula : option.formula;
314
+ debug.log(`🏷️ getResolvedLabel called with formula: "${formula}", slotLevel: ${selectedSlotLevel}`);
315
+
316
+ // Replace slotLevel with actual slot level (check for null/undefined, but allow 0)
317
+ // Use case-insensitive regex to handle slotLevel, slotlevel, SlotLevel, etc.
318
+ if (selectedSlotLevel != null && formula && /slotlevel/i.test(formula)) {
319
+ const originalFormula = formula;
320
+ formula = formula.replace(/slotlevel/gi, String(selectedSlotLevel));
321
+ debug.log(` ✅ Replaced slotLevel: "${originalFormula}" -> "${formula}"`);
322
+ }
323
+
324
+ // Replace ~target.level with character level
325
+ if (formula && formula.includes('~target.level') && characterData.level) {
326
+ formula = formula.replace(/~target\.level/g, characterData.level);
327
+ }
328
+
329
+ // Resolve variables and evaluate math
330
+ formula = resolveVariablesInFormula(formula);
331
+ formula = evaluateMathInFormula(formula);
332
+ debug.log(` 📊 Final resolved formula: "${formula}"`);
333
+
334
+ // Build label based on option type
335
+ if (option.type === 'lifesteal') {
336
+ let damageTypeLabel = '';
337
+ if (option.damageType && option.damageType !== 'untyped') {
338
+ damageTypeLabel = option.damageType.charAt(0).toUpperCase() + option.damageType.slice(1);
339
+ }
340
+ return `${formula} ${damageTypeLabel} + Heal (${option.healingRatio})`;
341
+ } else if (option.type === 'damage' || option.type === 'healing' || option.type === 'temphp') {
342
+ let damageTypeLabel = '';
343
+ if (option.damageType && option.damageType !== 'untyped') {
344
+ damageTypeLabel = option.damageType.charAt(0).toUpperCase() + option.damageType.slice(1);
345
+ }
346
+ return damageTypeLabel ? `${formula} ${damageTypeLabel}` : formula;
347
+ }
348
+
349
+ return option.label;
350
+ }
351
+
352
+ // Add buttons for each option
353
+ const optionButtons = []; // Store buttons so we can update them when slot changes
354
+
355
+ // Add custom macro buttons if configured
356
+ if (hasCustomMacros) {
357
+ customMacros.buttons.forEach((customBtn, index) => {
358
+ const btn = document.createElement('button');
359
+ btn.className = 'spell-custom-macro-btn';
360
+ btn.style.cssText = `
361
+ padding: 12px 16px;
362
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
363
+ color: white;
364
+ border: 2px solid rgba(255,255,255,0.3);
365
+ border-radius: 6px;
366
+ cursor: pointer;
367
+ font-weight: bold;
368
+ font-size: 16px;
369
+ text-align: left;
370
+ transition: opacity 0.2s, transform 0.2s;
371
+ box-shadow: 0 4px 8px rgba(0,0,0,0.3);
372
+ `;
373
+ btn.innerHTML = customBtn.label;
374
+
375
+ btn.addEventListener('mouseenter', () => {
376
+ btn.style.opacity = '0.9';
377
+ btn.style.transform = 'translateY(-2px)';
378
+ });
379
+ btn.addEventListener('mouseleave', () => {
380
+ btn.style.opacity = '1';
381
+ btn.style.transform = 'translateY(0)';
382
+ });
383
+
384
+ btn.addEventListener('click', () => {
385
+ // Send custom macro to chat
386
+ const colorBanner = getColoredBanner(characterData);
387
+ const message = customBtn.macro;
388
+
389
+ const messageData = {
390
+ action: 'announceSpell',
391
+ message: message,
392
+ color: characterData.notificationColor
393
+ };
394
+
395
+ sendToRoll20(messageData);
396
+ debug.log('✅ Custom macro sent to Roll20');
397
+
398
+ showNotification(`✨ ${spell.name} - Custom Macro Sent!`, 'success');
399
+ document.body.removeChild(overlay);
400
+ });
401
+
402
+ optionsContainer.appendChild(btn);
403
+ });
404
+
405
+ // If skipNormalButtons is true, don't add normal spell option buttons
406
+ if (customMacros.skipNormalButtons) {
407
+ debug.log(`⚙️ Skipping normal spell buttons for "${spell.name}" (custom macros only)`);
408
+ // Skip the normal options.forEach below
409
+ options = [];
410
+ }
411
+ }
412
+
413
+ options.forEach(option => {
414
+ const btn = document.createElement('button');
415
+ btn.className = `spell-option-btn-${option.type}`;
416
+
417
+ // Special styling for lifesteal buttons to make them more visually distinct
418
+ const isLifesteal = option.type === 'lifesteal';
419
+ const boxShadow = isLifesteal ? 'box-shadow: 0 4px 8px rgba(0,0,0,0.3), inset 0 -2px 4px rgba(0,0,0,0.2);' : '';
420
+ const border = isLifesteal ? 'border: 2px solid rgba(255,255,255,0.3);' : 'border: none;';
421
+
422
+ btn.style.cssText = `
423
+ padding: 12px 16px;
424
+ background: ${option.color};
425
+ color: white;
426
+ ${border}
427
+ border-radius: 6px;
428
+ cursor: pointer;
429
+ font-weight: bold;
430
+ font-size: 16px;
431
+ text-align: left;
432
+ transition: opacity 0.2s, transform 0.2s;
433
+ ${boxShadow}
434
+ `;
435
+
436
+ // Set initial label (with default slot level)
437
+ const initialSlotLevel = spell.level || null;
438
+ const resolvedLabel = getResolvedLabel(option, initialSlotLevel);
439
+ const edgeCaseNote = option.edgeCaseNote ? `<div style="font-size: 0.8em; color: #666; margin-top: 2px;">${option.edgeCaseNote}</div>` : '';
440
+ btn.innerHTML = `${option.icon} ${resolvedLabel}${edgeCaseNote}`;
441
+ btn.dataset.optionIndex = optionButtons.length; // Store index for later updates
442
+
443
+ btn.addEventListener('mouseenter', () => {
444
+ btn.style.opacity = '0.9';
445
+ if (isLifesteal) btn.style.transform = 'translateY(-2px)';
446
+ });
447
+ btn.addEventListener('mouseleave', () => {
448
+ btn.style.opacity = '1';
449
+ if (isLifesteal) btn.style.transform = 'translateY(0)';
450
+ });
451
+
452
+ optionButtons.push({ button: btn, option: option });
453
+
454
+ btn.addEventListener('click', () => {
455
+ // Get selected slot level - keep in "pact:X" format for castSpell to detect Pact Magic
456
+ let selectedSlotLevel = spell.level || null;
457
+
458
+ // Check if slot level was auto-selected (Pact Magic only, no dropdown shown)
459
+ if (modal.dataset.autoSlotLevel) {
460
+ selectedSlotLevel = modal.dataset.autoSlotLevel; // Keep as "pact:X" string
461
+ } else if (slotSelect) {
462
+ selectedSlotLevel = slotSelect.value; // Keep as "pact:X" string or regular level number
463
+ }
464
+
465
+ // Get selected metamagic options
466
+ const selectedMetamagic = metamagicCheckboxes
467
+ .filter(cb => cb.checked)
468
+ .map(cb => cb.value);
469
+
470
+ // Check if we should skip slot consumption (concentration recast)
471
+ const skipSlot = skipSlotCheckbox ? skipSlotCheckbox.checked : false;
472
+
473
+ if (option.type === 'cast') {
474
+ // Cast spell only (for spells with conditional damage like Meld into Stone)
475
+ // Announce description only if not already announced AND not using concentration recast
476
+ if (!descriptionAnnounced && !skipSlot) {
477
+ announceSpellDescription(spell, selectedSlotLevel);
478
+ }
479
+
480
+ const afterCast = (spell, slot) => {
481
+ usedSlot = slot;
482
+ showNotification(`✨ ${spell.name} cast successfully!`, 'success');
483
+ };
484
+ // Description announced (if needed), don't announce again in castSpell
485
+ castSpell(spell, spellIndex, afterCast, selectedSlotLevel, selectedMetamagic, skipSlot, true);
486
+ spellCast = true;
487
+
488
+ // Disable cast button after casting
489
+ btn.disabled = true;
490
+ btn.style.opacity = '0.5';
491
+ btn.style.cursor = 'not-allowed';
492
+
493
+ // Don't close modal - allow rolling damage if needed
494
+
495
+ } else if (option.type === 'attack') {
496
+ // Cast spell + roll attack, but keep modal open
497
+ // Announce description only if not already announced AND not using concentration recast
498
+ if (!descriptionAnnounced && !skipSlot) {
499
+ announceSpellDescription(spell, selectedSlotLevel);
500
+ }
501
+
502
+ const afterCast = (spell, slot) => {
503
+ usedSlot = slot;
504
+ const attackBonus = getSpellAttackBonus();
505
+ const attackFormula = attackBonus >= 0 ? `1d20+${attackBonus}` : `1d20${attackBonus}`;
506
+ roll(`${spell.name} - Spell Attack`, attackFormula);
507
+ };
508
+ // Description announced (if needed), don't announce again in castSpell
509
+ castSpell(spell, spellIndex, afterCast, selectedSlotLevel, selectedMetamagic, skipSlot, true);
510
+ spellCast = true;
511
+
512
+ // Disable slot selection and metamagic after casting
513
+ if (slotSelect) slotSelect.disabled = true;
514
+ metamagicCheckboxes.forEach(cb => cb.disabled = true);
515
+
516
+ // Disable attack button after casting
517
+ btn.disabled = true;
518
+ btn.style.opacity = '0.5';
519
+ btn.style.cursor = 'not-allowed';
520
+
521
+ } else if (option.type === 'damage' || option.type === 'healing' || option.type === 'temphp') {
522
+ // If spell not cast yet (no attack roll), cast it first
523
+ if (!spellCast) {
524
+ // Announce description only if not already announced AND not using concentration recast
525
+ if (!descriptionAnnounced && !skipSlot) {
526
+ announceSpellDescription(spell, selectedSlotLevel);
527
+ }
528
+
529
+ const afterCast = (spell, slot) => {
530
+ usedSlot = slot;
531
+ let formula = option.formula;
532
+ let actualSlotLevel = selectedSlotLevel != null ? selectedSlotLevel : (slot && slot.level);
533
+ // Extract numeric level from "pact:X" format if needed
534
+ if (typeof actualSlotLevel === 'string' && actualSlotLevel.startsWith('pact:')) {
535
+ actualSlotLevel = parseInt(actualSlotLevel.split(':')[1]);
536
+ }
537
+ if (actualSlotLevel != null) {
538
+ formula = formula.replace(/slotlevel/gi, actualSlotLevel);
539
+ }
540
+ // Replace ~target.level with character level (for cantrips)
541
+ if (formula.includes('~target.level') && characterData.level) {
542
+ formula = formula.replace(/~target\.level/g, characterData.level);
543
+ }
544
+ formula = resolveVariablesInFormula(formula);
545
+ formula = evaluateMathInFormula(formula);
546
+
547
+ const label = option.type === 'healing' ?
548
+ `${spell.name} - Healing` :
549
+ (option.type === 'temphp' ?
550
+ `${spell.name} - Temp HP` :
551
+ `${spell.name} - Damage (${option.damageType || ''})`);
552
+ roll(label, formula);
553
+ };
554
+ // Description announced (if needed), don't announce again in castSpell
555
+ castSpell(spell, spellIndex, afterCast, selectedSlotLevel, selectedMetamagic, skipSlot, true);
556
+ } else {
557
+ // Spell already cast (via attack), just roll damage
558
+ let formula = option.formula;
559
+ let actualSlotLevel = selectedSlotLevel != null ? selectedSlotLevel : (usedSlot && usedSlot.level);
560
+ // Extract numeric level from "pact:X" format if needed
561
+ if (typeof actualSlotLevel === 'string' && actualSlotLevel.startsWith('pact:')) {
562
+ actualSlotLevel = parseInt(actualSlotLevel.split(':')[1]);
563
+ }
564
+ if (actualSlotLevel != null) {
565
+ formula = formula.replace(/slotlevel/gi, actualSlotLevel);
566
+ }
567
+ // Replace ~target.level with character level (for cantrips)
568
+ if (formula.includes('~target.level') && characterData.level) {
569
+ formula = formula.replace(/~target\.level/g, characterData.level);
570
+ }
571
+ formula = resolveVariablesInFormula(formula);
572
+ formula = evaluateMathInFormula(formula);
573
+
574
+ const label = option.type === 'healing' ?
575
+ `${spell.name} - Healing` :
576
+ (option.type === 'temphp' ?
577
+ `${spell.name} - Temp HP` :
578
+ `${spell.name} - Damage (${option.damageType || ''})`);
579
+ roll(label, formula);
580
+ }
581
+
582
+ // Close modal after rolling damage
583
+ document.body.removeChild(overlay);
584
+
585
+ } else if (option.type === 'lifesteal') {
586
+ // Lifesteal: Cast spell, roll damage, calculate and apply healing
587
+ // Announce description only if not already announced AND not using concentration recast
588
+ if (!descriptionAnnounced && !skipSlot) {
589
+ announceSpellDescription(spell, selectedSlotLevel);
590
+ }
591
+
592
+ const afterCast = (spell, slot) => {
593
+ let damageFormula = option.damageFormula;
594
+ const actualSlotLevel = selectedSlotLevel != null ? selectedSlotLevel : (slot && slot.level);
595
+ if (actualSlotLevel != null) {
596
+ damageFormula = damageFormula.replace(/slotlevel/gi, actualSlotLevel);
597
+ }
598
+ if (damageFormula.includes('~target.level') && characterData.level) {
599
+ damageFormula = damageFormula.replace(/~target\.level/g, characterData.level);
600
+ }
601
+ damageFormula = resolveVariablesInFormula(damageFormula);
602
+ damageFormula = evaluateMathInFormula(damageFormula);
603
+
604
+ // Roll damage
605
+ roll(`${spell.name} - Lifesteal Damage (${option.damageType})`, damageFormula);
606
+
607
+ // After a short delay, prompt for damage dealt to calculate healing
608
+ setTimeout(() => {
609
+ const healingText = option.healingRatio === 'half' ? 'half' : 'the full amount';
610
+ const damageDealt = prompt(`💉 Lifesteal: Enter the damage dealt\n\nYou regain HP equal to ${healingText} of the damage.`);
611
+
612
+ if (damageDealt && !isNaN(damageDealt)) {
613
+ const damage = parseInt(damageDealt);
614
+ const healing = option.healingRatio === 'half' ? Math.floor(damage / 2) : damage;
615
+
616
+ // Apply healing
617
+ const oldHP = characterData.hitPoints.current;
618
+ const maxHP = characterData.hitPoints.max;
619
+ characterData.hitPoints.current = Math.min(oldHP + healing, maxHP);
620
+ const actualHealing = characterData.hitPoints.current - oldHP;
621
+
622
+ // Reset death saves if healing from 0 HP
623
+ if (oldHP === 0 && actualHealing > 0) {
624
+ characterData.deathSaves = { successes: 0, failures: 0 };
625
+ debug.log('♻️ Death saves reset due to healing');
626
+ }
627
+
628
+ saveCharacterData();
629
+ buildSheet(characterData);
630
+
631
+ // Announce healing
632
+ const colorBanner = getColoredBanner(characterData);
633
+ const message = `&{template:default} {{name=${colorBanner}${characterData.name} - Lifesteal}} {{💉 Damage Dealt=${damage}}} {{💚 HP Regained=${actualHealing}}} {{Current HP=${characterData.hitPoints.current}/${maxHP}}}`;
634
+
635
+ const messageData = {
636
+ action: 'announceSpell',
637
+ message: message,
638
+ color: characterData.notificationColor
639
+ };
640
+
641
+ sendToRoll20(messageData);
642
+
643
+ showNotification(`💉 Lifesteal! Dealt ${damage} damage, regained ${actualHealing} HP`, 'success');
644
+ }
645
+ }, 500);
646
+ };
647
+ // Description announced (if needed), don't announce again in castSpell
648
+ castSpell(spell, spellIndex, afterCast, selectedSlotLevel, selectedMetamagic, skipSlot, true);
649
+
650
+ // Close modal after rolling
651
+ document.body.removeChild(overlay);
652
+ }
653
+ });
654
+
655
+ optionsContainer.appendChild(btn);
656
+ });
657
+
658
+ // Set up slot selection change handler to update button labels
659
+ if (slotSelect) {
660
+ const updateButtonLabels = () => {
661
+ // Handle pact magic slot format "pact:X" - extract the level number
662
+ const slotValue = slotSelect.value;
663
+ const selectedSlotLevel = slotValue.startsWith('pact:')
664
+ ? parseInt(slotValue.split(':')[1])
665
+ : parseInt(slotValue);
666
+ optionButtons.forEach(({ button, option }) => {
667
+ const resolvedLabel = getResolvedLabel(option, selectedSlotLevel);
668
+ const edgeCaseNote = option.edgeCaseNote ? `<div style="font-size: 0.8em; color: #666; margin-top: 2px;">${option.edgeCaseNote}</div>` : '';
669
+ button.innerHTML = `${option.icon} ${resolvedLabel}${edgeCaseNote}`;
670
+ });
671
+ };
672
+
673
+ // Add change event listener
674
+ slotSelect.addEventListener('change', updateButtonLabels);
675
+
676
+ // Call initially to set correct labels for default selection
677
+ updateButtonLabels();
678
+ }
679
+
680
+ // Add "Done" button if spell has attack (to close modal after attacking without rolling damage)
681
+ if (hasAttack && hasDamage) {
682
+ const doneBtn = document.createElement('button');
683
+ doneBtn.style.cssText = 'padding: 10px; background: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;';
684
+ doneBtn.textContent = 'Done';
685
+ doneBtn.addEventListener('click', () => {
686
+ document.body.removeChild(overlay);
687
+ });
688
+ optionsContainer.appendChild(doneBtn);
689
+ }
690
+
691
+ modal.appendChild(optionsContainer);
692
+
693
+ // Cancel button
694
+ const cancelBtn = document.createElement('button');
695
+ cancelBtn.style.cssText = 'margin-top: 16px; padding: 10px; background: #95a5a6; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; width: 100%;';
696
+ cancelBtn.textContent = 'Cancel';
697
+ cancelBtn.addEventListener('click', () => {
698
+ document.body.removeChild(overlay);
699
+ });
700
+
701
+ modal.appendChild(cancelBtn);
702
+ overlay.appendChild(modal);
703
+
704
+ // Close on overlay click
705
+ overlay.addEventListener('click', (e) => {
706
+ if (e.target === overlay) {
707
+ document.body.removeChild(overlay);
708
+ }
709
+ });
710
+
711
+ // Add to DOM
712
+ document.body.appendChild(overlay);
713
+ }
714
+
715
+ /**
716
+ * Handle spell option click (simplified handler)
717
+ * @param {object} spell - Spell object
718
+ * @param {number} spellIndex - Spell index
719
+ * @param {object} option - Spell option object
720
+ */
721
+ function handleSpellOption(spell, spellIndex, option) {
722
+ if (option.type === 'attack') {
723
+ // Cast spell + roll attack
724
+ const afterCast = (spell, slot) => {
725
+ const attackBonus = getSpellAttackBonus();
726
+ const attackFormula = attackBonus >= 0 ? `1d20+${attackBonus}` : `1d20${attackBonus}`;
727
+ roll(`${spell.name} - Spell Attack`, attackFormula);
728
+ };
729
+ castSpell(spell, spellIndex, afterCast);
730
+ } else if (option.type === 'damage' || option.type === 'healing') {
731
+ // Handle OR choices if present
732
+ let damageType = option.damageType;
733
+ if (option.orChoices && option.orChoices.length > 1) {
734
+ const choiceText = option.orChoices.map((c, i) => `${i + 1}. ${c.damageType}`).join('\n');
735
+ const choice = prompt(`Choose damage type for ${spell.name}:\n${choiceText}\n\nEnter number (1-${option.orChoices.length}):`);
736
+
737
+ if (choice === null) return; // User cancelled
738
+
739
+ const choiceIndex = parseInt(choice) - 1;
740
+ if (choiceIndex >= 0 && choiceIndex < option.orChoices.length) {
741
+ damageType = option.orChoices[choiceIndex].damageType;
742
+ } else {
743
+ alert(`Invalid choice. Please try again.`);
744
+ return;
745
+ }
746
+ }
747
+
748
+ // Cast spell + roll damage/healing
749
+ const afterCast = (spell, slot) => {
750
+ let formula = option.formula;
751
+ // Replace slotLevel with actual slot level (case-insensitive)
752
+ if (slot && slot.level) {
753
+ formula = formula.replace(/slotlevel/gi, slot.level);
754
+ }
755
+ // Resolve other DiceCloud variables
756
+ formula = resolveVariablesInFormula(formula);
757
+ // Evaluate simple math expressions
758
+ formula = evaluateMathInFormula(formula);
759
+
760
+ const label = option.type === 'healing' ?
761
+ `${spell.name} - Healing` :
762
+ (damageType ? `${spell.name} - Damage (${damageType})` : `${spell.name} - Damage`);
763
+ roll(label, formula);
764
+ };
765
+ castSpell(spell, spellIndex, afterCast);
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Show resource choice modal (spell slot vs class resource)
771
+ * @param {object} spell - Spell object
772
+ * @param {number} spellLevel - Spell level
773
+ * @param {number} spellSlots - Current spell slots
774
+ * @param {number} maxSlots - Maximum spell slots
775
+ * @param {Array} classResources - Array of class resources
776
+ */
777
+ function showResourceChoice(spell, spellLevel, spellSlots, maxSlots, classResources) {
778
+ // Create modal overlay
779
+ const modal = document.createElement('div');
780
+ modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 10000;';
781
+
782
+ // Create modal content
783
+ const modalContent = document.createElement('div');
784
+ modalContent.style.cssText = 'background: var(--bg-secondary); color: var(--text-primary); padding: 30px; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); max-width: 400px; width: 90%;';
785
+
786
+ let buttonsHTML = `
787
+ <h3 style="margin: 0 0 20px 0; color: var(--text-primary); text-align: center;">Cast ${spell.name}</h3>
788
+ <p style="text-align: center; color: var(--text-secondary); margin-bottom: 25px;">Choose a resource:</p>
789
+ <div style="display: flex; flex-direction: column; gap: 12px;">
790
+ `;
791
+
792
+ // Add spell slot option if available
793
+ if (spellSlots > 0) {
794
+ buttonsHTML += `
795
+ <button class="resource-choice-btn" data-type="spell-slot" data-level="${spellLevel}" style="padding: 15px; font-size: 1em; font-weight: bold; background: #9b59b6; color: white; border: 2px solid #9b59b6; border-radius: 8px; cursor: pointer; transition: all 0.2s; text-align: left;">
796
+ <div style="display: flex; justify-content: space-between; align-items: center;">
797
+ <span>Level ${spellLevel} Spell Slot</span>
798
+ <span style="background: rgba(255,255,255,0.3); padding: 4px 8px; border-radius: 4px; font-size: 0.9em;">${spellSlots}/${maxSlots}</span>
799
+ </div>
800
+ </button>
801
+ `;
802
+ }
803
+
804
+ // Add class resource options
805
+ classResources.forEach((resource, idx) => {
806
+ const colors = {
807
+ 'Ki': { bg: '#f39c12', border: '#f39c12' },
808
+ 'Sorcery Points': { bg: '#e74c3c', border: '#e74c3c' },
809
+ 'Pact Magic': { bg: '#16a085', border: '#16a085' },
810
+ 'Channel Divinity': { bg: '#3498db', border: '#3498db' }
811
+ };
812
+ const color = colors[resource.name] || { bg: '#95a5a6', border: '#95a5a6' };
813
+
814
+ buttonsHTML += `
815
+ <button class="resource-choice-btn" data-type="class-resource" data-index="${idx}" style="padding: 15px; font-size: 1em; font-weight: bold; background: ${color.bg}; color: white; border: 2px solid ${color.border}; border-radius: 8px; cursor: pointer; transition: all 0.2s; text-align: left;">
816
+ <div style="display: flex; justify-content: space-between; align-items: center;">
817
+ <span>${resource.name}</span>
818
+ <span style="background: rgba(255,255,255,0.3); padding: 4px 8px; border-radius: 4px; font-size: 0.9em;">${resource.current}/${resource.max}</span>
819
+ </div>
820
+ </button>
821
+ `;
822
+ });
823
+
824
+ buttonsHTML += `
825
+ </div>
826
+ <button id="resource-cancel" style="width: 100%; margin-top: 20px; padding: 12px; font-size: 1em; background: #95a5a6; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold;">
827
+ Cancel
828
+ </button>
829
+ `;
830
+
831
+ modalContent.innerHTML = buttonsHTML;
832
+ modal.appendChild(modalContent);
833
+ document.body.appendChild(modal);
834
+
835
+ // Add hover effects
836
+ const resourceBtns = modalContent.querySelectorAll('.resource-choice-btn');
837
+ resourceBtns.forEach(btn => {
838
+ btn.addEventListener('mouseenter', () => {
839
+ btn.style.transform = 'translateY(-2px)';
840
+ btn.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)';
841
+ });
842
+ btn.addEventListener('mouseleave', () => {
843
+ btn.style.transform = 'translateY(0)';
844
+ btn.style.boxShadow = 'none';
845
+ });
846
+
847
+ btn.addEventListener('click', () => {
848
+ const type = btn.dataset.type;
849
+
850
+ if (type === 'spell-slot') {
851
+ const level = parseInt(btn.dataset.level);
852
+ modal.remove();
853
+ // Check if they want to upcast
854
+ showUpcastChoice(spell, level);
855
+ } else if (type === 'class-resource') {
856
+ const resourceIdx = parseInt(btn.dataset.index);
857
+ const resource = classResources[resourceIdx];
858
+ modal.remove();
859
+ if (useClassResource(resource, spell)) {
860
+ announceSpellCast(spell, resource.name);
861
+ }
862
+ }
863
+ });
864
+ });
865
+
866
+ // Cancel button
867
+ document.getElementById('resource-cancel').addEventListener('click', () => {
868
+ modal.remove();
869
+ });
870
+
871
+ // Click outside to close
872
+ modal.addEventListener('click', (e) => {
873
+ if (e.target === modal) {
874
+ modal.remove();
875
+ }
876
+ });
877
+ }
878
+
879
+ /**
880
+ * Show upcast selection modal
881
+ * @param {object} spell - Spell object
882
+ * @param {number} originalLevel - Original spell level
883
+ * @param {Function} afterCast - Callback after spell is cast
884
+ */
885
+ function showUpcastChoice(spell, originalLevel, afterCast = null) {
886
+ // Get all available spell slots at this level or higher
887
+ const availableSlots = [];
888
+
889
+ // Helper to extract numeric value from DiceCloud objects
890
+ const extractNum = (val) => {
891
+ if (val === null || val === undefined) return 0;
892
+ if (typeof val === 'number') return val;
893
+ if (typeof val === 'object') {
894
+ return val.value ?? val.total ?? val.currentValue ?? 0;
895
+ }
896
+ return parseInt(val) || 0;
897
+ };
898
+
899
+ // Check for Pact Magic slots (Warlock) - these are SEPARATE from regular spell slots
900
+ const rawPactLevel = characterData.spellSlots?.pactMagicSlotLevel ||
901
+ characterData.otherVariables?.pactMagicSlotLevel ||
902
+ characterData.otherVariables?.pactSlotLevelVisible ||
903
+ characterData.otherVariables?.pactSlotLevel;
904
+ const rawPactSlots = characterData.spellSlots?.pactMagicSlots ??
905
+ characterData.otherVariables?.pactMagicSlots ??
906
+ characterData.otherVariables?.pactSlot;
907
+ const rawPactSlotsMax = characterData.spellSlots?.pactMagicSlotsMax ??
908
+ characterData.otherVariables?.pactMagicSlotsMax;
909
+
910
+ // Extract numeric values (DiceCloud stores these as objects like {value: 2})
911
+ const pactMagicSlots = extractNum(rawPactSlots);
912
+ const pactMagicSlotsMax = extractNum(rawPactSlotsMax);
913
+ const effectivePactLevel = extractNum(rawPactLevel) || (pactMagicSlotsMax > 0 ? 5 : 0);
914
+
915
+ debug.log('🔮 Pact Magic detection:', { rawPactLevel, rawPactSlots, rawPactSlotsMax, pactMagicSlots, pactMagicSlotsMax, effectivePactLevel });
916
+
917
+ // Add Pact Magic slots first if available and spell level is compatible
918
+ // Show even if depleted (current = 0) - user can still cast with GM permission
919
+ if (pactMagicSlotsMax > 0 && originalLevel <= effectivePactLevel) {
920
+ availableSlots.push({
921
+ level: effectivePactLevel,
922
+ current: pactMagicSlots,
923
+ max: pactMagicSlotsMax,
924
+ slotVar: 'pactMagicSlots',
925
+ slotMaxVar: 'pactMagicSlotsMax',
926
+ isPactMagic: true,
927
+ label: `Level ${effectivePactLevel} - Pact Magic`
928
+ });
929
+ debug.log(`🔮 Added Pact Magic to upcast options: Level ${effectivePactLevel} (${pactMagicSlots}/${pactMagicSlotsMax})`);
930
+ }
931
+
932
+ // Then check regular spell slots - show all levels with max > 0 (even if depleted)
933
+ for (let level = originalLevel; level <= 9; level++) {
934
+ const slotVar = `level${level}SpellSlots`;
935
+ const slotMaxVar = `level${level}SpellSlotsMax`;
936
+ let current = characterData.spellSlots?.[slotVar] || 0;
937
+ let max = characterData.spellSlots?.[slotMaxVar] || 0;
938
+
939
+ // Skip if this level's slots are actually Pact Magic slots (avoid duplicates)
940
+ if (pactMagicSlotsMax > 0 && level === effectivePactLevel) {
941
+ // Pact Magic is already added separately above
942
+ continue;
943
+ }
944
+
945
+ // Show slot level if character has access to it (max > 0), even if depleted
946
+ if (max > 0) {
947
+ availableSlots.push({ level, current, max, slotVar, slotMaxVar });
948
+ }
949
+ }
950
+
951
+ // Check for metamagic options
952
+ const metamagicOptions = getAvailableMetamagic();
953
+ const sorceryPoints = getSorceryPointsResource();
954
+ debug.log('🔮 Metamagic detection:', {
955
+ metamagicOptions,
956
+ sorceryPoints,
957
+ hasMetamagic: metamagicOptions.length > 0 && sorceryPoints && sorceryPoints.current > 0
958
+ });
959
+ const hasMetamagic = metamagicOptions.length > 0 && sorceryPoints && sorceryPoints.current > 0;
960
+
961
+ debug.log('🔮 Available slots for casting:', availableSlots);
962
+
963
+ // Handle case where no spell slots are available - allow casting anyway with warning
964
+ if (availableSlots.length === 0) {
965
+ const noSlotsModal = document.createElement('div');
966
+ noSlotsModal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 10000;';
967
+
968
+ const noSlotsContent = document.createElement('div');
969
+ noSlotsContent.style.cssText = 'background: var(--bg-secondary); color: var(--text-primary); padding: 30px; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); max-width: 400px; width: 90%; text-align: center;';
970
+ noSlotsContent.innerHTML = `
971
+ <h3 style="margin: 0 0 20px 0; color: #e67e22;">No Spell Slots Available</h3>
972
+ <p style="color: var(--text-secondary); margin-bottom: 20px;">You don't have any spell slots of level ${originalLevel} or higher to cast ${spell.name}.</p>
973
+ <p style="color: #95a5a6; font-size: 0.9em; margin-bottom: 20px;">You can still cast if your GM allows it - no slot will be decremented.</p>
974
+ <div style="display: flex; gap: 10px; justify-content: center;">
975
+ <button id="no-slots-cancel" style="padding: 12px 25px; background: #95a5a6; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 1em;">Cancel</button>
976
+ <button id="no-slots-cast" style="padding: 12px 25px; background: #e67e22; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 1em;">Cast Anyway</button>
977
+ </div>
978
+ `;
979
+
980
+ noSlotsModal.appendChild(noSlotsContent);
981
+ document.body.appendChild(noSlotsModal);
982
+
983
+ document.getElementById('no-slots-cancel').onclick = () => noSlotsModal.remove();
984
+ document.getElementById('no-slots-cast').onclick = () => {
985
+ noSlotsModal.remove();
986
+ // Cast without decrementing a slot - pass a fake slot with noSlotUsed flag
987
+ castWithSlot(spell, {
988
+ level: originalLevel,
989
+ current: 0,
990
+ max: 0,
991
+ slotVar: null,
992
+ noSlotUsed: true
993
+ }, [], afterCast);
994
+ };
995
+ noSlotsModal.onclick = (e) => { if (e.target === noSlotsModal) noSlotsModal.remove(); };
996
+ return;
997
+ }
998
+
999
+ // Show upcast modal
1000
+ const modal = document.createElement('div');
1001
+ modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 10000;';
1002
+
1003
+ const modalContent = document.createElement('div');
1004
+ modalContent.style.cssText = 'background: var(--bg-secondary); color: var(--text-primary); padding: 30px; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); max-width: 400px; width: 90%;';
1005
+
1006
+ let dropdownHTML = `
1007
+ <h3 style="margin: 0 0 20px 0; color: var(--text-primary); text-align: center;">Cast ${spell.name}</h3>
1008
+ <p style="text-align: center; color: var(--text-secondary); margin-bottom: 20px;">Level ${originalLevel} spell</p>
1009
+
1010
+ <div style="margin-bottom: 25px;">
1011
+ <label style="display: block; margin-bottom: 10px; font-weight: bold; color: var(--text-primary);">Spell Slot Level:</label>
1012
+ <select id="upcast-slot-select" style="width: 100%; padding: 12px; font-size: 1.1em; border: 2px solid var(--border-color); border-radius: 6px; box-sizing: border-box; background: var(--bg-tertiary); color: var(--text-primary);">
1013
+ `;
1014
+
1015
+ availableSlots.forEach((slot, index) => {
1016
+ let label;
1017
+ const depleted = slot.current <= 0;
1018
+ const depletedMarker = depleted ? ' [EMPTY]' : '';
1019
+
1020
+ if (slot.isPactMagic) {
1021
+ label = `${slot.label} - ${slot.current}/${slot.max} remaining${depletedMarker}`;
1022
+ } else if (slot.level === originalLevel) {
1023
+ label = `Level ${slot.level} (Normal) - ${slot.current}/${slot.max} remaining${depletedMarker}`;
1024
+ } else {
1025
+ label = `Level ${slot.level} (Upcast) - ${slot.current}/${slot.max} remaining${depletedMarker}`;
1026
+ }
1027
+ // Store index so we can identify Pact Magic vs regular slots
1028
+ dropdownHTML += `<option value="${index}" data-level="${slot.level}" data-pact="${slot.isPactMagic || false}" data-current="${slot.current}">${label}</option>`;
1029
+ });
1030
+
1031
+ dropdownHTML += `
1032
+ </select>
1033
+ </div>
1034
+ `;
1035
+
1036
+ // Add metamagic options if available
1037
+ if (hasMetamagic) {
1038
+ dropdownHTML += `
1039
+ <div style="margin-bottom: 20px; padding: 12px; background: #f8f9fa; border-radius: 8px; border: 2px solid #9b59b6;">
1040
+ <div style="display: flex; justify-content: space-between; align-items: center; cursor: pointer; margin-bottom: 8px;" onclick="document.getElementById('metamagic-container').style.display = document.getElementById('metamagic-container').style.display === 'none' ? 'flex' : 'none'; this.querySelector('.toggle-arrow').textContent = document.getElementById('metamagic-container').style.display === 'none' ? '▶' : '▼';">
1041
+ <label style="font-weight: bold; color: #9b59b6; cursor: pointer;">✨ Metamagic (Sorcery Points: ${sorceryPoints.current}/${sorceryPoints.max})</label>
1042
+ <span class="toggle-arrow" style="color: #9b59b6; font-size: 0.8em;">▼</span>
1043
+ </div>
1044
+ <div id="metamagic-container" style="display: flex; flex-direction: column; gap: 6px;">
1045
+ `;
1046
+
1047
+ metamagicOptions.forEach((meta, index) => {
1048
+ const cost = meta.cost === 'variable' ? calculateMetamagicCost(meta.name, originalLevel) : meta.cost;
1049
+ const canAfford = sorceryPoints.current >= cost;
1050
+ const disabledStyle = !canAfford ? 'opacity: 0.5; cursor: not-allowed;' : '';
1051
+
1052
+ dropdownHTML += `
1053
+ <label style="display: flex; align-items: center; padding: 8px; background: white; border-radius: 4px; cursor: pointer; ${disabledStyle}" title="${meta.description || ''}">
1054
+ <input type="checkbox" class="metamagic-option" data-name="${meta.name}" data-cost="${cost}" ${!canAfford ? 'disabled' : ''} style="margin-right: 8px; width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
1055
+ <span style="flex: 1; color: var(--text-primary); font-size: 0.95em;">${meta.name}</span>
1056
+ <span style="color: #9b59b6; font-weight: bold; font-size: 0.9em;">${cost} SP</span>
1057
+ </label>
1058
+ `;
1059
+ });
1060
+
1061
+ dropdownHTML += `
1062
+ </div>
1063
+ <div id="metamagic-cost" style="margin-top: 8px; text-align: right; font-weight: bold; color: var(--text-primary); font-size: 0.9em;">Total Cost: 0 SP</div>
1064
+ </div>
1065
+ `;
1066
+ }
1067
+
1068
+ dropdownHTML += `
1069
+ <div style="display: flex; gap: 10px;">
1070
+ <button id="upcast-cancel" style="flex: 1; padding: 12px; font-size: 1em; background: #95a5a6; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold;">
1071
+ Cancel
1072
+ </button>
1073
+ <button id="upcast-confirm" style="flex: 1; padding: 12px; font-size: 1em; background: #9b59b6; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold;">
1074
+ Cast Spell
1075
+ </button>
1076
+ </div>
1077
+ `;
1078
+
1079
+ modalContent.innerHTML = dropdownHTML;
1080
+ modal.appendChild(modalContent);
1081
+ document.body.appendChild(modal);
1082
+
1083
+ const selectElement = document.getElementById('upcast-slot-select');
1084
+ const confirmBtn = document.getElementById('upcast-confirm');
1085
+ const cancelBtn = document.getElementById('upcast-cancel');
1086
+
1087
+ // Track metamagic selections
1088
+ let selectedMetamagic = [];
1089
+
1090
+ if (hasMetamagic) {
1091
+ const metamagicCheckboxes = document.querySelectorAll('.metamagic-option');
1092
+ const costDisplay = document.getElementById('metamagic-cost');
1093
+
1094
+ // Update selected spell level when it changes (affects Twinned Spell cost)
1095
+ selectElement.addEventListener('change', () => {
1096
+ const selectedIndex = parseInt(selectElement.value);
1097
+ const selectedLevel = availableSlots[selectedIndex]?.level || originalLevel;
1098
+
1099
+ // Recalculate costs for variable-cost metamagic
1100
+ metamagicCheckboxes.forEach(checkbox => {
1101
+ const metaName = checkbox.dataset.name;
1102
+ const metaOption = metamagicOptions.find(m => m.name === metaName);
1103
+ if (metaOption && metaOption.cost === 'variable') {
1104
+ const newCost = calculateMetamagicCost(metaName, selectedLevel);
1105
+ checkbox.dataset.cost = newCost;
1106
+
1107
+ // Update display
1108
+ const label = checkbox.closest('label');
1109
+ const costSpan = label.querySelector('span:last-child');
1110
+ costSpan.textContent = `${newCost} SP`;
1111
+
1112
+ // Check if still affordable
1113
+ if (sorceryPoints.current < newCost && checkbox.checked) {
1114
+ checkbox.checked = false;
1115
+ }
1116
+ }
1117
+ });
1118
+
1119
+ // Update total cost
1120
+ updateMetamagicCost();
1121
+ });
1122
+
1123
+ function updateMetamagicCost() {
1124
+ let totalCost = 0;
1125
+ selectedMetamagic = [];
1126
+
1127
+ metamagicCheckboxes.forEach(checkbox => {
1128
+ if (checkbox.checked) {
1129
+ const cost = parseInt(checkbox.dataset.cost);
1130
+ totalCost += cost;
1131
+ selectedMetamagic.push({
1132
+ name: checkbox.dataset.name,
1133
+ cost: cost
1134
+ });
1135
+ }
1136
+ });
1137
+
1138
+ costDisplay.textContent = `Total Cost: ${totalCost} SP`;
1139
+
1140
+ // Disable confirm if not enough sorcery points
1141
+ if (totalCost > sorceryPoints.current) {
1142
+ confirmBtn.disabled = true;
1143
+ confirmBtn.style.opacity = '0.5';
1144
+ confirmBtn.style.cursor = 'not-allowed';
1145
+ } else {
1146
+ confirmBtn.disabled = false;
1147
+ confirmBtn.style.opacity = '1';
1148
+ confirmBtn.style.cursor = 'pointer';
1149
+ }
1150
+ }
1151
+
1152
+ metamagicCheckboxes.forEach(checkbox => {
1153
+ checkbox.addEventListener('change', updateMetamagicCost);
1154
+ });
1155
+ }
1156
+
1157
+ confirmBtn.addEventListener('click', () => {
1158
+ const selectedIndex = parseInt(selectElement.value);
1159
+ const selectedSlot = availableSlots[selectedIndex];
1160
+ debug.log(`🔮 Selected slot from upcast modal:`, selectedSlot);
1161
+
1162
+ // Check if slot is depleted
1163
+ if (selectedSlot.current <= 0) {
1164
+ // Show warning modal
1165
+ modal.remove();
1166
+
1167
+ const warnModal = document.createElement('div');
1168
+ warnModal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 10001;';
1169
+
1170
+ const warnContent = document.createElement('div');
1171
+ warnContent.style.cssText = 'background: var(--bg-secondary); color: var(--text-primary); padding: 30px; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); max-width: 400px; width: 90%; text-align: center;';
1172
+ warnContent.innerHTML = `
1173
+ <h3 style="margin: 0 0 20px 0; color: #e67e22;">No Slots Remaining</h3>
1174
+ <p style="color: var(--text-secondary); margin-bottom: 20px;">You have no ${selectedSlot.isPactMagic ? 'Pact Magic' : `Level ${selectedSlot.level}`} spell slots remaining.</p>
1175
+ <p style="color: #95a5a6; font-size: 0.9em; margin-bottom: 20px;">You can still cast if your GM allows it - no slot will be decremented.</p>
1176
+ <div style="display: flex; gap: 10px; justify-content: center;">
1177
+ <button id="warn-cancel" style="padding: 12px 25px; background: #95a5a6; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 1em;">Cancel</button>
1178
+ <button id="warn-cast" style="padding: 12px 25px; background: #e67e22; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 1em;">Cast Anyway</button>
1179
+ </div>
1180
+ `;
1181
+
1182
+ warnModal.appendChild(warnContent);
1183
+ document.body.appendChild(warnModal);
1184
+
1185
+ document.getElementById('warn-cancel').onclick = () => warnModal.remove();
1186
+ document.getElementById('warn-cast').onclick = () => {
1187
+ warnModal.remove();
1188
+ // Cast with noSlotUsed flag
1189
+ castWithSlot(spell, { ...selectedSlot, noSlotUsed: true }, selectedMetamagic, afterCast);
1190
+ };
1191
+ warnModal.onclick = (e) => { if (e.target === warnModal) warnModal.remove(); };
1192
+ return;
1193
+ }
1194
+
1195
+ modal.remove();
1196
+ castWithSlot(spell, selectedSlot, selectedMetamagic, afterCast);
1197
+ });
1198
+
1199
+ cancelBtn.addEventListener('click', () => {
1200
+ modal.remove();
1201
+ });
1202
+
1203
+ // Click outside to close
1204
+ modal.addEventListener('click', (e) => {
1205
+ if (e.target === modal) {
1206
+ modal.remove();
1207
+ }
1208
+ });
1209
+ }
1210
+
1211
+ // Export functions to globalThis
1212
+ Object.assign(globalThis, {
1213
+ showSpellModal,
1214
+ handleSpellOption,
1215
+ showResourceChoice,
1216
+ showUpcastChoice
1217
+ });
1218
+
1219
+ console.log('✅ Spell Modals module loaded');
1220
+
1221
+ })();