@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,752 @@
1
+ /**
2
+ * HP Management Module
3
+ *
4
+ * Handles hit points, temporary HP, healing, damage, and resting.
5
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
6
+ *
7
+ * Functions exported to globalThis:
8
+ * - showHPModal()
9
+ * - takeShortRest()
10
+ * - takeLongRest()
11
+ * - getHitDieType()
12
+ * - initializeHitDice()
13
+ * - spendHitDice()
14
+ */
15
+
16
+ (function() {
17
+ 'use strict';
18
+
19
+ /**
20
+ * Show HP adjustment modal (heal, damage, temp HP)
21
+ */
22
+ function showHPModal() {
23
+ // characterData should be available from global scope
24
+ if (typeof characterData === 'undefined' || !characterData) {
25
+ if (typeof showNotification !== 'undefined') {
26
+ showNotification('❌ Character data not available', 'error');
27
+ }
28
+ return;
29
+ }
30
+
31
+ // Create modal overlay
32
+ const modal = document.createElement('div');
33
+ 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;';
34
+
35
+ // Create modal content
36
+ const modalContent = document.createElement('div');
37
+ 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: 300px;';
38
+
39
+ const currentHP = characterData.hitPoints.current;
40
+ const maxHP = characterData.hitPoints.max;
41
+ const tempHP = characterData.temporaryHP || 0;
42
+
43
+ modalContent.innerHTML = `
44
+ <h3 style="margin: 0 0 20px 0; color: var(--text-primary); text-align: center;">Adjust Hit Points</h3>
45
+ <div style="text-align: center; font-size: 1.2em; margin-bottom: 20px; color: var(--text-secondary);">
46
+ Current: <strong>${currentHP}${tempHP > 0 ? `+${tempHP}` : ''} / ${maxHP}</strong>
47
+ </div>
48
+
49
+ <div style="margin-bottom: 20px;">
50
+ <label style="display: block; margin-bottom: 10px; font-weight: bold; color: var(--text-primary);">Amount:</label>
51
+ <input type="number" id="hp-amount" min="1" value="1" style="width: 100%; padding: 10px; 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);">
52
+ </div>
53
+
54
+ <div style="margin-bottom: 25px;">
55
+ <label style="display: block; margin-bottom: 10px; font-weight: bold; color: var(--text-primary);">Action:</label>
56
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px;">
57
+ <button id="hp-toggle-heal" style="padding: 12px; font-size: 0.9em; font-weight: bold; border: 2px solid #27ae60; background: #27ae60; color: white; border-radius: 6px; cursor: pointer; transition: all 0.2s;">
58
+ 💚 Heal
59
+ </button>
60
+ <button id="hp-toggle-damage" style="padding: 12px; font-size: 0.9em; font-weight: bold; border: 2px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-secondary); border-radius: 6px; cursor: pointer; transition: all 0.2s;">
61
+ 💔 Damage
62
+ </button>
63
+ <button id="hp-toggle-temp" style="padding: 12px; font-size: 0.9em; font-weight: bold; border: 2px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-secondary); border-radius: 6px; cursor: pointer; transition: all 0.2s;">
64
+ 🛡️ Temp HP
65
+ </button>
66
+ </div>
67
+ </div>
68
+
69
+ <div style="display: flex; gap: 10px;">
70
+ <button id="hp-cancel" style="flex: 1; padding: 12px; font-size: 1em; background: #95a5a6; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">
71
+ Cancel
72
+ </button>
73
+ <button id="hp-confirm" style="flex: 1; padding: 12px; font-size: 1em; background: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">
74
+ Confirm
75
+ </button>
76
+ </div>
77
+ `;
78
+
79
+ modal.appendChild(modalContent);
80
+ document.body.appendChild(modal);
81
+
82
+ // Toggle state: 'heal', 'damage', or 'temp'
83
+ let actionType = 'heal';
84
+
85
+ const healBtn = document.getElementById('hp-toggle-heal');
86
+ const damageBtn = document.getElementById('hp-toggle-damage');
87
+ const tempBtn = document.getElementById('hp-toggle-temp');
88
+ const amountInput = document.getElementById('hp-amount');
89
+
90
+ // Return early if modal elements don't exist
91
+ if (!healBtn || !damageBtn || !tempBtn || !amountInput) {
92
+ debug.warn('⚠️ HP modal elements not found');
93
+ return;
94
+ }
95
+
96
+ // Helper function to reset all buttons
97
+ const resetButtons = () => {
98
+ healBtn.style.background = 'var(--bg-tertiary)';
99
+ healBtn.style.color = '#7f8c8d';
100
+ healBtn.style.borderColor = '#bdc3c7';
101
+ damageBtn.style.background = 'var(--bg-tertiary)';
102
+ damageBtn.style.color = '#7f8c8d';
103
+ damageBtn.style.borderColor = '#bdc3c7';
104
+ tempBtn.style.background = 'var(--bg-tertiary)';
105
+ tempBtn.style.color = '#7f8c8d';
106
+ tempBtn.style.borderColor = '#bdc3c7';
107
+ };
108
+
109
+ // Toggle button handlers
110
+ healBtn.addEventListener('click', () => {
111
+ actionType = 'heal';
112
+ resetButtons();
113
+ healBtn.style.background = '#27ae60';
114
+ healBtn.style.color = 'white';
115
+ healBtn.style.borderColor = '#27ae60';
116
+ });
117
+
118
+ damageBtn.addEventListener('click', () => {
119
+ actionType = 'damage';
120
+ resetButtons();
121
+ damageBtn.style.background = '#e74c3c';
122
+ damageBtn.style.color = 'white';
123
+ damageBtn.style.borderColor = '#e74c3c';
124
+ });
125
+
126
+ tempBtn.addEventListener('click', () => {
127
+ actionType = 'temp';
128
+ resetButtons();
129
+ tempBtn.style.background = '#3498db';
130
+ tempBtn.style.color = 'white';
131
+ tempBtn.style.borderColor = '#3498db';
132
+ });
133
+
134
+ // Cancel button
135
+ document.getElementById('hp-cancel').addEventListener('click', () => {
136
+ modal.remove();
137
+ });
138
+
139
+ // Confirm button
140
+ document.getElementById('hp-confirm').addEventListener('click', () => {
141
+ const amount = parseInt(amountInput.value);
142
+
143
+ if (isNaN(amount) || amount <= 0) {
144
+ if (typeof showNotification !== 'undefined') {
145
+ showNotification('❌ Please enter a valid amount', 'error');
146
+ }
147
+ return;
148
+ }
149
+
150
+ const oldHP = characterData.hitPoints.current;
151
+ const oldTempHP = characterData.temporaryHP || 0;
152
+ const colorBanner = typeof getColoredBanner !== 'undefined' ? getColoredBanner(characterData) : '';
153
+ let messageData;
154
+
155
+ if (actionType === 'heal') {
156
+ // Healing: increase current HP (up to max), doesn't affect temp HP (RAW)
157
+ characterData.hitPoints.current = Math.min(currentHP + amount, maxHP);
158
+ const actualHealing = characterData.hitPoints.current - oldHP;
159
+
160
+ // Reset death saves on healing
161
+ if (actualHealing > 0 && characterData.deathSaves && (characterData.deathSaves.successes > 0 || characterData.deathSaves.failures > 0)) {
162
+ characterData.deathSaves.successes = 0;
163
+ characterData.deathSaves.failures = 0;
164
+ debug.log('♻️ Death saves reset due to healing');
165
+ }
166
+
167
+ if (typeof showNotification !== 'undefined') {
168
+ showNotification(`💚 Healed ${actualHealing} HP! (${characterData.hitPoints.current}${characterData.temporaryHP > 0 ? `+${characterData.temporaryHP}` : ''}/${maxHP})`);
169
+ }
170
+
171
+ messageData = {
172
+ action: 'announceSpell',
173
+ message: `&{template:default} {{name=${colorBanner}${characterData.name} regains HP}} {{💚 Healing=${actualHealing} HP}} {{Current HP=${characterData.hitPoints.current}${characterData.temporaryHP > 0 ? `+${characterData.temporaryHP}` : ''}/${maxHP}}}`,
174
+ color: characterData.notificationColor
175
+ };
176
+ } else if (actionType === 'damage') {
177
+ // Damage: deplete temp HP first, then current HP (RAW)
178
+ let remainingDamage = amount;
179
+ let tempHPLost = 0;
180
+ let actualDamage = 0;
181
+
182
+ if (characterData.temporaryHP > 0) {
183
+ tempHPLost = Math.min(characterData.temporaryHP, remainingDamage);
184
+ characterData.temporaryHP -= tempHPLost;
185
+ remainingDamage -= tempHPLost;
186
+ }
187
+
188
+ if (remainingDamage > 0) {
189
+ characterData.hitPoints.current = Math.max(currentHP - remainingDamage, 0);
190
+ actualDamage = oldHP - characterData.hitPoints.current;
191
+ }
192
+
193
+ const damageMsg = tempHPLost > 0
194
+ ? `💔 Took ${amount} damage! (${tempHPLost} temp HP${actualDamage > 0 ? ` + ${actualDamage} HP` : ''})`
195
+ : `💔 Took ${actualDamage} damage!`;
196
+
197
+ if (typeof showNotification !== 'undefined') {
198
+ showNotification(`${damageMsg} (${characterData.hitPoints.current}${characterData.temporaryHP > 0 ? `+${characterData.temporaryHP}` : ''}/${maxHP})`);
199
+ }
200
+
201
+ const damageDetails = tempHPLost > 0
202
+ ? `{{Temp HP Lost=${tempHPLost}}}${actualDamage > 0 ? ` {{HP Lost=${actualDamage}}}` : ''}`
203
+ : `{{HP Lost=${actualDamage}}}`;
204
+
205
+ messageData = {
206
+ action: 'announceSpell',
207
+ message: `&{template:default} {{name=${colorBanner}${characterData.name} takes damage}} {{💔 Total Damage=${amount}}} ${damageDetails} {{Current HP=${characterData.hitPoints.current}${characterData.temporaryHP > 0 ? `+${characterData.temporaryHP}` : ''}/${maxHP}}}`,
208
+ color: characterData.notificationColor
209
+ };
210
+ } else if (actionType === 'temp') {
211
+ // Temp HP: RAW rules - new temp HP replaces old if higher, otherwise keep old
212
+ const newTempHP = amount;
213
+ if (newTempHP > oldTempHP) {
214
+ characterData.temporaryHP = newTempHP;
215
+ if (typeof showNotification !== 'undefined') {
216
+ showNotification(`🛡️ Gained ${newTempHP} temp HP! (${characterData.hitPoints.current}+${characterData.temporaryHP}/${maxHP})`);
217
+ }
218
+
219
+ messageData = {
220
+ action: 'announceSpell',
221
+ message: `&{template:default} {{name=${colorBanner}${characterData.name} gains temp HP}} {{🛡️ Temp HP=${newTempHP}}} {{Current HP=${characterData.hitPoints.current}+${characterData.temporaryHP}/${maxHP}}}`,
222
+ color: characterData.notificationColor
223
+ };
224
+ } else {
225
+ if (typeof showNotification !== 'undefined') {
226
+ showNotification(`⚠️ Kept ${oldTempHP} temp HP (higher than ${newTempHP})`);
227
+ }
228
+ modal.remove();
229
+ return; // Don't send message if temp HP wasn't gained
230
+ }
231
+ }
232
+
233
+ // Send message to Roll20
234
+ if (messageData) {
235
+ sendToRoll20(messageData);
236
+ }
237
+
238
+ // Save and rebuild sheet
239
+ if (typeof saveCharacterData !== 'undefined') {
240
+ saveCharacterData();
241
+ }
242
+ if (typeof buildSheet !== 'undefined') {
243
+ buildSheet(characterData);
244
+ }
245
+ modal.remove();
246
+ });
247
+
248
+ // Focus on input
249
+ amountInput.focus();
250
+ amountInput.select();
251
+
252
+ // Allow Enter key to confirm
253
+ amountInput.addEventListener('keypress', (e) => {
254
+ if (e.key === 'Enter') {
255
+ document.getElementById('hp-confirm').click();
256
+ }
257
+ });
258
+
259
+ // Click outside to close
260
+ modal.addEventListener('click', (e) => {
261
+ if (e.target === modal) {
262
+ modal.remove();
263
+ }
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Get hit die type based on character class
269
+ */
270
+ function getHitDieType() {
271
+ // characterData should be available from global scope
272
+ if (typeof characterData === 'undefined' || !characterData) return 'd8';
273
+
274
+ // Determine hit die based on class (D&D 5e)
275
+ const className = (characterData.class || '').toLowerCase();
276
+
277
+ const hitDiceMap = {
278
+ 'barbarian': 'd12',
279
+ 'fighter': 'd10',
280
+ 'paladin': 'd10',
281
+ 'ranger': 'd10',
282
+ 'bard': 'd8',
283
+ 'cleric': 'd8',
284
+ 'druid': 'd8',
285
+ 'monk': 'd8',
286
+ 'rogue': 'd8',
287
+ 'warlock': 'd8',
288
+ 'sorcerer': 'd6',
289
+ 'wizard': 'd6'
290
+ };
291
+
292
+ for (const [classKey, die] of Object.entries(hitDiceMap)) {
293
+ if (className.includes(classKey)) {
294
+ return die;
295
+ }
296
+ }
297
+
298
+ // Default to d8 if class not found
299
+ return 'd8';
300
+ }
301
+
302
+ /**
303
+ * Initialize hit dice if not already set
304
+ */
305
+ function initializeHitDice() {
306
+ if (typeof characterData === 'undefined' || !characterData) return;
307
+
308
+ // Initialize hit dice if not already set
309
+ if (characterData.hitDice === undefined) {
310
+ const level = characterData.level || 1;
311
+ characterData.hitDice = {
312
+ current: level,
313
+ max: level,
314
+ type: getHitDieType()
315
+ };
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Spend hit dice to restore HP (used during short rest)
321
+ */
322
+ function spendHitDice() {
323
+ if (typeof characterData === 'undefined' || !characterData) return;
324
+
325
+ initializeHitDice();
326
+
327
+ const conMod = characterData.attributeMods?.constitution || 0;
328
+ const hitDie = characterData.hitDice.type;
329
+ const maxDice = parseInt(hitDie.substring(1)); // Extract number from "d8" -> 8
330
+
331
+ if (characterData.hitDice.current <= 0) {
332
+ alert('You have no Hit Dice remaining to spend!');
333
+ return;
334
+ }
335
+
336
+ let totalHealed = 0;
337
+ let diceSpent = 0;
338
+
339
+ while (characterData.hitDice.current > 0 && characterData.hitPoints.current < characterData.hitPoints.max) {
340
+ const spend = confirm(
341
+ `Spend a Hit Die? (${characterData.hitDice.current}/${characterData.hitDice.max} remaining)\n\n` +
342
+ `Hit Die: ${hitDie}\n` +
343
+ `CON Modifier: ${conMod >= 0 ? '+' : ''}${conMod}\n` +
344
+ `Current HP: ${characterData.hitPoints.current}/${characterData.hitPoints.max}\n` +
345
+ `HP Healed so far: ${totalHealed}`
346
+ );
347
+
348
+ if (!spend) break;
349
+
350
+ // Roll the hit die
351
+ const roll = Math.floor(Math.random() * maxDice) + 1;
352
+ const healing = Math.max(1, roll + conMod); // Minimum 1 HP restored
353
+
354
+ characterData.hitDice.current--;
355
+ diceSpent++;
356
+
357
+ const oldHP = characterData.hitPoints.current;
358
+ characterData.hitPoints.current = Math.min(
359
+ characterData.hitPoints.current + healing,
360
+ characterData.hitPoints.max
361
+ );
362
+ const actualHealing = characterData.hitPoints.current - oldHP;
363
+ totalHealed += actualHealing;
364
+
365
+ debug.log(`🎲 Rolled ${hitDie}: ${roll} + ${conMod} = ${healing} HP (restored ${actualHealing})`);
366
+
367
+ // Announce the roll with fancy formatting
368
+ const colorBanner = typeof getColoredBanner !== 'undefined' ? getColoredBanner(characterData) : '';
369
+ sendToRoll20({
370
+ action: 'announceSpell',
371
+ message: `&{template:default} {{name=${colorBanner}${characterData.name} spends hit dice}} {{Roll=🎲 ${hitDie}: ${roll} + ${conMod} CON}} {{HP Restored=${healing}}} {{Current HP=${characterData.hitPoints.current}/${characterData.hitPoints.max}}}`,
372
+ color: characterData.notificationColor
373
+ });
374
+ }
375
+
376
+ if (diceSpent > 0) {
377
+ if (typeof showNotification !== 'undefined') {
378
+ showNotification(`🎲 Spent ${diceSpent} Hit Dice and restored ${totalHealed} HP!`);
379
+ }
380
+ } else {
381
+ if (typeof showNotification !== 'undefined') {
382
+ showNotification('No Hit Dice spent.');
383
+ }
384
+ }
385
+
386
+ // Announce short rest completion to Roll20
387
+ const colorBanner = typeof getColoredBanner !== 'undefined' ? getColoredBanner(characterData) : '';
388
+ const announcement = `&{template:default} {{name=${colorBanner}${characterData.name} takes a Short Rest!}} {{Type=Short Rest}} {{HP=${characterData.hitPoints.current}/${characterData.hitPoints.max}}}`;
389
+ const messageData = {
390
+ action: 'announceSpell',
391
+ message: announcement,
392
+ color: characterData.notificationColor
393
+ };
394
+
395
+ sendToRoll20(messageData);
396
+ }
397
+
398
+ /**
399
+ * Take a short rest - restores some resources and allows spending hit dice
400
+ */
401
+ function takeShortRest() {
402
+ if (typeof characterData === 'undefined' || !characterData) {
403
+ if (typeof showNotification !== 'undefined') {
404
+ showNotification('❌ Character data not available', 'error');
405
+ }
406
+ return;
407
+ }
408
+
409
+ const confirmed = confirm('Take a Short Rest?\n\nThis will:\n- Allow you to spend Hit Dice to restore HP\n- Restore Warlock spell slots\n- Restore some class features');
410
+
411
+ if (!confirmed) return;
412
+
413
+ debug.log('☕ Taking short rest...');
414
+
415
+ // Clear temporary HP (RAW: temp HP doesn't persist through rest)
416
+ if (characterData.temporaryHP > 0) {
417
+ characterData.temporaryHP = 0;
418
+ debug.log('✅ Cleared temporary HP');
419
+ }
420
+
421
+ // Note: Inspiration is NOT restored on short rest (DM grants it)
422
+ debug.log(`ℹ️ Inspiration status unchanged (${characterData.inspiration ? 'active' : 'none'})`);
423
+
424
+ // Restore Warlock Pact Magic slots (they recharge on short rest)
425
+ // Check both spellSlots and otherVariables for Pact Magic
426
+ if (characterData.spellSlots && characterData.spellSlots.pactMagicSlotsMax !== undefined) {
427
+ characterData.spellSlots.pactMagicSlots = characterData.spellSlots.pactMagicSlotsMax;
428
+ debug.log(`✅ Restored Pact Magic slots (spellSlots): ${characterData.spellSlots.pactMagicSlots}/${characterData.spellSlots.pactMagicSlotsMax}`);
429
+ }
430
+ if (characterData.otherVariables) {
431
+ if (characterData.otherVariables.pactMagicSlotsMax !== undefined) {
432
+ characterData.otherVariables.pactMagicSlots = characterData.otherVariables.pactMagicSlotsMax;
433
+ debug.log('✅ Restored Pact Magic slots (otherVariables)');
434
+ }
435
+
436
+ // Restore Ki points for Monk (short rest feature)
437
+ if (characterData.otherVariables.kiMax !== undefined) {
438
+ characterData.otherVariables.ki = characterData.otherVariables.kiMax;
439
+ debug.log('✅ Restored Ki points');
440
+ } else if (characterData.otherVariables.kiPointsMax !== undefined) {
441
+ characterData.otherVariables.kiPoints = characterData.otherVariables.kiPointsMax;
442
+ debug.log('✅ Restored Ki points');
443
+ }
444
+
445
+ // Restore Action Surge, Second Wind (short rest features)
446
+ if (characterData.otherVariables.actionSurgeMax !== undefined) {
447
+ characterData.otherVariables.actionSurge = characterData.otherVariables.actionSurgeMax;
448
+ }
449
+ if (characterData.otherVariables.secondWindMax !== undefined) {
450
+ characterData.otherVariables.secondWind = characterData.otherVariables.secondWindMax;
451
+ }
452
+ }
453
+
454
+ // Handle Hit Dice spending for HP restoration
455
+ spendHitDice();
456
+
457
+ // Restore class resources that recharge on short rest
458
+ // Most resources restore on short rest (Ki, Channel Divinity, Action Surge, etc.)
459
+ // Notable exceptions: Sorcery Points and Rage restore on long rest only
460
+ if (characterData.resources && characterData.resources.length > 0) {
461
+ characterData.resources.forEach(resource => {
462
+ const lowerName = resource.name.toLowerCase();
463
+
464
+ // Long rest only resources
465
+ if (lowerName.includes('sorcery') || lowerName.includes('rage')) {
466
+ debug.log(`⏭️ Skipping ${resource.name} (long rest only)`);
467
+ return;
468
+ }
469
+
470
+ // Restore all other resources
471
+ resource.current = resource.max;
472
+
473
+ // Also update otherVariables to keep data in sync
474
+ if (characterData.otherVariables && resource.varName) {
475
+ characterData.otherVariables[resource.varName] = resource.current;
476
+ }
477
+
478
+ debug.log(`✅ Restored ${resource.name} (${resource.current}/${resource.max})`);
479
+ });
480
+ }
481
+
482
+ // Reset limited uses for short rest abilities
483
+ if (characterData.actions) {
484
+ characterData.actions.forEach(action => {
485
+ if (action.uses) {
486
+ // Check if this ability resets on short rest
487
+ // DiceCloud uses 'reset' property with values: 'shortRest', 'longRest', etc.
488
+ const resetType = action.reset || action.uses?.reset;
489
+ const resetsOnShortRest =
490
+ resetType === 'shortRest' ||
491
+ resetType === 'short_rest' ||
492
+ resetType === 'short rest' ||
493
+ resetType === 'shortOrLongRest';
494
+
495
+ if (!resetsOnShortRest) {
496
+ debug.log(`⏭️ Skipping ${action.name} (does not reset on short rest, reset=${resetType})`);
497
+ return;
498
+ }
499
+
500
+ // Handle usesUsed pattern (older/local data)
501
+ if (action.usesUsed !== undefined && action.usesUsed > 0) {
502
+ action.usesUsed = 0;
503
+ debug.log(`✅ Reset uses for ${action.name}`);
504
+ }
505
+
506
+ // Handle usesLeft pattern (2024 D&D features, database data)
507
+ if (action.usesLeft !== undefined) {
508
+ const usesTotal = action.uses.total || action.uses.value || action.uses;
509
+ action.usesLeft = usesTotal;
510
+ debug.log(`✅ Restored ${action.name} (${action.usesLeft}/${usesTotal} uses)`);
511
+ }
512
+ }
513
+ });
514
+ }
515
+
516
+ // Save and rebuild sheet
517
+ if (typeof saveCharacterData !== 'undefined') {
518
+ saveCharacterData();
519
+ }
520
+ if (typeof buildSheet !== 'undefined') {
521
+ buildSheet(characterData);
522
+ }
523
+
524
+ if (typeof showNotification !== 'undefined') {
525
+ showNotification('☕ Short Rest complete! Resources recharged.');
526
+ }
527
+ debug.log('✅ Short rest complete');
528
+
529
+ // Announce to Roll20 with fancy formatting
530
+ const colorBanner = typeof getColoredBanner !== 'undefined' ? getColoredBanner(characterData) : '';
531
+ const messageData = {
532
+ action: 'announceSpell',
533
+ message: `&{template:default} {{name=${colorBanner}${characterData.name} takes a short rest}} {{=☕ Short rest complete. Resources recharged!}}`,
534
+ color: characterData.notificationColor
535
+ };
536
+
537
+ sendToRoll20(messageData);
538
+ }
539
+
540
+ /**
541
+ * Take a long rest - fully restores HP, spell slots, and all resources
542
+ */
543
+ function takeLongRest() {
544
+ if (typeof characterData === 'undefined' || !characterData) {
545
+ if (typeof showNotification !== 'undefined') {
546
+ showNotification('❌ Character data not available', 'error');
547
+ }
548
+ return;
549
+ }
550
+
551
+ const confirmed = confirm('Take a Long Rest?\n\nThis will:\n- Fully restore HP\n- Restore all spell slots\n- Restore all class features\n- Restore half your hit dice (minimum 1)');
552
+
553
+ if (!confirmed) return;
554
+
555
+ debug.log('🌙 Taking long rest...');
556
+
557
+ // Initialize hit dice if needed
558
+ initializeHitDice();
559
+
560
+ // Restore all HP
561
+ characterData.hitPoints.current = characterData.hitPoints.max;
562
+ debug.log('✅ Restored HP to max');
563
+
564
+ // Clear temporary HP (RAW: temp HP doesn't persist through rest)
565
+ if (characterData.temporaryHP > 0) {
566
+ characterData.temporaryHP = 0;
567
+ debug.log('✅ Cleared temporary HP');
568
+ }
569
+
570
+ // Note: Inspiration is NOT automatically restored on long rest
571
+ // It must be granted by the DM, so we don't touch it here
572
+ debug.log(`ℹ️ Inspiration status unchanged (${characterData.inspiration ? 'active' : 'none'})`);
573
+
574
+ // Restore hit dice (half of max, minimum 1)
575
+ const hitDiceRestored = Math.max(1, Math.floor(characterData.hitDice.max / 2));
576
+ const oldHitDice = characterData.hitDice.current;
577
+ characterData.hitDice.current = Math.min(
578
+ characterData.hitDice.current + hitDiceRestored,
579
+ characterData.hitDice.max
580
+ );
581
+ debug.log(`✅ Restored ${characterData.hitDice.current - oldHitDice} hit dice (${characterData.hitDice.current}/${characterData.hitDice.max})`);
582
+
583
+ // Restore all spell slots
584
+ if (characterData.spellSlots) {
585
+ // Restore regular spell slots (levels 1-9)
586
+ // Supports flat keys (level1SpellSlotsMax) and nested format ({ level1: { current, max } })
587
+ for (let level = 1; level <= 9; level++) {
588
+ const slotVar = `level${level}SpellSlots`;
589
+ const slotMaxVar = `level${level}SpellSlotsMax`;
590
+ const nested = characterData.spellSlots[`level${level}`];
591
+
592
+ // Restore BOTH formats independently. After buildSheet normalizes nested
593
+ // -> flat, the data carries both at once; the display and casting read the
594
+ // flat key, so an `else if` here (restoring only nested) left the flat key
595
+ // stale and slots appeared un-restored after a long rest.
596
+ if (nested && nested.max !== undefined) {
597
+ nested.current = nested.max;
598
+ }
599
+ if (characterData.spellSlots[slotMaxVar] !== undefined) {
600
+ characterData.spellSlots[slotVar] = characterData.spellSlots[slotMaxVar];
601
+ debug.log(`✅ Restored level ${level} spell slots`);
602
+ } else if (nested && nested.max !== undefined) {
603
+ // No flat max key yet — derive flat from nested so the display updates.
604
+ characterData.spellSlots[slotVar] = nested.max;
605
+ characterData.spellSlots[slotMaxVar] = nested.max;
606
+ debug.log(`✅ Restored level ${level} spell slots (from nested)`);
607
+ }
608
+ }
609
+
610
+ // Also restore Pact Magic slots (Warlock)
611
+ if (characterData.spellSlots.pactMagicSlotsMax !== undefined) {
612
+ characterData.spellSlots.pactMagicSlots = characterData.spellSlots.pactMagicSlotsMax;
613
+ debug.log(`✅ Restored Pact Magic slots: ${characterData.spellSlots.pactMagicSlots}/${characterData.spellSlots.pactMagicSlotsMax}`);
614
+ }
615
+ }
616
+
617
+ // Restore all class resources (Ki, Sorcery Points, Rage, etc.)
618
+ if (characterData.resources && characterData.resources.length > 0) {
619
+ characterData.resources.forEach(resource => {
620
+ resource.current = resource.max;
621
+
622
+ // Also update otherVariables to keep data in sync
623
+ if (characterData.otherVariables && resource.varName) {
624
+ characterData.otherVariables[resource.varName] = resource.current;
625
+ }
626
+
627
+ debug.log(`✅ Restored ${resource.name} (${resource.current}/${resource.max})`);
628
+ });
629
+ }
630
+
631
+ // Restore all class resources
632
+ if (characterData.otherVariables) {
633
+ Object.keys(characterData.otherVariables).forEach(key => {
634
+ // If there's a Max variant, restore to max
635
+ if (key.endsWith('Max')) {
636
+ const baseKey = key.replace('Max', '');
637
+ if (characterData.otherVariables[baseKey] !== undefined) {
638
+ characterData.otherVariables[baseKey] = characterData.otherVariables[key];
639
+ debug.log(`✅ Restored ${baseKey}`);
640
+ }
641
+ }
642
+ });
643
+
644
+ // Also restore specific resources that might not follow the Max pattern
645
+ if (characterData.otherVariables.kiMax !== undefined) {
646
+ characterData.otherVariables.ki = characterData.otherVariables.kiMax;
647
+ } else if (characterData.otherVariables.kiPointsMax !== undefined) {
648
+ characterData.otherVariables.kiPoints = characterData.otherVariables.kiPointsMax;
649
+ }
650
+
651
+ if (characterData.otherVariables.sorceryPointsMax !== undefined) {
652
+ characterData.otherVariables.sorceryPoints = characterData.otherVariables.sorceryPointsMax;
653
+ }
654
+
655
+ if (characterData.otherVariables.pactMagicSlotsMax !== undefined) {
656
+ characterData.otherVariables.pactMagicSlots = characterData.otherVariables.pactMagicSlotsMax;
657
+ }
658
+
659
+ // Restore Channel Divinity (try all possible variable names)
660
+ if (characterData.otherVariables.channelDivinityClericMax !== undefined) {
661
+ characterData.otherVariables.channelDivinityCleric = characterData.otherVariables.channelDivinityClericMax;
662
+ } else if (characterData.otherVariables.channelDivinityPaladinMax !== undefined) {
663
+ characterData.otherVariables.channelDivinityPaladin = characterData.otherVariables.channelDivinityPaladinMax;
664
+ } else if (characterData.otherVariables.channelDivinityMax !== undefined) {
665
+ characterData.otherVariables.channelDivinity = characterData.otherVariables.channelDivinityMax;
666
+ }
667
+ }
668
+
669
+ // Reset limited uses for long rest abilities
670
+ if (characterData.actions) {
671
+ characterData.actions.forEach(action => {
672
+ if (action.uses) {
673
+ // Check if this ability resets on long rest
674
+ // DiceCloud uses 'reset' property with values: 'shortRest', 'longRest', 'special', etc.
675
+ const resetType = action.reset || action.uses?.reset;
676
+
677
+ // Long rest resets both short rest and long rest abilities, but NOT special reset abilities
678
+ const resetsOnLongRest =
679
+ resetType === 'longRest' ||
680
+ resetType === 'long_rest' ||
681
+ resetType === 'long rest' ||
682
+ resetType === 'shortRest' ||
683
+ resetType === 'short_rest' ||
684
+ resetType === 'short rest' ||
685
+ resetType === 'shortOrLongRest';
686
+
687
+ // Check for special reset conditions (like Feline Agility which resets when not moving)
688
+ const isSpecialReset =
689
+ resetType === 'special' ||
690
+ resetType === 'custom' ||
691
+ (typeof resetType === 'string' && resetType.toLowerCase().includes('agility'));
692
+
693
+ if (isSpecialReset) {
694
+ debug.log(`⏭️ Skipping ${action.name} (special reset condition, reset=${resetType})`);
695
+ return;
696
+ }
697
+
698
+ if (!resetsOnLongRest) {
699
+ debug.log(`⏭️ Skipping ${action.name} (does not reset on long rest, reset=${resetType})`);
700
+ return;
701
+ }
702
+
703
+ // Handle usesUsed pattern (older/local data)
704
+ if (action.usesUsed !== undefined && action.usesUsed > 0) {
705
+ action.usesUsed = 0;
706
+ debug.log(`✅ Reset uses for ${action.name}`);
707
+ }
708
+
709
+ // Handle usesLeft pattern (2024 D&D features, database data)
710
+ if (action.usesLeft !== undefined) {
711
+ const usesTotal = action.uses.total || action.uses.value || action.uses;
712
+ action.usesLeft = usesTotal;
713
+ debug.log(`✅ Restored ${action.name} (${action.usesLeft}/${usesTotal} uses)`);
714
+ }
715
+ }
716
+ });
717
+ }
718
+
719
+ // Save and rebuild sheet
720
+ if (typeof saveCharacterData !== 'undefined') {
721
+ saveCharacterData();
722
+ }
723
+ if (typeof buildSheet !== 'undefined') {
724
+ buildSheet(characterData);
725
+ }
726
+
727
+ if (typeof showNotification !== 'undefined') {
728
+ showNotification('🌙 Long Rest complete! All resources restored.');
729
+ }
730
+ debug.log('✅ Long rest complete');
731
+
732
+ // Announce to Roll20 with fancy formatting
733
+ const colorBanner = typeof getColoredBanner !== 'undefined' ? getColoredBanner(characterData) : '';
734
+ const messageData = {
735
+ action: 'announceSpell',
736
+ message: `&{template:default} {{name=${colorBanner}${characterData.name} takes a long rest}} {{=🌙 Long rest complete!}} {{HP=${characterData.hitPoints.current}/${characterData.hitPoints.max} (Fully Restored)}} {{=All spell slots and resources restored!}}`,
737
+ color: characterData.notificationColor
738
+ };
739
+
740
+ sendToRoll20(messageData);
741
+ }
742
+
743
+ // ===== EXPORTS =====
744
+
745
+ globalThis.showHPModal = showHPModal;
746
+ globalThis.takeShortRest = takeShortRest;
747
+ globalThis.takeLongRest = takeLongRest;
748
+ globalThis.getHitDieType = getHitDieType;
749
+ globalThis.initializeHitDice = initializeHitDice;
750
+ globalThis.spendHitDice = spendHitDice;
751
+
752
+ })();