@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,1264 @@
1
+ /**
2
+ * Feature Modals Module
3
+ *
4
+ * Handles special feature modals (Inspiration, Divine Smite, Lay on Hands, Lucky).
5
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
6
+ *
7
+ * Functions exported to globalThis:
8
+ * - toggleInspiration()
9
+ * - showGainInspirationModal()
10
+ * - showUseInspirationModal()
11
+ * - showDivineSmiteModal(spell)
12
+ * - showLayOnHandsModal(layOnHandsPool)
13
+ * - showLuckyModal()
14
+ * - rollLuckyDie(type)
15
+ * - getLuckyResource()
16
+ * - useLuckyPoint()
17
+ * - getLayOnHandsResource()
18
+ * - createThemedModal()
19
+ */
20
+
21
+ (function() {
22
+ 'use strict';
23
+
24
+ // ===== HELPER FUNCTIONS =====
25
+
26
+ /**
27
+ * Create a theme-aware modal
28
+ */
29
+ function createThemedModal() {
30
+ const modal = document.createElement('div');
31
+ 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;';
32
+
33
+ const modalContent = document.createElement('div');
34
+ modalContent.className = 'owlcloud-modal-content';
35
+
36
+ // Check for system theme preference
37
+ const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
38
+ const isDarkTheme = document.body.classList.contains('dark-theme') ||
39
+ document.body.classList.contains('theme-dark') ||
40
+ prefersDark;
41
+
42
+ // Apply theme class
43
+ if (isDarkTheme) {
44
+ modalContent.classList.add('theme-dark');
45
+ } else {
46
+ modalContent.classList.add('theme-light');
47
+ }
48
+
49
+ // Base styling (theme-specific colors will be in CSS)
50
+ modalContent.style.cssText = 'padding: 30px; border-radius: 12px; max-width: 500px; width: 90%; text-align: center; box-shadow: 0 10px 30px rgba(0,0,0,0.3); border: 1px solid #e1e8ed;';
51
+
52
+ return { modal, modalContent, isDarkTheme };
53
+ }
54
+
55
+ /**
56
+ * Get Lay on Hands resource from character data
57
+ */
58
+ function getLayOnHandsResource() {
59
+ // Requires characterData to be available from global scope
60
+ if (typeof characterData === 'undefined' || !characterData || !characterData.resources) return null;
61
+
62
+ // Find Lay on Hands pool in resources
63
+ const layOnHandsResource = characterData.resources.find(r => {
64
+ const lowerName = r.name.toLowerCase();
65
+ return lowerName.includes('lay on hands') || lowerName === 'lay on hands pool';
66
+ });
67
+
68
+ return layOnHandsResource || null;
69
+ }
70
+
71
+ /**
72
+ * Get Lucky resource from character data
73
+ */
74
+ function getLuckyResource() {
75
+ // Requires characterData to be available from global scope
76
+ if (typeof characterData === 'undefined' || !characterData || !characterData.resources) {
77
+ debug.log('🎖️ No characterData or resources for Lucky detection');
78
+ return null;
79
+ }
80
+
81
+ // Find Lucky points in resources (flexible matching)
82
+ const luckyResource = characterData.resources.find(r => {
83
+ const lowerName = r.name.toLowerCase().trim();
84
+ return (
85
+ lowerName.includes('lucky point') ||
86
+ lowerName.includes('luck point') ||
87
+ lowerName === 'lucky points' ||
88
+ lowerName === 'lucky'
89
+ );
90
+ });
91
+
92
+ if (luckyResource) {
93
+ debug.log(`🎖️ Found Lucky resource: ${luckyResource.name} (${luckyResource.current}/${luckyResource.max})`);
94
+ } else {
95
+ debug.log('🎖️ No Lucky resource found in character data');
96
+ }
97
+
98
+ return luckyResource;
99
+ }
100
+
101
+ /**
102
+ * Use a Lucky point
103
+ */
104
+ function useLuckyPoint() {
105
+ debug.log('🎖️ useLuckyPoint called');
106
+ const luckyResource = getLuckyResource();
107
+ debug.log('🎖️ Lucky resource found:', luckyResource);
108
+
109
+ if (!luckyResource) {
110
+ debug.error('❌ No Lucky resource found');
111
+ return false;
112
+ }
113
+
114
+ if (luckyResource.current <= 0) {
115
+ debug.error(`❌ No Lucky points available (current: ${luckyResource.current})`);
116
+ return false;
117
+ }
118
+
119
+ // Decrement Lucky points
120
+ const oldCurrent = luckyResource.current;
121
+ luckyResource.current--;
122
+ debug.log(`🎖️ Used Lucky point. ${oldCurrent} → ${luckyResource.current}/${luckyResource.max}`);
123
+
124
+ // Save character data to preserve state when switching characters
125
+ if (typeof saveCharacterData !== 'undefined') {
126
+ saveCharacterData();
127
+ }
128
+
129
+ // Update display (buildResourcesDisplay and updateLuckyButtonText should be available from global scope)
130
+ if (typeof buildResourcesDisplay !== 'undefined') {
131
+ buildResourcesDisplay();
132
+ }
133
+ if (typeof updateLuckyButtonText !== 'undefined') {
134
+ updateLuckyButtonText();
135
+ }
136
+
137
+ debug.log('🎖️ Lucky button updated and character data saved');
138
+
139
+ return true;
140
+ }
141
+
142
+ // ===== INSPIRATION MODALS =====
143
+
144
+ /**
145
+ * Toggle inspiration (gain or use based on current state)
146
+ */
147
+ function toggleInspiration() {
148
+ // Requires characterData to be available from global scope
149
+ if (typeof characterData === 'undefined' || !characterData) return;
150
+
151
+ if (!characterData.inspiration) {
152
+ // Show modal to gain inspiration
153
+ showGainInspirationModal();
154
+ } else {
155
+ // Show modal to choose how to use inspiration (2014 vs 2024)
156
+ showUseInspirationModal();
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Show modal for gaining inspiration
162
+ */
163
+ function showGainInspirationModal() {
164
+ // Requires characterData to be available from global scope
165
+ if (typeof characterData === 'undefined' || !characterData) return;
166
+
167
+ // Create modal overlay
168
+ const modal = document.createElement('div');
169
+ 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;';
170
+
171
+ // Create modal content
172
+ const modalContent = document.createElement('div');
173
+ 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); min-width: 350px; max-width: 450px;';
174
+
175
+ modalContent.innerHTML = `
176
+ <h3 style="margin: 0 0 20px 0; color: var(--text-primary); text-align: center;">⭐ Gain Inspiration</h3>
177
+ <p style="text-align: center; margin-bottom: 25px; color: var(--text-secondary);">
178
+ You're about to gain Inspiration! This can be used for:
179
+ </p>
180
+ <div style="margin-bottom: 25px; padding: 15px; background: var(--bg-tertiary); border-radius: 8px;">
181
+ <div style="margin-bottom: 12px;">
182
+ <strong style="color: #3498db;">📖 D&D 2014:</strong> Gain advantage on an attack roll, saving throw, or ability check
183
+ </div>
184
+ <div>
185
+ <strong style="color: #e74c3c;">📖 D&D 2024:</strong> Reroll any die immediately after rolling it
186
+ </div>
187
+ </div>
188
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
189
+ <button id="gain-inspiration" style="padding: 15px; background: #27ae60; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">
190
+ ⭐ Gain It
191
+ </button>
192
+ <button id="cancel-modal" style="padding: 15px; background: #95a5a6; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">
193
+ Cancel
194
+ </button>
195
+ </div>
196
+ `;
197
+
198
+ modal.appendChild(modalContent);
199
+ document.body.appendChild(modal);
200
+
201
+ // Gain inspiration button
202
+ document.getElementById('gain-inspiration').addEventListener('click', () => {
203
+ characterData.inspiration = true;
204
+ const emoji = '⭐';
205
+
206
+ debug.log(`${emoji} Inspiration gained`);
207
+ if (typeof showNotification !== 'undefined') {
208
+ showNotification(`${emoji} Inspiration gained!`);
209
+ }
210
+
211
+ // TODO: Add Owlbear Rodeo integration for inspiration announcements
212
+
213
+ if (typeof saveCharacterData !== 'undefined') saveCharacterData();
214
+ if (typeof buildSheet !== 'undefined') buildSheet(characterData);
215
+ modal.remove();
216
+ });
217
+
218
+ // Cancel button
219
+ document.getElementById('cancel-modal').addEventListener('click', () => {
220
+ modal.remove();
221
+ });
222
+
223
+ // Click outside to close
224
+ modal.addEventListener('click', (e) => {
225
+ if (e.target === modal) {
226
+ modal.remove();
227
+ }
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Show modal for using inspiration (2014 vs 2024 rules)
233
+ */
234
+ function showUseInspirationModal() {
235
+ // Requires characterData to be available from global scope
236
+ if (typeof characterData === 'undefined' || !characterData) return;
237
+
238
+ // Create modal overlay
239
+ const modal = document.createElement('div');
240
+ 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;';
241
+
242
+ // Create modal content
243
+ const modalContent = document.createElement('div');
244
+ 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); min-width: 400px; max-width: 500px;';
245
+
246
+ const lastRollInfo = characterData.lastRoll
247
+ ? `<div style="margin-bottom: 20px; padding: 12px; background: var(--bg-tertiary); border-left: 4px solid #27ae60; border-radius: 4px;">
248
+ <strong style="color: var(--text-primary);">Last Roll:</strong> <span style="color: var(--text-secondary);">${characterData.lastRoll.name}</span>
249
+ </div>`
250
+ : `<div style="margin-bottom: 20px; padding: 12px; background: var(--bg-tertiary); border-left: 4px solid #e74c3c; border-radius: 4px;">
251
+ <strong style="color: var(--text-primary);">⚠️ No previous roll to reroll</strong>
252
+ </div>`;
253
+
254
+ modalContent.innerHTML = `
255
+ <h3 style="margin: 0 0 20px 0; color: var(--text-primary); text-align: center;">✨ Use Inspiration</h3>
256
+ <p style="text-align: center; margin-bottom: 20px; color: var(--text-secondary);">
257
+ How do you want to use your Inspiration?
258
+ </p>
259
+ ${lastRollInfo}
260
+ <div style="display: grid; gap: 12px; margin-bottom: 20px;">
261
+ <button id="use-2014" style="padding: 18px; background: #3498db; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; text-align: left;">
262
+ <div style="font-size: 1.1em; margin-bottom: 5px;">📖 D&D 2014 - Advantage</div>
263
+ <div style="font-size: 0.85em; opacity: 0.9;">Gain advantage on your next attack roll, saving throw, or ability check</div>
264
+ </button>
265
+ <button id="use-2024" ${!characterData.lastRoll ? 'disabled' : ''} style="padding: 18px; background: ${!characterData.lastRoll ? '#95a5a6' : '#e74c3c'}; color: white; border: none; border-radius: 8px; cursor: ${!characterData.lastRoll ? 'not-allowed' : 'pointer'}; font-weight: bold; text-align: left;">
266
+ <div style="font-size: 1.1em; margin-bottom: 5px;">📖 D&D 2024 - Reroll</div>
267
+ <div style="font-size: 0.85em; opacity: 0.9;">Reroll your last roll and use the new result</div>
268
+ </button>
269
+ </div>
270
+ <button id="cancel-use-modal" style="width: 100%; padding: 12px; background: #7f8c8d; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">
271
+ Cancel
272
+ </button>
273
+ `;
274
+
275
+ modal.appendChild(modalContent);
276
+ document.body.appendChild(modal);
277
+
278
+ // 2014 Advantage button
279
+ document.getElementById('use-2014').addEventListener('click', () => {
280
+ characterData.inspiration = false;
281
+ const emoji = '✨';
282
+
283
+ debug.log(`${emoji} Inspiration spent (2014 - Advantage)`);
284
+ if (typeof showNotification !== 'undefined') {
285
+ showNotification(`${emoji} Inspiration used! Gain advantage on your next roll.`);
286
+ }
287
+
288
+ // TODO: Add Owlbear Rodeo integration for inspiration usage announcements
289
+
290
+ if (typeof saveCharacterData !== 'undefined') saveCharacterData();
291
+ if (typeof buildSheet !== 'undefined') buildSheet(characterData);
292
+ modal.remove();
293
+ });
294
+
295
+ // 2024 Reroll button
296
+ if (characterData.lastRoll) {
297
+ document.getElementById('use-2024').addEventListener('click', () => {
298
+ characterData.inspiration = false;
299
+ const emoji = '✨';
300
+
301
+ debug.log(`${emoji} Inspiration spent (2024 - Reroll): ${characterData.lastRoll.name}`);
302
+ if (typeof showNotification !== 'undefined') {
303
+ showNotification(`${emoji} Inspiration used! Rerolling ${characterData.lastRoll.name}...`);
304
+ }
305
+
306
+ // TODO: Add Owlbear Rodeo integration for inspiration reroll announcements
307
+
308
+ // Trigger reroll
309
+ if (typeof roll !== 'undefined' && characterData.lastRoll) {
310
+ roll(characterData.lastRoll.name, characterData.lastRoll.formula);
311
+ }
312
+
313
+ if (typeof saveCharacterData !== 'undefined') saveCharacterData();
314
+ if (typeof buildSheet !== 'undefined') buildSheet(characterData);
315
+ modal.remove();
316
+ });
317
+ }
318
+
319
+ // Cancel button
320
+ document.getElementById('cancel-use-modal').addEventListener('click', () => {
321
+ modal.remove();
322
+ });
323
+
324
+ // Click outside to close
325
+ modal.addEventListener('click', (e) => {
326
+ if (e.target === modal) {
327
+ modal.remove();
328
+ }
329
+ });
330
+ }
331
+
332
+ // ===== DIVINE SMITE MODAL =====
333
+
334
+ /**
335
+ * Show Divine Smite modal for selecting spell slot and modifiers
336
+ */
337
+ function showDivineSmiteModal(spell) {
338
+ // Requires characterData to be available from global scope
339
+ if (typeof characterData === 'undefined' || !characterData) return;
340
+
341
+ // Get all available spell slots (like upcast modal)
342
+ const availableSlots = [];
343
+
344
+ // Helper to extract numeric value from DiceCloud objects
345
+ const extractNum = (val) => {
346
+ if (val === null || val === undefined) return 0;
347
+ if (typeof val === 'number') return val;
348
+ if (typeof val === 'object') {
349
+ return val.value ?? val.total ?? val.currentValue ?? 0;
350
+ }
351
+ return parseInt(val) || 0;
352
+ };
353
+
354
+ // Check for Pact Magic slots (Warlock) - these are SEPARATE from regular spell slots
355
+ const rawPactLevel = characterData.spellSlots?.pactMagicSlotLevel ||
356
+ characterData.otherVariables?.pactMagicSlotLevel ||
357
+ characterData.otherVariables?.pactSlotLevelVisible ||
358
+ characterData.otherVariables?.pactSlotLevel;
359
+ const rawPactSlots = characterData.spellSlots?.pactMagicSlots ??
360
+ characterData.otherVariables?.pactMagicSlots ??
361
+ characterData.otherVariables?.pactSlot;
362
+ const rawPactSlotsMax = characterData.spellSlots?.pactMagicSlotsMax ??
363
+ characterData.otherVariables?.pactMagicSlotsMax;
364
+
365
+ // Extract numeric values (DiceCloud stores these as objects like {value: 2})
366
+ const pactMagicSlots = extractNum(rawPactSlots);
367
+ const pactMagicSlotsMax = extractNum(rawPactSlotsMax);
368
+ const effectivePactLevel = extractNum(rawPactLevel) || (pactMagicSlotsMax > 0 ? 5 : 0);
369
+
370
+ debug.log('🔮 Pact Magic detection for Divine Smite:', { rawPactLevel, rawPactSlots, rawPactSlotsMax, pactMagicSlots, pactMagicSlotsMax, effectivePactLevel });
371
+
372
+ // Add Pact Magic slots first if available
373
+ if (pactMagicSlotsMax > 0) {
374
+ availableSlots.push({
375
+ level: effectivePactLevel,
376
+ current: pactMagicSlots,
377
+ max: pactMagicSlotsMax,
378
+ slotVar: 'pactMagicSlots',
379
+ slotMaxVar: 'pactMagicSlotsMax',
380
+ isPactMagic: true,
381
+ label: `Level ${effectivePactLevel} - Pact Magic (${pactMagicSlots}/${pactMagicSlotsMax})`
382
+ });
383
+ debug.log(`🔮 Added Pact Magic to Divine Smite options: Level ${effectivePactLevel} (${pactMagicSlots}/${pactMagicSlotsMax})`);
384
+ }
385
+
386
+ // Then check regular spell slots - Divine Smite only works up to 5th level
387
+ for (let level = 1; level <= 5; level++) {
388
+ const slotVar = `level${level}SpellSlots`;
389
+ const slotMaxVar = `level${level}SpellSlotsMax`;
390
+ let current = characterData.spellSlots?.[slotVar] || 0;
391
+ let max = characterData.spellSlots?.[slotMaxVar] || 0;
392
+
393
+ // Skip if this level's slots are actually Pact Magic slots (avoid duplicates)
394
+ if (pactMagicSlotsMax > 0 && level === effectivePactLevel) {
395
+ continue;
396
+ }
397
+
398
+ // Show slot level if character has access to it (max > 0), even if depleted
399
+ if (max > 0) {
400
+ availableSlots.push({
401
+ level,
402
+ current,
403
+ max,
404
+ slotVar,
405
+ slotMaxVar,
406
+ isPactMagic: false,
407
+ label: `Level ${level} (${current}/${max})`
408
+ });
409
+ debug.log(`🔮 Added Level ${level} to Divine Smite options: ${current}/${max}`);
410
+ }
411
+ }
412
+
413
+ debug.log('🔮 Available slots for Divine Smite:', availableSlots);
414
+
415
+ // Sort by level (lowest first)
416
+ availableSlots.sort((a, b) => a.level - b.level);
417
+
418
+ // Create theme-aware modal
419
+ const { modal, modalContent, isDarkTheme } = createThemedModal();
420
+
421
+ // Generate slot options
422
+ const slotOptions = availableSlots.map((slot, index) =>
423
+ `<option value="${index}" ${slot.current <= 0 ? 'disabled' : ''}>
424
+ ${slot.label} ${slot.current <= 0 ? '(No slots remaining)' : ''}
425
+ </option>`
426
+ ).join('');
427
+
428
+ modalContent.innerHTML = `
429
+ <h2 style="margin: 0 0 20px 0; font-size: 1.5em;">⚡ Divine Smite</h2>
430
+ <p style="margin: 0 0 20px 0; font-size: 0.95em;">
431
+ Expend a spell slot to deal extra radiant damage on a melee weapon hit
432
+ </p>
433
+
434
+ <div style="margin: 20px 0;">
435
+ <label style="display: block; margin-bottom: 8px; font-size: 0.95em;">Choose Spell Slot:</label>
436
+ <select id="spellSlotSelect" style="width: 100%; padding: 8px; font-size: 1em; border: 2px solid var(--accent-info); border-radius: 6px;">
437
+ ${slotOptions}
438
+ </select>
439
+ </div>
440
+
441
+ <div style="margin: 20px 0; text-align: left;">
442
+ <h3 style="margin: 0 0 15px 0; font-size: 1.1em;">Damage Options:</h3>
443
+
444
+ <label style="display: flex; align-items: center; margin: 10px 0; cursor: pointer;">
445
+ <input type="checkbox" id="critCheckbox" style="margin-right: 10px; width: 18px; height: 18px;">
446
+ <span>Critical Hit (double damage dice)</span>
447
+ </label>
448
+
449
+ <label style="display: flex; align-items: center; margin: 10px 0; cursor: pointer;">
450
+ <input type="checkbox" id="fiendCheckbox" style="margin-right: 10px; width: 18px; height: 18px;">
451
+ <span>Against Fiend or Undead (+1d8)</span>
452
+ </label>
453
+ </div>
454
+
455
+ <div id="damagePreview" style="margin: 15px 0; padding: 10px; border-radius: 6px; font-weight: bold; display: none;">
456
+ <!-- Hidden - damage shown only on button -->
457
+ </div>
458
+
459
+ <div style="margin-top: 25px; display: flex; gap: 10px; justify-content: center;">
460
+ <button id="confirmDivineSmite" style="padding: 12px 24px; font-size: 1em; font-weight: bold; background: var(--accent-warning); color: white; border: none; border-radius: 6px; cursor: pointer;" disabled>
461
+ Select Slot
462
+ </button>
463
+ <button id="cancelDivineSmite" style="padding: 12px 24px; font-size: 1em; font-weight: bold; background: var(--accent-danger); color: white; border: none; border-radius: 6px; cursor: pointer;">
464
+ Cancel
465
+ </button>
466
+ </div>
467
+ `;
468
+
469
+ modal.appendChild(modalContent);
470
+ document.body.appendChild(modal);
471
+
472
+ // Get elements
473
+ const critCheckbox = document.getElementById('critCheckbox');
474
+ const fiendCheckbox = document.getElementById('fiendCheckbox');
475
+ const slotSelect = document.getElementById('spellSlotSelect');
476
+ const confirmBtn = document.getElementById('confirmDivineSmite');
477
+ const cancelBtn = document.getElementById('cancelDivineSmite');
478
+
479
+ // Update button text when options change
480
+ function updateDamagePreview() {
481
+ const selectedIndex = parseInt(slotSelect.value);
482
+ if (isNaN(selectedIndex) || !availableSlots[selectedIndex]) {
483
+ confirmBtn.disabled = true;
484
+ confirmBtn.textContent = 'Select Slot';
485
+ return;
486
+ }
487
+
488
+ const slot = availableSlots[selectedIndex];
489
+ if (slot.current <= 0) {
490
+ confirmBtn.disabled = true;
491
+ confirmBtn.textContent = 'No Slots';
492
+ return;
493
+ }
494
+
495
+ const level = slot.level;
496
+ const baseDice = 1 + level; // 2d8 at level 1, +1d8 per level above
497
+ let damageFormula = `${baseDice}d8`;
498
+
499
+ // Add +1d8 for fiends/undead
500
+ if (fiendCheckbox.checked) {
501
+ damageFormula += ` + 1d8`;
502
+ }
503
+
504
+ // Apply critical hit doubling
505
+ if (critCheckbox.checked) {
506
+ damageFormula = `(${damageFormula}) * 2`;
507
+ }
508
+
509
+ // Update confirm button
510
+ let buttonText = '⚡ ';
511
+ let modifiers = [];
512
+
513
+ if (damageFormula.includes('* 2')) {
514
+ buttonText += `${baseDice}d8`;
515
+ modifiers.push('(CRIT)');
516
+ } else {
517
+ buttonText += `${baseDice}d8`;
518
+ }
519
+
520
+ if (damageFormula.includes('+ 1d8')) {
521
+ modifiers.unshift('+1d8');
522
+ }
523
+
524
+ if (modifiers.length > 0) {
525
+ buttonText += ' ' + modifiers.join(' ');
526
+ }
527
+
528
+ buttonText += ` Damage (Lvl ${slot.level})`;
529
+
530
+ confirmBtn.innerHTML = buttonText;
531
+ confirmBtn.disabled = false;
532
+ }
533
+
534
+ critCheckbox.addEventListener('change', updateDamagePreview);
535
+ fiendCheckbox.addEventListener('change', updateDamagePreview);
536
+ slotSelect.addEventListener('change', updateDamagePreview);
537
+
538
+ // Handle confirm
539
+ confirmBtn.addEventListener('click', () => {
540
+ const selectedIndex = parseInt(slotSelect.value);
541
+ const slot = availableSlots[selectedIndex];
542
+
543
+ if (slot.current <= 0) {
544
+ if (typeof showNotification !== 'undefined') {
545
+ showNotification(`❌ No Level ${slot.level} spell slot available!`, 'error');
546
+ }
547
+ return;
548
+ }
549
+
550
+ // Calculate damage
551
+ const level = slot.level;
552
+ const baseDice = 1 + level;
553
+ let damageFormula = `${baseDice}d8`;
554
+
555
+ // Add +1d8 for fiends/undead
556
+ if (fiendCheckbox.checked) {
557
+ damageFormula += ` + 1d8`;
558
+ }
559
+
560
+ // Apply critical hit doubling
561
+ if (critCheckbox.checked) {
562
+ damageFormula = `(${damageFormula}) * 2`;
563
+ }
564
+
565
+ // Consume the spell slot
566
+ if (slot.isPactMagic) {
567
+ characterData.spellSlots[slot.slotVar] = Math.max(0, characterData.spellSlots[slot.slotVar] - 1);
568
+ } else {
569
+ characterData.spellSlots[slot.slotVar] = Math.max(0, characterData.spellSlots[slot.slotVar] - 1);
570
+ }
571
+ if (typeof saveCharacterData !== 'undefined') saveCharacterData();
572
+
573
+ // Build description
574
+ let description = `Divine Smite (Level ${level}`;
575
+ if (critCheckbox.checked) description += ', Critical';
576
+ if (fiendCheckbox.checked) description += ', vs Fiend/Undead';
577
+ description += ')';
578
+
579
+ // Announce and roll the damage
580
+ if (typeof announceAction !== 'undefined') {
581
+ announceAction({
582
+ name: 'Divine Smite',
583
+ description: description
584
+ });
585
+ }
586
+
587
+ if (typeof roll !== 'undefined') {
588
+ roll('Divine Smite', damageFormula);
589
+ }
590
+
591
+ // Show notification
592
+ const remaining = slot.isPactMagic ?
593
+ characterData.spellSlots[slot.slotVar] :
594
+ characterData.spellSlots[slot.slotVar];
595
+ if (typeof showNotification !== 'undefined') {
596
+ showNotification(`⚡ Divine Smite! Used Level ${slot.level} slot (${remaining}/${slot.max} left)`);
597
+ }
598
+
599
+ // Remove modal and refresh display
600
+ document.body.removeChild(modal);
601
+ if (typeof buildSheet !== 'undefined') buildSheet(characterData);
602
+ });
603
+
604
+ // Handle cancel
605
+ cancelBtn.addEventListener('click', () => {
606
+ document.body.removeChild(modal);
607
+ });
608
+
609
+ // Handle escape key
610
+ const handleEscape = (e) => {
611
+ if (e.key === 'Escape') {
612
+ document.body.removeChild(modal);
613
+ document.removeEventListener('keydown', handleEscape);
614
+ }
615
+ };
616
+ document.addEventListener('keydown', handleEscape);
617
+
618
+ // Initialize the damage preview
619
+ updateDamagePreview();
620
+ }
621
+
622
+ // ===== LAY ON HANDS MODAL =====
623
+
624
+ /**
625
+ * Show Lay on Hands modal for spending healing points
626
+ */
627
+ function showLayOnHandsModal(layOnHandsPool) {
628
+ // Requires characterData to be available from global scope
629
+ if (typeof characterData === 'undefined' || !characterData) return;
630
+
631
+ // Create theme-aware modal
632
+ const { modal, modalContent, isDarkTheme } = createThemedModal();
633
+
634
+ modalContent.innerHTML = `
635
+ <h2 style="margin: 0 0 20px 0; font-size: 1.5em;">💚 Lay on Hands</h2>
636
+ <p style="margin: 0 0 15px 0; font-size: 1.1em;">
637
+ Available Points: <strong>${layOnHandsPool.current}/${layOnHandsPool.max}</strong>
638
+ </p>
639
+ <p style="margin: 0 0 20px 0; font-size: 0.95em;">
640
+ How many points do you want to spend?
641
+ </p>
642
+ <div style="margin: 20px 0;">
643
+ <input type="number" id="layOnHandsAmount" min="1" max="${layOnHandsPool.current}" value="1"
644
+ style="width: 80px; padding: 8px; font-size: 1.1em; text-align: center; border: 2px solid var(--accent-info); border-radius: 6px;">
645
+ <span style="margin-left: 10px; font-weight: bold;" id="healingDisplay">1 HP healed</span>
646
+ </div>
647
+ <div style="margin-top: 25px; display: flex; gap: 10px; justify-content: center;">
648
+ <button id="confirmLayOnHands" style="padding: 12px 24px; font-size: 1em; font-weight: bold; background: var(--accent-success); color: white; border: none; border-radius: 6px; cursor: pointer;">
649
+ Heal
650
+ </button>
651
+ <button id="cancelLayOnHands" style="padding: 12px 24px; font-size: 1em; font-weight: bold; background: var(--accent-danger); color: white; border: none; border-radius: 6px; cursor: pointer;">
652
+ Cancel
653
+ </button>
654
+ </div>
655
+ `;
656
+
657
+ modal.appendChild(modalContent);
658
+ document.body.appendChild(modal);
659
+
660
+ // Get elements
661
+ const amountInput = document.getElementById('layOnHandsAmount');
662
+ const healingDisplay = document.getElementById('healingDisplay');
663
+ const confirmBtn = document.getElementById('confirmLayOnHands');
664
+ const cancelBtn = document.getElementById('cancelLayOnHands');
665
+
666
+ // Update healing display when amount changes
667
+ function updateHealingDisplay() {
668
+ const amount = parseInt(amountInput.value) || 0;
669
+ healingDisplay.textContent = `${amount} HP healed`;
670
+ healingDisplay.style.color = '#3498db';
671
+ }
672
+
673
+ amountInput.addEventListener('input', updateHealingDisplay);
674
+
675
+ // Handle confirm
676
+ confirmBtn.addEventListener('click', () => {
677
+ const amount = parseInt(amountInput.value);
678
+
679
+ if (isNaN(amount) || amount < 1 || amount > layOnHandsPool.current) {
680
+ if (typeof showNotification !== 'undefined') {
681
+ showNotification(`❌ Please enter a number between 1 and ${layOnHandsPool.current}`, 'error');
682
+ }
683
+ return;
684
+ }
685
+
686
+ // Deduct points
687
+ layOnHandsPool.current -= amount;
688
+ if (typeof saveCharacterData !== 'undefined') saveCharacterData();
689
+
690
+ // Announce the healing
691
+ debug.log(`💚 Used ${amount} Lay on Hands points. Remaining: ${layOnHandsPool.current}/${layOnHandsPool.max}`);
692
+
693
+ if (amount === 5) {
694
+ if (typeof announceAction !== 'undefined') {
695
+ announceAction({
696
+ name: 'Lay on Hands',
697
+ description: `Cured disease/poison`
698
+ });
699
+ }
700
+ if (typeof showNotification !== 'undefined') {
701
+ showNotification(`💚 Lay on Hands: Cured disease/poison (${layOnHandsPool.current}/${layOnHandsPool.max} points left)`);
702
+ }
703
+ } else {
704
+ if (typeof announceAction !== 'undefined') {
705
+ announceAction({
706
+ name: 'Lay on Hands',
707
+ description: `Restored ${amount} HP`
708
+ });
709
+ }
710
+ if (typeof showNotification !== 'undefined') {
711
+ showNotification(`💚 Lay on Hands: Restored ${amount} HP (${layOnHandsPool.current}/${layOnHandsPool.max} points left)`);
712
+ }
713
+ }
714
+
715
+ // Remove modal and refresh display
716
+ document.body.removeChild(modal);
717
+ if (typeof buildSheet !== 'undefined') buildSheet(characterData);
718
+ });
719
+
720
+ // Handle cancel
721
+ cancelBtn.addEventListener('click', () => {
722
+ document.body.removeChild(modal);
723
+ });
724
+
725
+ // Handle escape key
726
+ const handleEscape = (e) => {
727
+ if (e.key === 'Escape') {
728
+ document.body.removeChild(modal);
729
+ document.removeEventListener('keydown', handleEscape);
730
+ }
731
+ };
732
+ document.addEventListener('keydown', handleEscape);
733
+
734
+ // Focus input
735
+ amountInput.focus();
736
+ amountInput.select();
737
+ }
738
+
739
+ // ===== STARRY FORM (Stars Druid) =====
740
+
741
+ function getWisModForStarryForm() {
742
+ if (characterData.attributeMods && typeof characterData.attributeMods.wisdom === 'number') {
743
+ return characterData.attributeMods.wisdom;
744
+ }
745
+ const score = characterData.attributes && characterData.attributes.wisdom;
746
+ if (typeof score === 'number') return Math.floor((score - 10) / 2);
747
+ return 0;
748
+ }
749
+
750
+ // Find a tracked "Wild Shape" resource (used to power Starry Form), if present.
751
+ function getWildShapeResource() {
752
+ if (!characterData || !Array.isArray(characterData.resources)) return null;
753
+ return characterData.resources.find(r => r && r.name && /wild\s*shape/i.test(r.name)) || null;
754
+ }
755
+
756
+ /**
757
+ * Starry Form (Circle of Stars Druid): choose a constellation. Spends one Wild
758
+ * Shape use, announces the constellation and its effect, and records the active
759
+ * constellation. The per-constellation mechanics are announced as their rules
760
+ * text because the dice roll on Roll20, not in the extension.
761
+ */
762
+ function showStarryFormModal(action) {
763
+ if (typeof characterData === 'undefined' || !characterData) return;
764
+
765
+ const wis = getWisModForStarryForm();
766
+ const fmtMod = (m) => (m >= 0 ? `+${m}` : `${m}`);
767
+ const wsRes = getWildShapeResource();
768
+
769
+ const constellations = [
770
+ { key: 'Archer', icon: '🏹', color: 'var(--accent-info)',
771
+ effect: `As a bonus action now and on later turns, make a ranged spell attack (120 ft) for 1d8 ${fmtMod(wis)} radiant. (Use the Archer Attack action.)` },
772
+ { key: 'Chalice', icon: '🍷', color: 'var(--accent-success)',
773
+ effect: `Whenever you cast a spell using a spell slot to restore hit points, you or a creature within 30 ft regains 1d8 ${fmtMod(wis)} hit points.` },
774
+ { key: 'Dragon', icon: '🐉', color: 'var(--accent-warning)',
775
+ effect: `When you make an Intelligence or Wisdom check, or a Constitution save to maintain concentration, treat a d20 roll of 9 or lower as 10.` },
776
+ ];
777
+
778
+ const { modal, modalContent } = createThemedModal();
779
+ modalContent.innerHTML = `
780
+ <h2 style="margin:0 0 8px;font-size:1.5em;">✨ Starry Form</h2>
781
+ <p style="margin:0 0 4px;font-size:0.95em;">Assume a Starry Form (expends one Wild Shape). Choose a constellation:</p>
782
+ <p style="margin:0 0 16px;font-size:0.85em;opacity:0.8;">${wsRes ? `Wild Shape: ${wsRes.current}/${wsRes.max}` : 'Lasts 10 minutes'}</p>
783
+ <div style="display:flex;flex-direction:column;gap:10px;text-align:left;">
784
+ ${constellations.map((c, i) => `
785
+ <button class="starry-choice" data-index="${i}" style="display:block;width:100%;padding:12px 14px;border:none;border-radius:8px;background:${c.color};color:#fff;cursor:pointer;text-align:left;">
786
+ <div style="font-weight:bold;font-size:1.05em;">${c.icon} ${c.key}</div>
787
+ <div style="font-size:0.82em;opacity:0.95;margin-top:4px;line-height:1.35;">${c.effect}</div>
788
+ </button>`).join('')}
789
+ </div>
790
+ <div style="margin-top:18px;">
791
+ <button id="cancelStarryForm" style="padding:10px 22px;font-size:1em;font-weight:bold;background:var(--accent-danger);color:#fff;border:none;border-radius:6px;cursor:pointer;">Cancel</button>
792
+ </div>
793
+ `;
794
+ modal.appendChild(modalContent);
795
+ document.body.appendChild(modal);
796
+
797
+ const close = () => {
798
+ if (modal.parentNode) document.body.removeChild(modal);
799
+ document.removeEventListener('keydown', onEsc);
800
+ };
801
+ const onEsc = (e) => { if (e.key === 'Escape') close(); };
802
+ document.addEventListener('keydown', onEsc);
803
+ document.getElementById('cancelStarryForm').addEventListener('click', close);
804
+
805
+ modalContent.querySelectorAll('.starry-choice').forEach(btn => {
806
+ btn.addEventListener('click', () => {
807
+ const c = constellations[parseInt(btn.dataset.index, 10)];
808
+
809
+ // Spend one Wild Shape use, if tracked.
810
+ if (wsRes) {
811
+ if (wsRes.current <= 0) {
812
+ if (typeof showNotification !== 'undefined') showNotification('❌ No Wild Shape uses remaining!', 'error');
813
+ return;
814
+ }
815
+ wsRes.current -= 1;
816
+ if (characterData.otherVariables && wsRes.varName) {
817
+ characterData.otherVariables[wsRes.varName] = wsRes.current;
818
+ }
819
+ }
820
+ if (typeof saveCharacterData !== 'undefined') saveCharacterData();
821
+
822
+ // Record the active constellation (used for display / reminders).
823
+ characterData.starryFormConstellation = c.key;
824
+
825
+ // Announce to chat + notify.
826
+ if (typeof announceAction !== 'undefined') {
827
+ announceAction({ name: `Starry Form: ${c.key}`, description: c.effect });
828
+ }
829
+ if (typeof showNotification !== 'undefined') {
830
+ showNotification(`✨ Starry Form: ${c.key}${wsRes ? ` (Wild Shape ${wsRes.current}/${wsRes.max})` : ''}`);
831
+ }
832
+
833
+ close();
834
+ if (typeof buildSheet !== 'undefined') buildSheet(characterData);
835
+ });
836
+ });
837
+ }
838
+
839
+ // ===== WILD SHAPE (Druid) =====
840
+
841
+ /**
842
+ * Wild Shape: spend a use to transform into a beast. Beast stat blocks are
843
+ * open-ended, so this records the chosen beast by name, announces the
844
+ * transformation + duration, and decrements the Wild Shape use.
845
+ */
846
+ function showWildShapeModal(action) {
847
+ if (typeof characterData === 'undefined' || !characterData) return;
848
+
849
+ const wsRes = getWildShapeResource();
850
+ const level = Number(characterData.level) || 1;
851
+ const durationHours = Math.max(1, Math.floor(level / 2)); // half druid level, min 1
852
+
853
+ const { modal, modalContent } = createThemedModal();
854
+ modalContent.innerHTML = `
855
+ <h2 style="margin:0 0 8px;font-size:1.5em;">🐾 Wild Shape</h2>
856
+ <p style="margin:0 0 12px;font-size:0.95em;">
857
+ Transform into a beast for up to ${durationHours} hour${durationHours === 1 ? '' : 's'}${wsRes ? ` (Wild Shape: ${wsRes.current}/${wsRes.max})` : ''}.
858
+ </p>
859
+ <div style="margin:0 0 16px;text-align:left;">
860
+ <label style="display:block;font-size:0.85em;margin-bottom:6px;opacity:0.85;">Beast form (name)</label>
861
+ <input type="text" id="wildShapeBeast" placeholder="e.g. Dire Wolf, Giant Eagle…"
862
+ style="width:100%;padding:10px;font-size:1em;border:2px solid var(--accent-info);border-radius:6px;background:rgba(0,0,0,0.2);color:inherit;">
863
+ </div>
864
+ <div style="display:flex;gap:10px;justify-content:center;">
865
+ <button id="confirmWildShape" style="padding:12px 24px;font-size:1em;font-weight:bold;background:var(--accent-success);color:#fff;border:none;border-radius:6px;cursor:pointer;">Transform</button>
866
+ <button id="cancelWildShape" style="padding:12px 24px;font-size:1em;font-weight:bold;background:var(--accent-danger);color:#fff;border:none;border-radius:6px;cursor:pointer;">Cancel</button>
867
+ </div>
868
+ `;
869
+ modal.appendChild(modalContent);
870
+ document.body.appendChild(modal);
871
+
872
+ const input = document.getElementById('wildShapeBeast');
873
+ const close = () => {
874
+ if (modal.parentNode) document.body.removeChild(modal);
875
+ document.removeEventListener('keydown', onEsc);
876
+ };
877
+ const onEsc = (e) => { if (e.key === 'Escape') close(); };
878
+ document.addEventListener('keydown', onEsc);
879
+ document.getElementById('cancelWildShape').addEventListener('click', close);
880
+
881
+ document.getElementById('confirmWildShape').addEventListener('click', () => {
882
+ const beast = (input.value || '').trim() || 'a beast';
883
+
884
+ if (wsRes) {
885
+ if (wsRes.current <= 0) {
886
+ if (typeof showNotification !== 'undefined') showNotification('❌ No Wild Shape uses remaining!', 'error');
887
+ return;
888
+ }
889
+ wsRes.current -= 1;
890
+ if (characterData.otherVariables && wsRes.varName) {
891
+ characterData.otherVariables[wsRes.varName] = wsRes.current;
892
+ }
893
+ }
894
+ if (typeof saveCharacterData !== 'undefined') saveCharacterData();
895
+
896
+ if (typeof announceAction !== 'undefined') {
897
+ announceAction({ name: 'Wild Shape', description: `Transforms into ${beast} for up to ${durationHours} hour${durationHours === 1 ? '' : 's'}.` });
898
+ }
899
+ if (typeof showNotification !== 'undefined') {
900
+ showNotification(`🐾 Wild Shape: ${beast}${wsRes ? ` (${wsRes.current}/${wsRes.max})` : ''}`);
901
+ }
902
+
903
+ close();
904
+ if (typeof buildSheet !== 'undefined') buildSheet(characterData);
905
+ });
906
+
907
+ input.focus();
908
+ }
909
+
910
+ // ===== LUCKY FEAT MODAL =====
911
+
912
+ /**
913
+ * Show Lucky feat modal for using luck points
914
+ */
915
+ function showLuckyModal() {
916
+ debug.log('🎖️ Lucky modal called');
917
+
918
+ const luckyResource = getLuckyResource();
919
+ if (!luckyResource || luckyResource.current <= 0) {
920
+ if (typeof showNotification !== 'undefined') {
921
+ showNotification('❌ No luck points available!', 'error');
922
+ }
923
+ return;
924
+ }
925
+
926
+ // Create modal overlay
927
+ const modal = document.createElement('div');
928
+ 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;';
929
+
930
+ // Create modal content
931
+ const modalContent = document.createElement('div');
932
+ modalContent.style.cssText = 'background: var(--bg-secondary); color: var(--text-primary); border-radius: 8px; padding: 20px; max-width: 400px; width: 90%; box-shadow: 0 4px 20px rgba(0,0,0,0.3);';
933
+
934
+ modalContent.innerHTML = `
935
+ <h3 style="margin: 0 0 15px 0; color: #f39c12;">🎖️ Use Lucky Point</h3>
936
+ <p style="margin: 0 0 15px 0; color: #666;">Choose what to use Lucky for:</p>
937
+ <div style="margin-bottom: 15px; padding: 10px; background: #f8f9fa; border-radius: 4px;">
938
+ <strong>Luck Points:</strong> ${luckyResource.current}/${luckyResource.max}
939
+ </div>
940
+ <div style="display: flex; flex-direction: column; gap: 8px;">
941
+ <button id="luckyOffensive" style="padding: 10px; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">⚔️ Attack/Check/Saving Throw</button>
942
+ <button id="luckyDefensive" style="padding: 10px; background: #e74c3c; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">🛡️ Against Attack on You</button>
943
+ <button id="luckyCancel" style="padding: 10px; background: #95a5a6; color: white; border: none; border-radius: 4px; cursor: pointer;">Cancel</button>
944
+ </div>
945
+ `;
946
+
947
+ modal.appendChild(modalContent);
948
+ document.body.appendChild(modal);
949
+
950
+ // Add event listeners
951
+ document.getElementById('luckyOffensive').addEventListener('click', () => {
952
+ if (useLuckyPoint()) {
953
+ modal.remove();
954
+ // Roll a d20 for Lucky
955
+ rollLuckyDie('offensive');
956
+ }
957
+ });
958
+
959
+ document.getElementById('luckyDefensive').addEventListener('click', () => {
960
+ if (useLuckyPoint()) {
961
+ modal.remove();
962
+ // Roll a d20 for Lucky defense
963
+ rollLuckyDie('defensive');
964
+ }
965
+ });
966
+
967
+ document.getElementById('luckyCancel').addEventListener('click', () => {
968
+ modal.remove();
969
+ });
970
+
971
+ // Close on overlay click
972
+ modal.addEventListener('click', (e) => {
973
+ if (e.target === modal) modal.remove();
974
+ });
975
+
976
+ debug.log('🎖️ Lucky modal displayed');
977
+ }
978
+
979
+ /**
980
+ * Roll Lucky d20 die
981
+ */
982
+ function rollLuckyDie(type) {
983
+ // Requires characterData to be available from global scope
984
+ if (typeof characterData === 'undefined' || !characterData) return;
985
+
986
+ debug.log(`🎖️ Rolling Lucky d20 for ${type}`);
987
+
988
+ // Roll a d20
989
+ const luckyRoll = Math.floor(Math.random() * 20) + 1;
990
+
991
+ // TODO: Add Owlbear Rodeo integration for Lucky rolls
992
+
993
+ if (type === 'offensive') {
994
+ if (typeof showNotification !== 'undefined') {
995
+ showNotification(`🎖️ Lucky roll: ${luckyRoll}! Use this instead of your next d20 roll.`, 'success');
996
+ }
997
+ } else {
998
+ if (typeof showNotification !== 'undefined') {
999
+ showNotification(`🎖️ Lucky defense roll: ${luckyRoll}! Compare against attacker's roll.`, 'success');
1000
+ }
1001
+ }
1002
+
1003
+ debug.log(`🎖️ Lucky d20 result: ${luckyRoll} - sent to chat`);
1004
+ }
1005
+
1006
+ // ===== DIVINE SPARK (CLERIC CHANNEL DIVINITY) =====
1007
+
1008
+ /**
1009
+ * Show Divine Spark modal (Cleric Channel Divinity feature)
1010
+ * @param {object} action - Action object
1011
+ * @param {object} channelDivinityResource - Channel Divinity resource
1012
+ */
1013
+ function showDivineSparkModal(action, channelDivinityResource) {
1014
+ // Create modal overlay
1015
+ const modal = document.createElement('div');
1016
+ modal.className = 'modal-overlay';
1017
+ modal.style.cssText = `
1018
+ position: fixed;
1019
+ top: 0;
1020
+ left: 0;
1021
+ right: 0;
1022
+ bottom: 0;
1023
+ background: rgba(0, 0, 0, 0.7);
1024
+ display: flex;
1025
+ align-items: center;
1026
+ justify-content: center;
1027
+ z-index: 10000;
1028
+ `;
1029
+
1030
+ // Get cleric level for damage calculation
1031
+ const clericLevel = characterData.otherVariables?.clericLevel || characterData.otherVariables?.cleric?.level || 1;
1032
+ const wisdomMod = characterData.abilityScores?.wisdom?.modifier || 0;
1033
+
1034
+ // Calculate number of d8 dice based on cleric level
1035
+ const diceArray = [1,1,1,1,1,1,2,2,2,2,2,2,3,3,3,3,3,4,4,4];
1036
+ const numDice = diceArray[Math.min(clericLevel, 20) - 1] || 1;
1037
+
1038
+ // Create modal content
1039
+ const modalContent = document.createElement('div');
1040
+ modalContent.style.cssText = `
1041
+ background: #2a2a2a;
1042
+ border-radius: 8px;
1043
+ padding: 24px;
1044
+ max-width: 400px;
1045
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
1046
+ color: #fff;
1047
+ `;
1048
+
1049
+ modalContent.innerHTML = `
1050
+ <h3 style="margin-top: 0; margin-bottom: 16px; color: #ffd700; text-align: center;">
1051
+ ✨ Divine Spark
1052
+ </h3>
1053
+ <p style="margin-bottom: 8px; text-align: center; color: #ccc;">
1054
+ Roll: ${numDice}d8 + ${wisdomMod}
1055
+ </p>
1056
+ <p style="margin-bottom: 20px; text-align: center; font-size: 14px; color: #aaa;">
1057
+ Channel Divinity: ${channelDivinityResource.current}/${channelDivinityResource.max}
1058
+ </p>
1059
+ <p style="margin-bottom: 20px; text-align: center; color: #fff;">
1060
+ Choose the effect:
1061
+ </p>
1062
+ <div style="display: flex; flex-direction: column; gap: 12px;">
1063
+ <button id="divine-spark-heal" style="
1064
+ padding: 12px 20px;
1065
+ font-size: 16px;
1066
+ border: 2px solid #4ade80;
1067
+ background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
1068
+ color: white;
1069
+ border-radius: 6px;
1070
+ cursor: pointer;
1071
+ font-weight: bold;
1072
+ transition: all 0.2s;
1073
+ ">💚 Heal Target</button>
1074
+ <button id="divine-spark-necrotic" style="
1075
+ padding: 12px 20px;
1076
+ font-size: 16px;
1077
+ border: 2px solid #a78bfa;
1078
+ background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
1079
+ color: white;
1080
+ border-radius: 6px;
1081
+ cursor: pointer;
1082
+ font-weight: bold;
1083
+ transition: all 0.2s;
1084
+ ">🖤 Necrotic Damage</button>
1085
+ <button id="divine-spark-radiant" style="
1086
+ padding: 12px 20px;
1087
+ font-size: 16px;
1088
+ border: 2px solid #fbbf24;
1089
+ background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
1090
+ color: white;
1091
+ border-radius: 6px;
1092
+ cursor: pointer;
1093
+ font-weight: bold;
1094
+ transition: all 0.2s;
1095
+ ">✨ Radiant Damage</button>
1096
+ <button id="divine-spark-cancel" style="
1097
+ padding: 10px 20px;
1098
+ font-size: 14px;
1099
+ background: #444;
1100
+ color: white;
1101
+ border: 1px solid #666;
1102
+ border-radius: 6px;
1103
+ cursor: pointer;
1104
+ margin-top: 8px;
1105
+ ">Cancel</button>
1106
+ </div>
1107
+ `;
1108
+
1109
+ modal.appendChild(modalContent);
1110
+
1111
+ // Add hover effects
1112
+ const buttons = modalContent.querySelectorAll('button:not(#divine-spark-cancel)');
1113
+ buttons.forEach(btn => {
1114
+ btn.addEventListener('mouseenter', () => {
1115
+ btn.style.transform = 'scale(1.05)';
1116
+ btn.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)';
1117
+ });
1118
+ btn.addEventListener('mouseleave', () => {
1119
+ btn.style.transform = 'scale(1)';
1120
+ btn.style.boxShadow = 'none';
1121
+ });
1122
+ });
1123
+
1124
+ // Helper function to execute Divine Spark
1125
+ const executeDivineSpark = (type, color, damageType = null) => {
1126
+ // Consume Channel Divinity use
1127
+ channelDivinityResource.current -= 1;
1128
+ if (typeof saveCharacterData !== 'undefined') {
1129
+ saveCharacterData();
1130
+ }
1131
+
1132
+ // Set the Divine Spark Choice variable in DiceCloud
1133
+ const choiceValue = type === 'heal' ? 1 : (type === 'necrotic' ? 2 : 3);
1134
+ if (characterData.otherVariables) {
1135
+ characterData.otherVariables.divineSparkChoice = choiceValue;
1136
+ }
1137
+
1138
+ // Build the roll formula
1139
+ const rollFormula = `${numDice}d8 + ${wisdomMod}`;
1140
+
1141
+ // Create roll description
1142
+ const effectText = type === 'heal' ? 'Healing' : `${damageType} Damage`;
1143
+
1144
+ // TODO: Add Owlbear Rodeo integration for Divine Spark rolls
1145
+
1146
+ // Show notification
1147
+ if (typeof showNotification !== 'undefined') {
1148
+ showNotification(`✨ Divine Spark (${effectText})! Channel Divinity: ${channelDivinityResource.current}/${channelDivinityResource.max}`, 'success');
1149
+ }
1150
+ debug.log(`✨ Divine Spark used: ${effectText}`);
1151
+
1152
+ // Rebuild sheet to show updated Channel Divinity count
1153
+ if (typeof buildSheet !== 'undefined') {
1154
+ buildSheet(characterData);
1155
+ }
1156
+
1157
+ // Remove modal
1158
+ modal.remove();
1159
+ };
1160
+
1161
+ // Add button click handlers
1162
+ document.getElementById('divine-spark-heal')?.addEventListener('click', () => {
1163
+ executeDivineSpark('heal', '#22c55e');
1164
+ });
1165
+
1166
+ document.getElementById('divine-spark-necrotic')?.addEventListener('click', () => {
1167
+ executeDivineSpark('necrotic', '#8b5cf6', 'Necrotic');
1168
+ });
1169
+
1170
+ document.getElementById('divine-spark-radiant')?.addEventListener('click', () => {
1171
+ executeDivineSpark('radiant', '#f59e0b', 'Radiant');
1172
+ });
1173
+
1174
+ document.getElementById('divine-spark-cancel')?.addEventListener('click', () => {
1175
+ modal.remove();
1176
+ });
1177
+
1178
+ // Close on overlay click
1179
+ modal.addEventListener('click', (e) => {
1180
+ if (e.target === modal) {
1181
+ modal.remove();
1182
+ }
1183
+ });
1184
+
1185
+ // Add to document
1186
+ document.body.appendChild(modal);
1187
+
1188
+ // Wait for modal to be in DOM before adding event listeners
1189
+ requestAnimationFrame(() => {
1190
+ const healBtn = document.getElementById('divine-spark-heal');
1191
+ const necroticBtn = document.getElementById('divine-spark-necrotic');
1192
+ const radiantBtn = document.getElementById('divine-spark-radiant');
1193
+ const cancelBtn = document.getElementById('divine-spark-cancel');
1194
+
1195
+ healBtn?.addEventListener('click', () => executeDivineSpark('heal', '#22c55e'));
1196
+ necroticBtn?.addEventListener('click', () => executeDivineSpark('necrotic', '#8b5cf6', 'Necrotic'));
1197
+ radiantBtn?.addEventListener('click', () => executeDivineSpark('radiant', '#f59e0b', 'Radiant'));
1198
+ cancelBtn?.addEventListener('click', () => modal.remove());
1199
+ });
1200
+ }
1201
+
1202
+ // ===== EXPORTS =====
1203
+
1204
+ globalThis.toggleInspiration = toggleInspiration;
1205
+ globalThis.showGainInspirationModal = showGainInspirationModal;
1206
+ globalThis.showUseInspirationModal = showUseInspirationModal;
1207
+ globalThis.showDivineSmiteModal = showDivineSmiteModal;
1208
+ globalThis.showLayOnHandsModal = showLayOnHandsModal;
1209
+ globalThis.showStarryFormModal = showStarryFormModal;
1210
+ globalThis.showDivineSparkModal = showDivineSparkModal;
1211
+ globalThis.showLuckyModal = showLuckyModal;
1212
+ globalThis.rollLuckyDie = rollLuckyDie;
1213
+ globalThis.getLuckyResource = getLuckyResource;
1214
+ globalThis.useLuckyPoint = useLuckyPoint;
1215
+ globalThis.getLayOnHandsResource = getLayOnHandsResource;
1216
+ globalThis.showWildShapeModal = showWildShapeModal;
1217
+ globalThis.createThemedModal = createThemedModal;
1218
+
1219
+ // ===== Safe fallbacks for not-yet-implemented feature/spell modals =====
1220
+ // action-display.js routes ~95 spells/features to dedicated show*Modal()
1221
+ // handlers, but only a handful are implemented. Calling an undefined one threw
1222
+ // a ReferenceError, so clicking those actions did nothing. Define a generic
1223
+ // fallback (announce the action) for every modal name that isn't already a
1224
+ // real implementation, so nothing crashes and the action still posts to chat.
1225
+ function genericFeatureModalFallback(action) {
1226
+ try {
1227
+ if (typeof announceAction === 'function') {
1228
+ announceAction(action || {});
1229
+ } else if (typeof showNotification === 'function') {
1230
+ showNotification(`${(action && action.name) || 'Action'} used`);
1231
+ }
1232
+ } catch (e) {
1233
+ (window.debug || console).warn('Feature modal fallback failed:', e);
1234
+ }
1235
+ }
1236
+
1237
+ [
1238
+ 'showAbsorbElementsModal','showAidModal','showAnimateObjectsModal','showArmorOfAgathysModal',
1239
+ 'showAstralProjectionModal','showAuguryModal','showBaneModal','showBigbysHandModal','showBlessModal',
1240
+ 'showBoomingBladeModal','showChaosBoltModal','showChromaticOrbModal','showCloneModal','showCloudOfDaggersModal',
1241
+ 'showCommuneModal','showConjureModal','showContactOtherPlaneModal','showContingencyModal','showCounterspellModal',
1242
+ 'showDelayedBlastFireballModal','showDetectMagicModal','showDispelEvilAndGoodModal','showDispelMagicModal',
1243
+ 'showDivinationModal','showDivineInterventionModal','showDragonsBreathModal','showDreamModal',
1244
+ 'showElementalWeaponModal','showEtherealnessModal','showFeatherFallModal','showFindThePathModal',
1245
+ 'showFireShieldModal','showFlamingSphereModal','showForcecageModal','showFreedomOfMovementModal','showGateModal',
1246
+ 'showGeasModal','showGlyphOfWardingModal','showGreaterRestorationModal','showGreenFlameBladeModal','showGuidanceModal',
1247
+ 'showHarnessDivinePowerModal','showHasteModal','showHealingSpiritModal','showHellishRebukeModal','showHexModal',
1248
+ 'showHuntersMarkModal','showIdentifyModal','showImprisonmentModal','showLegendLoreModal','showLifeTransferenceModal',
1249
+ 'showMagicCircleModal','showMagicJarModal','showMagicMissileModal','showMazeModal','showMeldIntoStoneModal',
1250
+ 'showMirageArcaneModal','showMoonbeamModal','showNondetectionModal','showPlanarBindingModal','showPolymorphModal',
1251
+ 'showProgrammedIllusionModal','showProtectionFromEnergyModal','showProtectionFromEvilAndGoodModal','showRaiseDeadModal',
1252
+ 'showRemoveCurseModal','showResistanceModal','showResurrectionModal','showRevivifyModal','showSanctuaryModal',
1253
+ 'showScorchingRayModal','showScryingModal','showSendingModal','showSequesterModal','showShapechangeModal',
1254
+ 'showShieldModal','showSilenceModal','showSimulacrumModal','showSpeakWithAnimalsModal','showSpeakWithDeadModal',
1255
+ 'showSpeakWithPlantsModal','showSpikeGrowthModal','showSpiritGuardiansModal','showSpiritualWeaponModal','showSymbolModal',
1256
+ 'showTeleportModal','showTimeStopModal','showTruePolymorphModal','showTrueResurrectionModal','showVampiricTouchModal',
1257
+ 'showWallOfFireModal','showWishModal','showWordOfRecallModal','showZoneOfTruthModal',
1258
+ ].forEach((name) => {
1259
+ if (typeof globalThis[name] !== 'function') {
1260
+ globalThis[name] = genericFeatureModalFallback;
1261
+ }
1262
+ });
1263
+
1264
+ })();