@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,602 @@
1
+ /**
2
+ * Spell Cards Module
3
+ *
4
+ * Handles spell card creation, validation, and option generation.
5
+ * - Creates interactive spell card UI elements
6
+ * - Validates spell data structure
7
+ * - Generates spell options (attack, damage, healing, etc.)
8
+ *
9
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
10
+ *
11
+ * TODO: Refactor inline styles to CSS classes to fix CSP warnings
12
+ * - Replace element.style assignments with classList.add()
13
+ * - Replace inline event handlers with addEventListener()
14
+ * - This will eliminate ~100+ CSP warnings in the browser console
15
+ */
16
+
17
+ (function() {
18
+ 'use strict';
19
+
20
+ /**
21
+ * Create a spell card UI element
22
+ * @param {object} spell - Spell object
23
+ * @param {number} index - Spell index
24
+ * @returns {HTMLElement} Spell card element
25
+ */
26
+ function createSpellCard(spell, index) {
27
+ const card = document.createElement('div');
28
+ card.className = 'spell-card';
29
+
30
+ const header = document.createElement('div');
31
+ header.className = 'spell-header';
32
+
33
+ // Build tags string
34
+ let tags = '';
35
+ if (spell.concentration) {
36
+ tags += '<span class="concentration-tag">🧠 Concentration</span>';
37
+ }
38
+ if (spell.ritual) {
39
+ tags += `<button class="ritual-tag ritual-cast-btn" data-spell-index="${index}" style="padding: 4px 8px; background: #8e44ad; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; transition: background 0.2s;" onmouseover="this.style.background='#9b59b6'" onmouseout="this.style.background='#8e44ad'" title="Cast as ritual (no spell slot required)">📖 Ritual</button>`;
40
+ }
41
+
42
+ // All spells get a single Cast button that opens a modal with options
43
+ const castButtonHTML = `<button class="cast-spell-modal-btn" data-spell-index="${index}" style="padding: 6px 12px; background: #9b59b6; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">✨ Cast</button>`;
44
+
45
+ // Custom macro override button (for magic items and custom spells) - only shown if setting is enabled
46
+ const overrideButtonHTML = (typeof showCustomMacroButtons !== 'undefined' && showCustomMacroButtons)
47
+ ? `<button class="custom-macro-btn" data-spell-index="${index}" style="padding: 6px 12px; background: #34495e; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;" title="Configure custom macros for this spell">⚙️</button>`
48
+ : '';
49
+
50
+ header.innerHTML = `
51
+ <div>
52
+ <span style="font-weight: bold;">${spell.name}</span>
53
+ ${spell.level ? `<span style="margin-left: 10px; color: #666;">Level ${spell.level}</span>` : ''}
54
+ ${tags}
55
+ </div>
56
+ <div style="display: flex; gap: 8px;">
57
+ ${castButtonHTML}
58
+ ${overrideButtonHTML}
59
+ <button class="toggle-btn">▼ Details</button>
60
+ </div>
61
+ `;
62
+
63
+ const desc = document.createElement('div');
64
+ desc.className = 'spell-description';
65
+ desc.id = `spell-desc-${index}`;
66
+
67
+ const debug = window.debug || console;
68
+
69
+ // Debug spell data
70
+ if (spell.attackRoll || spell.damage) {
71
+ debug.log(`📝 Spell "${spell.name}" has attack/damage:`, { attackRoll: spell.attackRoll, damage: spell.damage, damageType: spell.damageType });
72
+ }
73
+
74
+ // Resolve DiceCloud inline calculations ({#spellList.dc}, {max(slotLevel,1)},
75
+ // {120 * (1 + spellSniper)}, etc.) in any displayed text so players see real
76
+ // numbers instead of raw template syntax. Falls back to the raw text if the
77
+ // resolver isn't loaded.
78
+ const rv = (text) => (text && typeof resolveVariablesInFormula === 'function')
79
+ ? resolveVariablesInFormula(String(text))
80
+ : text;
81
+
82
+ // Build full description from summary and description fields
83
+ let fullDescription = '';
84
+ if (spell.summary && spell.description) {
85
+ fullDescription = `${rv(spell.summary)}<br><br>${rv(spell.description)}`;
86
+ } else if (spell.summary) {
87
+ fullDescription = rv(spell.summary);
88
+ } else if (spell.description) {
89
+ fullDescription = rv(spell.description);
90
+ }
91
+
92
+ desc.innerHTML = `
93
+ ${spell.castingTime ? `<div><strong>Casting Time:</strong> ${rv(spell.castingTime)}</div>` : ''}
94
+ ${spell.range ? `<div><strong>Range:</strong> ${rv(spell.range)}</div>` : ''}
95
+ ${spell.components ? `<div><strong>Components:</strong> ${rv(spell.components)}</div>` : ''}
96
+ ${spell.duration ? `<div><strong>Duration:</strong> ${rv(spell.duration)}</div>` : ''}
97
+ ${spell.school ? `<div><strong>School:</strong> ${spell.school}</div>` : ''}
98
+ ${spell.source ? `<div><strong>Source:</strong> ${spell.source}</div>` : ''}
99
+ ${fullDescription ? `<div style="margin-top: 10px;"><strong>Summary:</strong> ${fullDescription}</div>` : ''}
100
+ ${spell.formula ? `<button class="roll-btn">🎲 Roll ${spell.formula}</button>` : ''}
101
+ `;
102
+
103
+ // Toggle functionality
104
+ const toggleBtn = header.querySelector('.toggle-btn');
105
+ header.addEventListener('click', (e) => {
106
+ if (!e.target.classList.contains('roll-btn') &&
107
+ !e.target.classList.contains('cast-spell-modal-btn') &&
108
+ !e.target.classList.contains('ritual-cast-btn')) {
109
+ desc.classList.toggle('expanded');
110
+ toggleBtn.textContent = desc.classList.contains('expanded') ? '▲ Hide' : '▼ Details';
111
+ }
112
+ });
113
+
114
+ // Roll button
115
+ const rollBtn = desc.querySelector('.roll-btn');
116
+ if (rollBtn && spell.formula && typeof roll === 'function') {
117
+ rollBtn.addEventListener('click', (e) => {
118
+ e.stopPropagation();
119
+ roll(spell.name, spell.formula);
120
+ });
121
+ }
122
+
123
+ // Ritual cast button
124
+ const ritualBtn = header.querySelector('.ritual-cast-btn');
125
+ if (ritualBtn) {
126
+ ritualBtn.addEventListener('click', (e) => {
127
+ e.stopPropagation();
128
+ debug.log(`📖 Ritual cast button clicked for: ${spell.name}`);
129
+
130
+ // Cast as ritual - no spell slot consumed
131
+ if (typeof announceSpellDescription === 'function') {
132
+ // Announce with ritual note
133
+ const ritualSpell = { ...spell, name: `${spell.name} (Ritual)` };
134
+ announceSpellDescription(ritualSpell);
135
+ }
136
+
137
+ // Cast spell with skipSlotConsumption = true for rituals
138
+ if (typeof castSpell === 'function') {
139
+ castSpell(spell, index, null, spell.level, [], true, true); // skipSlotConsumption = true, skipAnnouncement = true
140
+ } else {
141
+ debug.error('❌ castSpell function not available');
142
+ if (typeof showNotification === 'function') {
143
+ showNotification('❌ Cannot cast spell', 'error');
144
+ }
145
+ }
146
+ });
147
+ }
148
+
149
+ // Cast spell modal button
150
+ const castModalBtn = header.querySelector('.cast-spell-modal-btn');
151
+ if (castModalBtn) {
152
+ castModalBtn.addEventListener('click', (e) => {
153
+ e.stopPropagation();
154
+
155
+ // Check for Divine Smite special handling
156
+ if (spell.name.toLowerCase().includes('divine smite')) {
157
+ debug.log(`⚡ Divine Smite cast button clicked: ${spell.name}, showing custom modal`);
158
+ if (typeof announceSpellDescription === 'function') {
159
+ announceSpellDescription(spell);
160
+ }
161
+ if (typeof showDivineSmiteModal === 'function') {
162
+ showDivineSmiteModal(spell);
163
+ }
164
+ return;
165
+ }
166
+
167
+ // Check for Lay on Hands: Heal special handling
168
+ const normalizedSpellName = spell.name.toLowerCase()
169
+ .replace(/[^a-z0-9\s:]/g, '') // Remove special chars except colon and space
170
+ .replace(/\s+/g, ' ') // Normalize spaces
171
+ .trim();
172
+ const normalizedSearch = 'lay on hands: heal';
173
+
174
+ if (normalizedSpellName === normalizedSearch) {
175
+ debug.log(`💚 Lay on Hands: Heal cast button clicked: ${spell.name}, showing custom modal`);
176
+ debug.log(`💚 Normalized match: "${normalizedSpellName}" === "${normalizedSearch}"`);
177
+ if (typeof announceSpellDescription === 'function') {
178
+ announceSpellDescription(spell);
179
+ }
180
+ if (typeof getLayOnHandsResource === 'function') {
181
+ const layOnHandsPool = getLayOnHandsResource();
182
+ if (layOnHandsPool && typeof showLayOnHandsModal === 'function') {
183
+ showLayOnHandsModal(layOnHandsPool);
184
+ } else if (typeof showNotification === 'function') {
185
+ showNotification('❌ No Lay on Hands pool resource found', 'error');
186
+ }
187
+ }
188
+ return;
189
+ }
190
+
191
+ // Fallback: Catch ANY Lay on Hands action for debugging
192
+ if (spell.name.toLowerCase().includes('lay on hands')) {
193
+ debug.log(`🚨 FALLBACK: Caught Lay on Hands spell: "${spell.name}"`);
194
+ debug.log(`🚨 This spell didn't match 'lay on hands: heal' but contains 'lay on hands'`);
195
+ debug.log(`🚨 Showing modal anyway for debugging`);
196
+ if (typeof announceSpellDescription === 'function') {
197
+ announceSpellDescription(spell);
198
+ }
199
+ if (typeof getLayOnHandsResource === 'function') {
200
+ const layOnHandsPool = getLayOnHandsResource();
201
+ if (layOnHandsPool && typeof showLayOnHandsModal === 'function') {
202
+ showLayOnHandsModal(layOnHandsPool);
203
+ } else if (typeof showNotification === 'function') {
204
+ showNotification('❌ No Lay on Hands pool resource found', 'error');
205
+ }
206
+ }
207
+ return;
208
+ }
209
+
210
+ const spellOptionsResult = getSpellOptions(spell);
211
+ const options = spellOptionsResult.options;
212
+
213
+ // Check if this is a "too complicated" spell that should only announce
214
+ if (spellOptionsResult.skipNormalButtons) {
215
+ if (typeof announceSpellDescription === 'function') {
216
+ announceSpellDescription(spell);
217
+ }
218
+ if (typeof castSpell === 'function') {
219
+ castSpell(spell, index, null, null, [], false, true); // skipAnnouncement = true
220
+ }
221
+ return;
222
+ }
223
+
224
+ if (options.length === 0) {
225
+ // No rolls - announce description and cast immediately with base spell level
226
+ if (typeof announceSpellDescription === 'function') {
227
+ announceSpellDescription(spell);
228
+ }
229
+ if (typeof castSpell === 'function') {
230
+ // Auto-select base spell level for direct casting (so slots decrement)
231
+ const baseLevel = spell.level && spell.level > 0 ? parseInt(spell.level) : null;
232
+ castSpell(spell, index, null, baseLevel, [], false, true); // skipAnnouncement = true
233
+ }
234
+ } else {
235
+ // Has rolls - show modal with options
236
+ debug.log(`✨ Spell "${spell.name}" has ${options.length} options, showing modal`);
237
+
238
+ // Check if concentration recast option will exist in modal
239
+ const hasConcentrationRecast = spell.concentration && typeof concentratingSpell !== 'undefined' && concentratingSpell === spell.name;
240
+
241
+ if (!hasConcentrationRecast) {
242
+ // No concentration recast option - announce description immediately
243
+ if (typeof announceSpellDescription === 'function') {
244
+ announceSpellDescription(spell);
245
+ } else {
246
+ debug.warn('⚠️ announceSpellDescription not available');
247
+ }
248
+
249
+ if (typeof showSpellModal === 'function') {
250
+ debug.log('📋 Calling showSpellModal with descriptionAnnounced=true');
251
+ try {
252
+ showSpellModal(spell, index, options, true); // descriptionAnnounced = true
253
+ } catch (error) {
254
+ debug.error('❌ Error calling showSpellModal:', error);
255
+ }
256
+ } else {
257
+ debug.error('❌ showSpellModal function not available!');
258
+ }
259
+ } else {
260
+ // Has concentration recast - announce from modal button handlers
261
+ if (typeof showSpellModal === 'function') {
262
+ debug.log('📋 Calling showSpellModal with descriptionAnnounced=false (concentration recast)');
263
+ try {
264
+ showSpellModal(spell, index, options, false); // descriptionAnnounced = false
265
+ } catch (error) {
266
+ debug.error('❌ Error calling showSpellModal:', error);
267
+ }
268
+ } else {
269
+ debug.error('❌ showSpellModal function not available!');
270
+ }
271
+ }
272
+ }
273
+ });
274
+ }
275
+
276
+ // Custom macro override button
277
+ const customMacroBtn = header.querySelector('.custom-macro-btn');
278
+ if (customMacroBtn && typeof showCustomMacroModal === 'function') {
279
+ customMacroBtn.addEventListener('click', (e) => {
280
+ e.stopPropagation();
281
+ showCustomMacroModal(spell, index);
282
+ });
283
+ }
284
+
285
+ card.appendChild(header);
286
+ card.appendChild(desc);
287
+ return card;
288
+ }
289
+
290
+ /**
291
+ * Validate spell data and log any issues
292
+ * Cross-checks parsed data against spell description
293
+ * @param {object} spell - Spell object
294
+ * @returns {object} Validation result with valid flag, issues, and warnings
295
+ */
296
+ function validateSpellData(spell) {
297
+ const issues = [];
298
+ const warnings = [];
299
+
300
+ // Check if spell has children data
301
+ if (!spell.damageRolls && !spell.attackRoll) {
302
+ console.log(`ℹ️ Spell "${spell.name}" has no attack or damage data (utility spell)`);
303
+ return { valid: true, issues: [], warnings: [] };
304
+ }
305
+
306
+ // Validate attack roll
307
+ if (spell.attackRoll && spell.attackRoll !== '(none)') {
308
+ if (typeof spell.attackRoll !== 'string' || spell.attackRoll.trim() === '') {
309
+ issues.push(`Attack roll is invalid: ${spell.attackRoll}`);
310
+ }
311
+ }
312
+
313
+ // Validate damage rolls
314
+ if (spell.damageRolls && Array.isArray(spell.damageRolls)) {
315
+ spell.damageRolls.forEach((roll, index) => {
316
+ if (!roll.damage) {
317
+ issues.push(`Damage roll ${index} missing formula`);
318
+ } else if (typeof roll.damage !== 'string' || roll.damage.trim() === '') {
319
+ issues.push(`Damage roll ${index} has invalid formula: ${roll.damage}`);
320
+ }
321
+
322
+ if (!roll.damageType) {
323
+ warnings.push(`Damage roll ${index} missing damage type (will show as "untyped")`);
324
+ }
325
+
326
+ // Check for dice notation
327
+ const hasDice = /d\d+/i.test(roll.damage);
328
+ if (!hasDice) {
329
+ warnings.push(`Damage roll "${roll.damage}" doesn't contain dice notation - might be a variable reference`);
330
+ }
331
+ });
332
+ }
333
+
334
+ // Cross-check against description
335
+ const description = (spell.description || '').toLowerCase();
336
+ const summary = (spell.summary || '').toLowerCase();
337
+ const fullText = `${summary} ${description}`;
338
+
339
+ if (fullText) {
340
+ // Check for attack mention (use word boundaries to avoid false positives like Shield's "triggering attack")
341
+ const hasAttackMention = /\b(spell attack|attack roll)\b/i.test(fullText);
342
+ const hasAttackData = spell.attackRoll && spell.attackRoll !== '(none)';
343
+
344
+ if (hasAttackMention && !hasAttackData) {
345
+ warnings.push(`Description mentions attack but no attack roll found`);
346
+ } else if (!hasAttackMention && hasAttackData) {
347
+ warnings.push(`Has attack roll but description doesn't mention attack`);
348
+ }
349
+
350
+ // Check for damage mention
351
+ const damageMentions = fullText.match(/(\d+d\d+)/g);
352
+ const hasDamageMention = damageMentions && damageMentions.length > 0;
353
+ const hasDamageData = spell.damageRolls && spell.damageRolls.length > 0;
354
+
355
+ if (hasDamageMention && !hasDamageData) {
356
+ warnings.push(`Description mentions ${damageMentions.join(', ')} but no damage rolls found`);
357
+ } else if (hasDamageData && !hasDamageMention) {
358
+ // This is fine - description might use variables like "spell level" instead of exact dice
359
+ console.log(`ℹ️ "${spell.name}" has ${spell.damageRolls.length} damage rolls but description doesn't show explicit dice`);
360
+ }
361
+ }
362
+
363
+ if (issues.length > 0) {
364
+ console.warn(`❌ Validation issues for spell "${spell.name}":`, issues);
365
+ }
366
+
367
+ if (warnings.length > 0) {
368
+ console.warn(`⚠️ Validation warnings for spell "${spell.name}":`, warnings);
369
+ }
370
+
371
+ if (issues.length === 0 && warnings.length === 0) {
372
+ console.log(`✅ Spell "${spell.name}" validated successfully`);
373
+ }
374
+
375
+ return { valid: issues.length === 0, issues, warnings };
376
+ }
377
+
378
+ /**
379
+ * Get available spell options (attack/damage rolls)
380
+ * @param {object} spell - Spell object
381
+ * @returns {object} Options object with options array and skipNormalButtons flag
382
+ */
383
+ function getSpellOptions(spell) {
384
+ // Validate spell data first
385
+ const validation = validateSpellData(spell);
386
+
387
+ // Detailed debug logging to trace damage data
388
+ console.log(`🔮 getSpellOptions for "${spell.name}":`, {
389
+ attackRoll: spell.attackRoll,
390
+ damageRolls: spell.damageRolls,
391
+ damageRollsLength: spell.damageRolls ? spell.damageRolls.length : 'undefined',
392
+ damageRollsContent: JSON.stringify(spell.damageRolls),
393
+ concentration: spell.concentration
394
+ });
395
+
396
+ const options = [];
397
+
398
+ // Check for attack (exclude defensive spells which should never have attack button)
399
+ const spellNameLower = (spell.name || '').toLowerCase();
400
+ const isDefensiveSpell = spellNameLower === 'shield' ||
401
+ spellNameLower.startsWith('shield ') ||
402
+ spellNameLower === 'absorb elements' ||
403
+ spellNameLower === 'counterspell';
404
+
405
+ if (spell.attackRoll && spell.attackRoll !== '(none)' && !isDefensiveSpell) {
406
+ // Handle special flag from dicecloud.js that indicates we should use spell attack bonus
407
+ let attackFormula = spell.attackRoll;
408
+ if (attackFormula === 'use_spell_attack_bonus' && typeof getSpellAttackBonus === 'function') {
409
+ const attackBonus = getSpellAttackBonus();
410
+ attackFormula = attackBonus >= 0 ? `1d20+${attackBonus}` : `1d20${attackBonus}`;
411
+ }
412
+
413
+ options.push({
414
+ type: 'attack',
415
+ label: '⚔️ Spell Attack',
416
+ formula: attackFormula,
417
+ icon: '⚔️',
418
+ color: '#e74c3c'
419
+ });
420
+ }
421
+
422
+ // Check for damage/healing rolls
423
+ if (spell.damageRolls && spell.damageRolls.length > 0) {
424
+ // Handle lifesteal spells specially (damage + healing based on damage dealt)
425
+ if (spell.isLifesteal) {
426
+ const damageRoll = spell.damageRolls.find(r => r.damageType && r.damageType.toLowerCase() !== 'healing');
427
+ const healingRoll = spell.damageRolls.find(r => r.damageType && r.damageType.toLowerCase() === 'healing');
428
+
429
+ if (damageRoll && healingRoll) {
430
+ // Resolve formula for display
431
+ let displayFormula = damageRoll.damage;
432
+ if (displayFormula.includes('~target.level') && characterData.level) {
433
+ displayFormula = displayFormula.replace(/~target\.level/g, characterData.level);
434
+ }
435
+ if (typeof resolveVariablesInFormula === 'function') {
436
+ displayFormula = resolveVariablesInFormula(displayFormula);
437
+ }
438
+ if (typeof evaluateMathInFormula === 'function') {
439
+ displayFormula = evaluateMathInFormula(displayFormula);
440
+ }
441
+
442
+ // Format damage type
443
+ let damageTypeLabel = '';
444
+ if (damageRoll.damageType && damageRoll.damageType !== 'untyped') {
445
+ damageTypeLabel = damageRoll.damageType.charAt(0).toUpperCase() + damageRoll.damageType.slice(1);
446
+ }
447
+
448
+ // Check healing formula to determine healing ratio
449
+ const healingFormula = healingRoll.damage.toLowerCase();
450
+ let healingRatio = 'full';
451
+ if (healingFormula.includes('/ 2') || healingFormula.includes('*0.5') || healingFormula.includes('half')) {
452
+ healingRatio = 'half';
453
+ }
454
+
455
+ options.push({
456
+ type: 'lifesteal',
457
+ label: `${displayFormula} ${damageTypeLabel} + Heal (${healingRatio})`,
458
+ damageFormula: damageRoll.damage,
459
+ healingFormula: healingRoll.damage,
460
+ damageType: damageRoll.damageType,
461
+ healingRatio: healingRatio,
462
+ icon: '💉',
463
+ color: 'linear-gradient(135deg, #c0392b 0%, #27ae60 100%)'
464
+ });
465
+ }
466
+ } else {
467
+ // Normal spells - show separate buttons for each damage/healing type
468
+ spell.damageRolls.forEach((roll, index) => {
469
+ // Skip rolls that are part of an OR group (they'll be represented by the main roll)
470
+ if (roll.isOrGroupMember) {
471
+ return;
472
+ }
473
+
474
+ const isHealing = roll.damageType && roll.damageType.toLowerCase() === 'healing';
475
+ const isTempHP = roll.damageType && (
476
+ roll.damageType.toLowerCase() === 'temphp' ||
477
+ roll.damageType.toLowerCase() === 'temporary' ||
478
+ roll.damageType.toLowerCase().includes('temp')
479
+ );
480
+
481
+ // Resolve non-slot-dependent variables for display (character level, ability mods, etc.)
482
+ // Keep slotLevel as-is since we don't know what slot will be used yet
483
+ let displayFormula = roll.damage || roll.formula || '';
484
+ let actualFormula = roll.damage || roll.formula || ''; // Keep separate from display formula
485
+
486
+ // Skip if no formula
487
+ if (!displayFormula) {
488
+ debug.warn(`⚠️ Skipping damage roll for "${spell.name}" - no formula found`);
489
+ return;
490
+ }
491
+
492
+ // Apply warlock invocation modifications to damage
493
+ if (typeof getActiveInvocations === 'function' && typeof applyInvocationToDamage === 'function') {
494
+ const activeInvocations = getActiveInvocations(characterData);
495
+ if (activeInvocations.length > 0) {
496
+ const modified = applyInvocationToDamage(spell.name, displayFormula, activeInvocations, characterData);
497
+ if (modified.modified) {
498
+ displayFormula = modified.display;
499
+ actualFormula = modified.formula;
500
+ }
501
+ }
502
+ }
503
+
504
+ // Replace ~target.level with character level (for cantrips like Toll the Dead)
505
+ if (displayFormula && displayFormula.includes('~target.level') && characterData.level) {
506
+ displayFormula = displayFormula.replace(/~target\.level/g, characterData.level);
507
+ actualFormula = actualFormula.replace(/~target\.level/g, characterData.level);
508
+ }
509
+
510
+ if (typeof resolveVariablesInFormula === 'function') {
511
+ displayFormula = resolveVariablesInFormula(displayFormula);
512
+ }
513
+ if (typeof evaluateMathInFormula === 'function') {
514
+ displayFormula = evaluateMathInFormula(displayFormula);
515
+ }
516
+
517
+ // If this roll has OR choices, create separate buttons for each choice
518
+ if (roll.orChoices && roll.orChoices.length > 1) {
519
+ roll.orChoices.forEach(choice => {
520
+ // Format damage type nicely
521
+ let damageTypeLabel = '';
522
+ if (choice.damageType && choice.damageType !== 'untyped') {
523
+ damageTypeLabel = choice.damageType.charAt(0).toUpperCase() + choice.damageType.slice(1);
524
+ }
525
+
526
+ const label = damageTypeLabel ? `${displayFormula} ${damageTypeLabel}` : displayFormula;
527
+
528
+ const choiceIsTempHP = choice.damageType === 'temphp' || choice.damageType === 'temporary' ||
529
+ (choice.damageType && choice.damageType.toLowerCase().includes('temp'));
530
+
531
+ options.push({
532
+ type: choiceIsTempHP ? 'temphp' : (isHealing ? 'healing' : 'damage'),
533
+ label: label,
534
+ formula: actualFormula, // Use actualFormula which includes Agonizing Blast modifier
535
+ damageType: choice.damageType,
536
+ index: index,
537
+ icon: choiceIsTempHP ? '🛡️' : (isHealing ? '💚' : '💥'),
538
+ color: choiceIsTempHP ? '#3498db' : (isHealing ? '#27ae60' : '#e67e22')
539
+ });
540
+ });
541
+ } else {
542
+ // Single damage type - create one button
543
+ // Format damage type nicely
544
+ let damageTypeLabel = '';
545
+ if (roll.damageType && roll.damageType !== 'untyped') {
546
+ // Capitalize first letter
547
+ damageTypeLabel = roll.damageType.charAt(0).toUpperCase() + roll.damageType.slice(1);
548
+ }
549
+
550
+ // Build label: formula + damage type
551
+ const label = damageTypeLabel ? `${displayFormula} ${damageTypeLabel}` : displayFormula;
552
+
553
+ options.push({
554
+ type: isTempHP ? 'temphp' : (isHealing ? 'healing' : 'damage'),
555
+ label: label,
556
+ formula: actualFormula, // Use actualFormula which includes Agonizing Blast modifier
557
+ damageType: roll.damageType,
558
+ index: index,
559
+ icon: isTempHP ? '🛡️' : (isHealing ? '💚' : '💥'),
560
+ color: isTempHP ? '#3498db' : (isHealing ? '#27ae60' : '#e67e22')
561
+ });
562
+ }
563
+ });
564
+ }
565
+ }
566
+
567
+ // Log options before edge case modifications
568
+ console.log(`📋 getSpellOptions "${spell.name}" - options before edge cases:`, options.map(o => `${o.type}: ${o.label}`));
569
+
570
+ // If spell has BOTH attack AND damage options, add a "Cast Spell" button first
571
+ // This allows users to cast the spell (consume slot) without immediately rolling attack or damage
572
+ const hasAttack = options.some(opt => opt.type === 'attack');
573
+ const hasDamage = options.some(opt => opt.type === 'damage' || opt.type === 'healing');
574
+ if (hasAttack && hasDamage) {
575
+ options.unshift({
576
+ type: 'cast',
577
+ label: 'Cast Spell',
578
+ icon: '✨',
579
+ color: '#9b59b6',
580
+ edgeCaseNote: 'Cast without rolling - then click Attack or Damage'
581
+ });
582
+ }
583
+
584
+ // Apply edge case modifications
585
+ const result = typeof applyEdgeCaseModifications === 'function'
586
+ ? applyEdgeCaseModifications(spell, options)
587
+ : { options, skipNormalButtons: false };
588
+
589
+ console.log(`📋 getSpellOptions "${spell.name}" - final options:`, result.options?.map(o => `${o.type}: ${o.label}`), 'skipNormalButtons:', result.skipNormalButtons);
590
+ return result;
591
+ }
592
+
593
+ // Export functions to globalThis
594
+ Object.assign(globalThis, {
595
+ createSpellCard,
596
+ validateSpellData,
597
+ getSpellOptions
598
+ });
599
+
600
+ console.log('✅ Spell Cards module loaded');
601
+
602
+ })();