@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,775 @@
1
+ /**
2
+ * Resource Manager Module
3
+ *
4
+ * Handles class resource tracking, consumption, and conversion.
5
+ * Loaded as a plain script (no ES6 modules) to export to window.
6
+ *
7
+ * Functions exported to globalThis:
8
+ * - buildResourcesDisplay()
9
+ * - adjustResource(resource)
10
+ * - getSorceryPointsResource()
11
+ * - getKiPointsResource()
12
+ * - findResourceByVariableName(variableName)
13
+ * - getResourceCostsFromAction(action)
14
+ * - getKiCostFromAction(action)
15
+ * - getSorceryPointCostFromAction(action)
16
+ * - decrementActionResources(action)
17
+ * - showConvertSlotToPointsModal()
18
+ * - showFontOfMagicModal()
19
+ * - showSpellSlotRestorationModal(channelDivinityResource, maxSlotLevel)
20
+ * - restoreSpellSlot(level, channelDivinityResource)
21
+ */
22
+
23
+ (function() {
24
+ 'use strict';
25
+
26
+ // ===== RESOURCE DISPLAY FUNCTIONS =====
27
+
28
+ /**
29
+ * Build resources display grid
30
+ */
31
+ function buildResourcesDisplay() {
32
+ const container = document.getElementById('resources-container');
33
+
34
+ if (!container) {
35
+ debug.warn('⚠️ Resources container not found in DOM');
36
+ return;
37
+ }
38
+
39
+ if (!characterData || !characterData.resources || characterData.resources.length === 0) {
40
+ container.innerHTML = '<p style="text-align: center; color: #666;">No class resources available</p>';
41
+ debug.log('⚠️ No resources in character data');
42
+ // Collapse the section when empty
43
+ if (typeof collapseSectionByContainerId !== 'undefined') {
44
+ collapseSectionByContainerId('resources-container');
45
+ }
46
+ return;
47
+ }
48
+
49
+ // Expand the section when it has content
50
+ if (typeof expandSectionByContainerId !== 'undefined') {
51
+ expandSectionByContainerId('resources-container');
52
+ }
53
+
54
+ debug.log(`📊 Building resources display with ${characterData.resources.length} resources:`,
55
+ characterData.resources.map(r => `${r.name} (${r.current}/${r.max})`));
56
+
57
+ const resourcesGrid = document.createElement('div');
58
+ resourcesGrid.className = 'spell-slots-grid'; // Reuse spell slot styling
59
+
60
+ characterData.resources.forEach(resource => {
61
+ // Skip resources with MAX = 0 (useless paladin amount resources)
62
+ if (resource.max === 0) {
63
+ debug.log(`⏭️ Skipping resource with MAX = 0: ${resource.name}`);
64
+ return;
65
+ }
66
+
67
+ // Skip Lucky resources since they have their own action button
68
+ const lowerName = resource.name.toLowerCase().trim();
69
+ if (lowerName.includes('lucky point') || lowerName.includes('luck point') || lowerName === 'lucky points' || lowerName === 'lucky') {
70
+ debug.log(`⏭️ Skipping Lucky resource from display: ${resource.name}`);
71
+ return;
72
+ }
73
+
74
+ // Skip HP resources since HP has its own dedicated UI section
75
+ if (lowerName.includes('hit point') || lowerName === 'hp' || lowerName === 'health' || lowerName.includes('hitpoint')) {
76
+ debug.log(`⏭️ Skipping HP resource from display: ${resource.name}`);
77
+ return;
78
+ }
79
+
80
+ // Skip "Spell Level" resource - this is metadata, not an actual resource
81
+ if (lowerName === 'spell level' || lowerName === 'spelllevel') {
82
+ debug.log(`⏭️ Skipping Spell Level resource from display: ${resource.name}`);
83
+ return;
84
+ }
85
+
86
+ debug.log(`📊 Displaying resource: ${resource.name} (${resource.current}/${resource.max})`);
87
+
88
+ const resourceCard = document.createElement('div');
89
+ resourceCard.className = resource.current > 0 ? 'spell-slot-card' : 'spell-slot-card empty';
90
+ resourceCard.innerHTML = `
91
+ <div class="spell-slot-level">${resource.name}</div>
92
+ <div class="spell-slot-count">${resource.current}/${resource.max}</div>
93
+ `;
94
+
95
+ // Add click to manually adjust resource
96
+ resourceCard.addEventListener('click', () => {
97
+ adjustResource(resource);
98
+ });
99
+ resourceCard.style.cursor = 'pointer';
100
+
101
+ resourcesGrid.appendChild(resourceCard);
102
+ });
103
+
104
+ container.innerHTML = '';
105
+ container.appendChild(resourcesGrid);
106
+
107
+ const note = document.createElement('p');
108
+ note.style.cssText = 'text-align: center; color: #95a5a6; font-size: 0.85em; margin-top: 10px;';
109
+ note.textContent = 'Click a resource to manually adjust';
110
+ container.appendChild(note);
111
+ }
112
+
113
+ /**
114
+ * Manual resource adjustment modal
115
+ */
116
+ function adjustResource(resource) {
117
+ const newValue = prompt(`Adjust ${resource.name}\n\nCurrent: ${resource.current}/${resource.max}\n\nEnter new current value (0-${resource.max}):`);
118
+
119
+ if (newValue === null) return; // Cancelled
120
+
121
+ const parsed = parseInt(newValue);
122
+ if (isNaN(parsed) || parsed < 0 || parsed > resource.max) {
123
+ alert(`Please enter a number between 0 and ${resource.max}`);
124
+ return;
125
+ }
126
+
127
+ resource.current = parsed;
128
+
129
+ // Also update otherVariables to keep data in sync
130
+ if (characterData.otherVariables && resource.varName) {
131
+ characterData.otherVariables[resource.varName] = resource.current;
132
+ }
133
+
134
+ if (typeof saveCharacterData !== 'undefined') {
135
+ saveCharacterData();
136
+ }
137
+ if (typeof buildSheet !== 'undefined') {
138
+ buildSheet(characterData);
139
+ }
140
+
141
+ if (typeof showNotification !== 'undefined') {
142
+ showNotification(`✅ ${resource.name} updated to ${resource.current}/${resource.max}`);
143
+ }
144
+ }
145
+
146
+ // ===== RESOURCE FINDER FUNCTIONS =====
147
+
148
+ /**
149
+ * Get Sorcery Points resource (uses action-executor)
150
+ */
151
+ function getSorceryPointsResource() {
152
+ if (typeof executorGetSorceryPointsResource !== 'undefined') {
153
+ return executorGetSorceryPointsResource(characterData);
154
+ }
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * Get Ki Points resource
160
+ */
161
+ function getKiPointsResource() {
162
+ if (!characterData || !characterData.resources) return null;
163
+
164
+ // Find ki points in resources
165
+ const kiResource = characterData.resources.find(r => {
166
+ const lowerName = r.name.toLowerCase();
167
+ return lowerName.includes('ki point') || lowerName === 'ki points' || lowerName === 'ki';
168
+ });
169
+
170
+ return kiResource || null;
171
+ }
172
+
173
+ /**
174
+ * Find resource by variable name (with flexible Channel Divinity matching)
175
+ */
176
+ function findResourceByVariableName(variableName) {
177
+ // Check for exact match first
178
+ let resource = characterData.resources?.find(r => r.variableName === variableName);
179
+
180
+ if (resource) {
181
+ return resource;
182
+ }
183
+
184
+ // Special handling for Channel Divinity - try all possible variable names
185
+ if (variableName === 'channelDivinity' ||
186
+ variableName === 'channelDivinityCleric' ||
187
+ variableName === 'channelDivinityPaladin') {
188
+ resource = characterData.resources?.find(r =>
189
+ r.name === 'Channel Divinity' ||
190
+ r.variableName === 'channelDivinityCleric' ||
191
+ r.variableName === 'channelDivinityPaladin' ||
192
+ r.variableName === 'channelDivinity'
193
+ );
194
+ }
195
+
196
+ return resource;
197
+ }
198
+
199
+ // ===== RESOURCE COST EXTRACTION FUNCTIONS =====
200
+
201
+ /**
202
+ * Get resource costs from action (uses DiceCloud structured data)
203
+ */
204
+ function getResourceCostsFromAction(action) {
205
+ // Use DiceCloud's structured resource consumption data instead of regex parsing
206
+ if (!action || !action.resources || !action.resources.attributesConsumed) {
207
+ return [];
208
+ }
209
+
210
+ const costs = action.resources.attributesConsumed.map(consumed => {
211
+ const quantity = consumed.quantity?.value || 0;
212
+ return {
213
+ name: consumed.statName || '',
214
+ variableName: consumed.variableName || '',
215
+ quantity: quantity
216
+ };
217
+ });
218
+
219
+ if (costs.length > 0) {
220
+ debug.log(`💰 Resource costs for ${action.name}:`, costs);
221
+ // Debug: Log each cost with its variableName
222
+ costs.forEach(cost => {
223
+ debug.log(` 📋 Cost: ${cost.name || 'unnamed'}, variableName: "${cost.variableName}", quantity: ${cost.quantity}`);
224
+ });
225
+ }
226
+
227
+ return costs;
228
+ }
229
+
230
+ /**
231
+ * Get Ki cost from action (legacy compatibility)
232
+ */
233
+ function getKiCostFromAction(action) {
234
+ const costs = getResourceCostsFromAction(action);
235
+ const kiCost = costs.find(c =>
236
+ c.variableName === 'kiPoints' ||
237
+ c.name.toLowerCase().includes('ki point')
238
+ );
239
+
240
+ if (kiCost) {
241
+ debug.log(`💨 Ki cost for ${action.name}: ${kiCost.quantity} ki points`);
242
+ return kiCost.quantity;
243
+ }
244
+
245
+ return 0;
246
+ }
247
+
248
+ /**
249
+ * Get Sorcery Point cost from action (legacy compatibility)
250
+ */
251
+ function getSorceryPointCostFromAction(action) {
252
+ const costs = getResourceCostsFromAction(action);
253
+ const sorceryCost = costs.find(c =>
254
+ c.variableName === 'sorceryPoints' ||
255
+ c.name.toLowerCase().includes('sorcery point')
256
+ );
257
+
258
+ if (sorceryCost) {
259
+ debug.log(`✨ Sorcery Point cost for ${action.name}: ${sorceryCost.quantity} SP`);
260
+ return sorceryCost.quantity;
261
+ }
262
+
263
+ return 0;
264
+ }
265
+
266
+ // ===== RESOURCE CONSUMPTION FUNCTIONS =====
267
+
268
+ /**
269
+ * Decrement action resources (Wild Shape uses, Breath Weapon uses, etc.)
270
+ */
271
+ function decrementActionResources(action) {
272
+ // Decrement all resource costs for an action
273
+ const costs = getResourceCostsFromAction(action);
274
+
275
+ if (!costs || costs.length === 0) {
276
+ return true; // No resources to decrement
277
+ }
278
+
279
+ // Check all resources have sufficient quantities before decrementing any
280
+ for (const cost of costs) {
281
+ // Skip Ki and Sorcery Points as they're handled separately
282
+ if (cost.variableName === 'kiPoints' || cost.variableName === 'sorceryPoints') {
283
+ continue;
284
+ }
285
+
286
+ if (!cost.variableName) {
287
+ debug.log(`⚠️ Resource cost missing variableName for ${action.name}:`, cost);
288
+ continue;
289
+ }
290
+
291
+ // Find the resource in character data (with flexible Channel Divinity matching)
292
+ const resource = findResourceByVariableName(cost.variableName);
293
+
294
+ if (!resource) {
295
+ debug.log(`⚠️ Resource not found: ${cost.variableName} for ${action.name}`);
296
+ continue;
297
+ }
298
+
299
+ if (resource.current < cost.quantity) {
300
+ if (typeof showNotification !== 'undefined') {
301
+ showNotification(`❌ Not enough ${cost.name || cost.variableName}! Need ${cost.quantity}, have ${resource.current}`, 'error');
302
+ }
303
+ return false;
304
+ }
305
+ }
306
+
307
+ // All checks passed, now decrement the resources
308
+ for (const cost of costs) {
309
+ // Skip Ki and Sorcery Points as they're handled separately
310
+ if (cost.variableName === 'kiPoints' || cost.variableName === 'sorceryPoints') {
311
+ continue;
312
+ }
313
+
314
+ if (!cost.variableName) {
315
+ continue;
316
+ }
317
+
318
+ // Find the resource (with flexible Channel Divinity matching)
319
+ const resource = findResourceByVariableName(cost.variableName);
320
+
321
+ if (resource) {
322
+ resource.current -= cost.quantity;
323
+
324
+ // Also update otherVariables to keep data in sync
325
+ if (characterData.otherVariables && resource.varName) {
326
+ characterData.otherVariables[resource.varName] = resource.current;
327
+ }
328
+
329
+ debug.log(`✅ Used ${cost.quantity} ${cost.name || cost.variableName} for ${action.name}. Remaining: ${resource.current}/${resource.max}`);
330
+ if (typeof showNotification !== 'undefined') {
331
+ showNotification(`✅ Used ${action.name}! (${resource.current}/${resource.max} ${cost.name || cost.variableName} left)`);
332
+ }
333
+ }
334
+ }
335
+
336
+ if (typeof saveCharacterData !== 'undefined') {
337
+ saveCharacterData();
338
+ }
339
+ if (typeof buildSheet !== 'undefined') {
340
+ buildSheet(characterData); // Refresh display
341
+ }
342
+ return true;
343
+ }
344
+
345
+ // ===== SORCERY POINT CONVERSION MODALS =====
346
+
347
+ /**
348
+ * Convert spell slot to sorcery points (Font of Magic)
349
+ */
350
+ function showConvertSlotToPointsModal() {
351
+ const sorceryPoints = getSorceryPointsResource();
352
+
353
+ if (!sorceryPoints) {
354
+ if (typeof showNotification !== 'undefined') {
355
+ showNotification('❌ No Sorcery Points resource found', 'error');
356
+ }
357
+ return;
358
+ }
359
+
360
+ // Get available spell slots
361
+ const availableSlots = [];
362
+ for (let level = 1; level <= 9; level++) {
363
+ const slotVar = `level${level}SpellSlots`;
364
+ const maxSlotVar = `level${level}SpellSlotsMax`;
365
+ const current = characterData.spellSlots?.[slotVar] || 0;
366
+ const max = characterData.spellSlots?.[maxSlotVar] || 0;
367
+
368
+ if (current > 0) {
369
+ availableSlots.push({ level, current, max, slotVar, maxSlotVar });
370
+ }
371
+ }
372
+
373
+ if (availableSlots.length === 0) {
374
+ if (typeof showNotification !== 'undefined') {
375
+ showNotification('❌ No spell slots available to convert!', 'error');
376
+ }
377
+ return;
378
+ }
379
+
380
+ // Create modal
381
+ const modal = document.createElement('div');
382
+ 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;';
383
+
384
+ const modalContent = document.createElement('div');
385
+ 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%;';
386
+
387
+ let optionsHTML = `
388
+ <h3 style="margin: 0 0 15px 0; color: var(--text-primary); text-align: center;">Convert Spell Slot to Sorcery Points</h3>
389
+ <p style="text-align: center; color: #e74c3c; margin-bottom: 20px; font-weight: bold;">Current: ${sorceryPoints.current}/${sorceryPoints.max} SP</p>
390
+
391
+ <div style="margin-bottom: 25px;">
392
+ <label style="display: block; margin-bottom: 10px; font-weight: bold; color: var(--text-primary);">Expend Spell Slot:</label>
393
+ <select id="slot-to-points-level" 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);">
394
+ `;
395
+
396
+ availableSlots.forEach(slot => {
397
+ optionsHTML += `<option value="${slot.level}">Level ${slot.level} - Gain ${slot.level} SP (${slot.current}/${slot.max} slots)</option>`;
398
+ });
399
+
400
+ optionsHTML += `
401
+ </select>
402
+ </div>
403
+
404
+ <div style="display: flex; gap: 10px;">
405
+ <button id="slot-cancel" style="flex: 1; padding: 12px; font-size: 1em; background: #95a5a6; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold;">
406
+ Cancel
407
+ </button>
408
+ <button id="slot-confirm" style="flex: 1; padding: 12px; font-size: 1em; background: #9b59b6; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold;">
409
+ Convert
410
+ </button>
411
+ </div>
412
+ `;
413
+
414
+ modalContent.innerHTML = optionsHTML;
415
+ modal.appendChild(modalContent);
416
+ document.body.appendChild(modal);
417
+
418
+ const selectElement = document.getElementById('slot-to-points-level');
419
+ const confirmBtn = document.getElementById('slot-confirm');
420
+ const cancelBtn = document.getElementById('slot-cancel');
421
+
422
+ cancelBtn.addEventListener('click', () => {
423
+ document.body.removeChild(modal);
424
+ });
425
+
426
+ confirmBtn.addEventListener('click', () => {
427
+ const selectedLevel = parseInt(selectElement.value);
428
+ const slotVar = `level${selectedLevel}SpellSlots`;
429
+ const currentSlots = characterData.spellSlots?.[slotVar] || 0;
430
+
431
+ if (currentSlots <= 0) {
432
+ if (typeof showNotification !== 'undefined') {
433
+ showNotification(`❌ No Level ${selectedLevel} spell slots available!`, 'error');
434
+ }
435
+ return;
436
+ }
437
+
438
+ // Remove spell slot
439
+ characterData.spellSlots[slotVar] -= 1;
440
+
441
+ // Gain sorcery points equal to slot level
442
+ const pointsGained = selectedLevel;
443
+ sorceryPoints.current = Math.min(sorceryPoints.current + pointsGained, sorceryPoints.max);
444
+
445
+ if (typeof saveCharacterData !== 'undefined') {
446
+ saveCharacterData();
447
+ }
448
+
449
+ const maxSlotVar = `level${selectedLevel}SpellSlotsMax`;
450
+ const newSlotCount = characterData.spellSlots[slotVar];
451
+ const maxSlots = characterData.spellSlots[maxSlotVar];
452
+ if (typeof showNotification !== 'undefined') {
453
+ showNotification(`✨ Gained ${pointsGained} Sorcery Points! (${sorceryPoints.current}/${sorceryPoints.max} SP, ${newSlotCount}/${maxSlots} slots)`);
454
+ }
455
+
456
+ // Announce to Roll20
457
+ if (typeof getColoredBanner !== 'undefined') {
458
+ const colorBanner = getColoredBanner(characterData);
459
+ const message = `&{template:default} {{name=${colorBanner}${characterData.name} uses Font of Magic⚡}} {{Action=Convert Spell Slot to Sorcery Points}} {{Result=Expended Level ${selectedLevel} spell slot for ${pointsGained} SP}} {{Sorcery Points=${sorceryPoints.current}/${sorceryPoints.max}}}`;
460
+
461
+ sendToRoll20({
462
+ action: 'roll',
463
+ characterName: characterData.name,
464
+ message: message,
465
+ color: characterData.notificationColor
466
+ });
467
+ }
468
+
469
+ document.body.removeChild(modal);
470
+ if (typeof buildSheet !== 'undefined') {
471
+ buildSheet(characterData); // Refresh display
472
+ }
473
+ });
474
+ }
475
+
476
+ /**
477
+ * Convert sorcery points to spell slot (Font of Magic)
478
+ */
479
+ function showFontOfMagicModal() {
480
+ const sorceryPoints = getSorceryPointsResource();
481
+
482
+ if (!sorceryPoints) {
483
+ if (typeof showNotification !== 'undefined') {
484
+ showNotification('❌ No Sorcery Points resource found', 'error');
485
+ }
486
+ return;
487
+ }
488
+
489
+ // Font of Magic spell slot creation costs (D&D 5e rules)
490
+ const slotCosts = {
491
+ 1: 2,
492
+ 2: 3,
493
+ 3: 5,
494
+ 4: 6,
495
+ 5: 7
496
+ };
497
+
498
+ // Create modal
499
+ const modal = document.createElement('div');
500
+ 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;';
501
+
502
+ const modalContent = document.createElement('div');
503
+ 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%;';
504
+
505
+ let optionsHTML = `
506
+ <h3 style="margin: 0 0 15px 0; color: var(--text-primary); text-align: center;">Convert Sorcery Points to Spell Slot</h3>
507
+ <p style="text-align: center; color: #e74c3c; margin-bottom: 20px; font-weight: bold;">Current: ${sorceryPoints.current}/${sorceryPoints.max} SP</p>
508
+
509
+ <div style="margin-bottom: 25px;">
510
+ <label style="display: block; margin-bottom: 10px; font-weight: bold; color: var(--text-primary);">Create Spell Slot Level:</label>
511
+ <select id="font-of-magic-slot" 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);">
512
+ `;
513
+
514
+ // Add options for each spell slot level
515
+ for (let level = 1; level <= 5; level++) {
516
+ const cost = slotCosts[level];
517
+ const canAfford = sorceryPoints.current >= cost;
518
+ const slotVar = `level${level}SpellSlots`;
519
+ const maxSlotVar = `level${level}SpellSlotsMax`;
520
+ const currentSlots = characterData.spellSlots?.[slotVar] || 0;
521
+ const maxSlots = characterData.spellSlots?.[maxSlotVar] || 0;
522
+
523
+ const disabledAttr = canAfford ? '' : 'disabled';
524
+ const affordText = canAfford ? '' : ' (not enough SP)';
525
+
526
+ optionsHTML += `<option value="${level}" ${disabledAttr}>Level ${level} - ${cost} SP${affordText} (${currentSlots}/${maxSlots} slots)</option>`;
527
+ }
528
+
529
+ optionsHTML += `
530
+ </select>
531
+ </div>
532
+
533
+ <div style="display: flex; gap: 10px;">
534
+ <button id="font-cancel" style="flex: 1; padding: 12px; font-size: 1em; background: #95a5a6; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold;">
535
+ Cancel
536
+ </button>
537
+ <button id="font-confirm" style="flex: 1; padding: 12px; font-size: 1em; background: #e74c3c; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold;">
538
+ Convert
539
+ </button>
540
+ </div>
541
+ `;
542
+
543
+ modalContent.innerHTML = optionsHTML;
544
+ modal.appendChild(modalContent);
545
+ document.body.appendChild(modal);
546
+
547
+ const selectElement = document.getElementById('font-of-magic-slot');
548
+ const confirmBtn = document.getElementById('font-confirm');
549
+ const cancelBtn = document.getElementById('font-cancel');
550
+
551
+ cancelBtn.addEventListener('click', () => {
552
+ document.body.removeChild(modal);
553
+ });
554
+
555
+ confirmBtn.addEventListener('click', () => {
556
+ const selectedLevel = parseInt(selectElement.value);
557
+ const cost = slotCosts[selectedLevel];
558
+
559
+ if (sorceryPoints.current < cost) {
560
+ if (typeof showNotification !== 'undefined') {
561
+ showNotification(`❌ Not enough Sorcery Points! Need ${cost}, have ${sorceryPoints.current}`, 'error');
562
+ }
563
+ return;
564
+ }
565
+
566
+ // Deduct sorcery points
567
+ sorceryPoints.current -= cost;
568
+
569
+ // Add spell slot
570
+ const slotVar = `level${selectedLevel}SpellSlots`;
571
+ const maxSlotVar = `level${selectedLevel}SpellSlotsMax`;
572
+ const maxSlots = characterData.spellSlots?.[maxSlotVar] || 0;
573
+
574
+ characterData.spellSlots[slotVar] = Math.min((characterData.spellSlots[slotVar] || 0) + 1, maxSlots);
575
+
576
+ if (typeof saveCharacterData !== 'undefined') {
577
+ saveCharacterData();
578
+ }
579
+
580
+ const currentSlots = characterData.spellSlots[slotVar];
581
+ if (typeof showNotification !== 'undefined') {
582
+ showNotification(`✨ Created Level ${selectedLevel} spell slot! (${sorceryPoints.current}/${sorceryPoints.max} SP left, ${currentSlots}/${maxSlots} slots)`);
583
+ }
584
+
585
+ // Announce to Roll20
586
+ if (typeof getColoredBanner !== 'undefined') {
587
+ const colorBanner = getColoredBanner(characterData);
588
+ const message = `&{template:default} {{name=${colorBanner}${characterData.name} uses Font of Magic⚡}} {{Action=Convert Sorcery Points to Spell Slot}} {{Result=Created Level ${selectedLevel} spell slot for ${cost} SP}} {{Sorcery Points=${sorceryPoints.current}/${sorceryPoints.max}}}`;
589
+
590
+ sendToRoll20({
591
+ action: 'roll',
592
+ characterName: characterData.name,
593
+ message: message,
594
+ color: characterData.notificationColor
595
+ });
596
+ }
597
+
598
+ document.body.removeChild(modal);
599
+ if (typeof buildSheet !== 'undefined') {
600
+ buildSheet(characterData); // Refresh display
601
+ }
602
+ });
603
+ }
604
+
605
+ // ===== CHANNEL DIVINITY SPELL SLOT RESTORATION =====
606
+
607
+ /**
608
+ * Show spell slot restoration modal (Harness Divine Power)
609
+ */
610
+ function showSpellSlotRestorationModal(channelDivinityResource, maxSlotLevel) {
611
+ // Create modal overlay
612
+ const modal = document.createElement('div');
613
+ 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;';
614
+
615
+ // Create modal content
616
+ const modalContent = document.createElement('div');
617
+ 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;';
618
+
619
+ // Build spell slot buttons
620
+ let slotButtonsHTML = '';
621
+ const spellSlots = characterData.spellSlots || {};
622
+
623
+ for (let level = 1; level <= maxSlotLevel; level++) {
624
+ const slotVar = `level${level}SpellSlots`;
625
+ const slotMaxVar = `level${level}SpellSlotsMax`;
626
+ const current = spellSlots[slotVar] || 0;
627
+ const max = spellSlots[slotMaxVar] || 0;
628
+
629
+ const isAvailable = max > 0 && current < max;
630
+ const disabled = !isAvailable ? 'disabled' : '';
631
+ const bgColor = isAvailable ? '#9b59b6' : '#bdc3c7';
632
+ const cursor = isAvailable ? 'pointer' : 'not-allowed';
633
+ const opacity = isAvailable ? '1' : '0.6';
634
+
635
+ slotButtonsHTML += `
636
+ <button
637
+ class="spell-slot-restore-btn"
638
+ data-level="${level}"
639
+ ${disabled}
640
+ style="width: 100%; padding: 15px; background: ${bgColor}; color: white; border: none; border-radius: 8px; cursor: ${cursor}; font-weight: bold; margin-bottom: 10px; opacity: ${opacity};">
641
+ <div style="display: flex; justify-content: space-between; align-items: center;">
642
+ <span>Level ${level} Spell Slot</span>
643
+ <span style="font-size: 0.9em;">${current}/${max}</span>
644
+ </div>
645
+ </button>
646
+ `;
647
+ }
648
+
649
+ modalContent.innerHTML = `
650
+ <h3 style="margin: 0 0 15px 0; color: var(--text-primary); text-align: center;">🔮 Harness Divine Power</h3>
651
+ <p style="text-align: center; margin-bottom: 20px; color: #555; font-size: 0.95em;">
652
+ Choose which spell slot to restore (max level ${maxSlotLevel})
653
+ </p>
654
+ <div style="margin-bottom: 20px;">
655
+ ${slotButtonsHTML}
656
+ </div>
657
+ <button id="cancel-restore-modal" style="width: 100%; padding: 12px; background: #7f8c8d; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">
658
+ Cancel
659
+ </button>
660
+ `;
661
+
662
+ modal.appendChild(modalContent);
663
+ document.body.appendChild(modal);
664
+
665
+ // Add click handlers to spell slot buttons
666
+ const slotButtons = modal.querySelectorAll('.spell-slot-restore-btn:not([disabled])');
667
+ slotButtons.forEach(button => {
668
+ button.addEventListener('click', () => {
669
+ const level = parseInt(button.getAttribute('data-level'));
670
+ restoreSpellSlot(level, channelDivinityResource);
671
+ modal.remove();
672
+ });
673
+ });
674
+
675
+ // Cancel button
676
+ document.getElementById('cancel-restore-modal').addEventListener('click', () => {
677
+ modal.remove();
678
+ });
679
+
680
+ // Click outside to close
681
+ modal.addEventListener('click', (e) => {
682
+ if (e.target === modal) {
683
+ modal.remove();
684
+ }
685
+ });
686
+ }
687
+
688
+ /**
689
+ * Restore a spell slot using Channel Divinity (Harness Divine Power)
690
+ */
691
+ function restoreSpellSlot(level, channelDivinityResource) {
692
+ const slotVar = `level${level}SpellSlots`;
693
+ const slotMaxVar = `level${level}SpellSlotsMax`;
694
+
695
+ if (!characterData.spellSlots) {
696
+ if (typeof showNotification !== 'undefined') {
697
+ showNotification('❌ No spell slots available!', 'error');
698
+ }
699
+ return;
700
+ }
701
+
702
+ const current = characterData.spellSlots[slotVar] || 0;
703
+ const max = characterData.spellSlots[slotMaxVar] || 0;
704
+
705
+ if (max === 0) {
706
+ if (typeof showNotification !== 'undefined') {
707
+ showNotification('❌ No spell slots at that level!', 'error');
708
+ }
709
+ return;
710
+ }
711
+
712
+ if (current >= max) {
713
+ if (typeof showNotification !== 'undefined') {
714
+ showNotification(`❌ Level ${level} spell slots already full!`, 'error');
715
+ }
716
+ return;
717
+ }
718
+
719
+ // Restore the spell slot
720
+ characterData.spellSlots[slotVar] = Math.min(current + 1, max);
721
+
722
+ // Expend Channel Divinity use
723
+ channelDivinityResource.current = Math.max(0, channelDivinityResource.current - 1);
724
+
725
+ // Update character data - sync with otherVariables using the correct variable name
726
+ if (characterData.otherVariables && channelDivinityResource.variableName) {
727
+ characterData.otherVariables[channelDivinityResource.variableName] = channelDivinityResource.current;
728
+ } else if (characterData.otherVariables && channelDivinityResource.varName) {
729
+ characterData.otherVariables[channelDivinityResource.varName] = channelDivinityResource.current;
730
+ }
731
+
732
+ if (typeof saveCharacterData !== 'undefined') {
733
+ saveCharacterData();
734
+ }
735
+ if (typeof buildSheet !== 'undefined') {
736
+ buildSheet(characterData);
737
+ }
738
+
739
+ // Announce to Roll20
740
+ if (typeof getColoredBanner !== 'undefined') {
741
+ const colorBanner = getColoredBanner(characterData);
742
+ const newCurrent = characterData.spellSlots[slotVar];
743
+ const messageData = {
744
+ action: 'announceSpell',
745
+ message: `&{template:default} {{name=${colorBanner}${characterData.name} uses Harness Divine Power}} {{🔮=Restored a Level ${level} spell slot! (${newCurrent}/${max})}}`,
746
+ color: characterData.notificationColor
747
+ };
748
+
749
+ // Send to Roll20
750
+ sendToRoll20(messageData);
751
+ }
752
+
753
+ if (typeof showNotification !== 'undefined') {
754
+ showNotification(`🔮 Harness Divine Power! Restored Level ${level} spell slot. Channel Divinity: ${channelDivinityResource.current}/${channelDivinityResource.max}`);
755
+ }
756
+ debug.log(`✨ Harness Divine Power used to restore Level ${level} spell slot`);
757
+ }
758
+
759
+ // ===== EXPORTS =====
760
+
761
+ window.buildResourcesDisplay = buildResourcesDisplay;
762
+ window.adjustResource = adjustResource;
763
+ window.getSorceryPointsResource = getSorceryPointsResource;
764
+ window.getKiPointsResource = getKiPointsResource;
765
+ window.findResourceByVariableName = findResourceByVariableName;
766
+ window.getResourceCostsFromAction = getResourceCostsFromAction;
767
+ window.getKiCostFromAction = getKiCostFromAction;
768
+ window.getSorceryPointCostFromAction = getSorceryPointCostFromAction;
769
+ window.decrementActionResources = decrementActionResources;
770
+ window.showConvertSlotToPointsModal = showConvertSlotToPointsModal;
771
+ window.showFontOfMagicModal = showFontOfMagicModal;
772
+ window.showSpellSlotRestorationModal = showSpellSlotRestorationModal;
773
+ window.restoreSpellSlot = restoreSpellSlot;
774
+
775
+ })();