@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,654 @@
1
+ /**
2
+ * Sheet Builder Module
3
+ *
4
+ * Handles the main character sheet UI construction.
5
+ * Builds all UI sections including:
6
+ * - Character header (name, class, level, race)
7
+ * - Combat stats (AC, HP, initiative, death saves, inspiration)
8
+ * - Abilities, saves, and skills
9
+ * - Actions & attacks
10
+ * - Spells (organized by source and level)
11
+ * - Inventory & equipment
12
+ * - Companions (familiars, summons, animal companions)
13
+ * - Resources and spell slots
14
+ *
15
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
16
+ *
17
+ * Functions exported to globalThis:
18
+ * - buildSheet(data)
19
+ */
20
+
21
+ (function() {
22
+ 'use strict';
23
+
24
+ /**
25
+ * Build the entire character sheet UI from character data
26
+ * @param {Object} data - Character data object
27
+ */
28
+ function buildSheet(data) {
29
+ debug.log('Building character sheet...');
30
+ debug.log('📊 Character data received:', data);
31
+ debug.log('✨ Spell slots data:', data.spellSlots);
32
+
33
+ // Normalize snake_case fields to camelCase (database uses snake_case, UI expects camelCase)
34
+ debug.log('🔄 HP normalization check:', {
35
+ has_hit_points: !!data.hit_points,
36
+ has_hitPoints: !!data.hitPoints,
37
+ hit_points_value: data.hit_points,
38
+ hitPoints_value: data.hitPoints,
39
+ hitPoints_type: typeof data.hitPoints,
40
+ full_data_keys: Object.keys(data)
41
+ });
42
+
43
+ if (data.hit_points && !data.hitPoints) {
44
+ data.hitPoints = data.hit_points;
45
+ debug.log('✅ Normalized hit_points to hitPoints:', data.hitPoints);
46
+ } else if (!data.hit_points && !data.hitPoints) {
47
+ debug.warn('⚠️ No HP data found in character data! Keys available:', Object.keys(data));
48
+ }
49
+ if (data.character_name && !data.name) {
50
+ data.name = data.character_name;
51
+ }
52
+ if (data.armor_class !== undefined && data.armorClass === undefined) {
53
+ data.armorClass = data.armor_class;
54
+ }
55
+ if (data.hit_dice && !data.hitDice) {
56
+ data.hitDice = data.hit_dice;
57
+ }
58
+ if (data.temporary_hp !== undefined && data.temporaryHP === undefined) {
59
+ data.temporaryHP = data.temporary_hp;
60
+ }
61
+ if (data.death_saves && !data.deathSaves) {
62
+ data.deathSaves = data.death_saves;
63
+ }
64
+ if (data.proficiency_bonus !== undefined && data.proficiencyBonus === undefined) {
65
+ data.proficiencyBonus = data.proficiency_bonus;
66
+ }
67
+ if (data.spell_slots && !data.spellSlots) {
68
+ data.spellSlots = data.spell_slots;
69
+ }
70
+ // Normalize nested spell-slot format ({ level1: { current, max } }) into the
71
+ // flat keys (level1SpellSlots / level1SpellSlotsMax) that the cast, upcast,
72
+ // and Divine Smite modals read. Some parsers emit nested, others flat; the
73
+ // slot *display* tolerates both, but those modals read flat only — so with
74
+ // nested data the slots render yet never decrement on cast and Divine Smite
75
+ // lists no levels. Flat is authoritative (casting decrements the flat key),
76
+ // so we only fill flat keys that are missing and never clobber them.
77
+ if (data.spellSlots) {
78
+ for (let level = 1; level <= 9; level++) {
79
+ const nested = data.spellSlots[`level${level}`];
80
+ if (nested && typeof nested === 'object') {
81
+ const curKey = `level${level}SpellSlots`;
82
+ const maxKey = `level${level}SpellSlotsMax`;
83
+ if (data.spellSlots[maxKey] === undefined && nested.max !== undefined) {
84
+ data.spellSlots[maxKey] = nested.max;
85
+ }
86
+ if (data.spellSlots[curKey] === undefined && nested.current !== undefined) {
87
+ data.spellSlots[curKey] = nested.current;
88
+ }
89
+ }
90
+ }
91
+ }
92
+ if (data.attribute_mods && !data.attributeMods) {
93
+ data.attributeMods = data.attribute_mods;
94
+ }
95
+ if (data.notification_color && !data.notificationColor) {
96
+ data.notificationColor = data.notification_color;
97
+ }
98
+
99
+ // DEBUG: Log actions and spells arrays
100
+ debug.log('🔍 Actions array check:', {
101
+ has_actions: !!data.actions,
102
+ is_array: Array.isArray(data.actions),
103
+ length: data.actions?.length,
104
+ first_action: data.actions?.[0]?.name
105
+ });
106
+ debug.log('🔍 Spells array check:', {
107
+ has_spells: !!data.spells,
108
+ is_array: Array.isArray(data.spells),
109
+ length: data.spells?.length,
110
+ first_spell: data.spells?.[0]?.name
111
+ });
112
+
113
+ // Safety check: Ensure critical DOM elements exist before building
114
+ const charNameEl = document.getElementById('char-name');
115
+ if (!charNameEl) {
116
+ debug.error('❌ Critical DOM elements not found! DOM may not be ready yet.');
117
+ debug.log('⏳ Queuing buildSheet for when DOM is ready...');
118
+ if (typeof domReady !== 'undefined' && !domReady) {
119
+ if (typeof pendingOperations !== 'undefined') {
120
+ pendingOperations.push(() => buildSheet(data));
121
+ }
122
+ } else {
123
+ // DOM claims to be ready but elements aren't there - retry after a short delay
124
+ debug.log('⏱️ DOM ready but elements missing - retrying in 100ms...');
125
+ setTimeout(() => buildSheet(data), 100);
126
+ }
127
+ return;
128
+ }
129
+
130
+ // Store character data globally for other modules to access
131
+ globalThis.characterData = data;
132
+
133
+ // Helper function to safely set element properties
134
+ const safeSet = (id, prop, value) => {
135
+ const el = document.getElementById(id);
136
+ if (el) el[prop] = value;
137
+ return el;
138
+ };
139
+
140
+ // Initialize concentration from saved data
141
+ if (data.concentration) {
142
+ if (typeof window.concentratingSpell !== 'undefined') {
143
+ window.concentratingSpell = data.concentration;
144
+ }
145
+ if (typeof updateConcentrationDisplay === 'function') {
146
+ updateConcentrationDisplay();
147
+ }
148
+ debug.log(`🧠 Restored concentration: ${data.concentration}`);
149
+ } else {
150
+ if (typeof window.concentratingSpell !== 'undefined') {
151
+ window.concentratingSpell = null;
152
+ }
153
+ if (typeof updateConcentrationDisplay === 'function') {
154
+ updateConcentrationDisplay();
155
+ }
156
+ }
157
+
158
+ // Character name (without source badge)
159
+ const characterName = data.name || 'Character';
160
+ charNameEl.textContent = characterName;
161
+
162
+ // Update color picker emoji in systems bar
163
+ const currentColorEmoji = getColorEmoji(data.notificationColor || '#3498db');
164
+ const colorEmojiEl = document.getElementById('color-emoji');
165
+ if (colorEmojiEl) {
166
+ colorEmojiEl.textContent = currentColorEmoji;
167
+ }
168
+
169
+ // Populate color palette in systems bar (but keep it hidden initially)
170
+ const colorPaletteEl = document.getElementById('color-palette');
171
+ if (colorPaletteEl) {
172
+ colorPaletteEl.innerHTML = createColorPalette(data.notificationColor || '#3498db');
173
+ colorPaletteEl.style.display = 'none'; // Start hidden - user must click to show
174
+ colorPaletteEl.style.gridTemplateColumns = 'repeat(4, 1fr)';
175
+ colorPaletteEl.style.gap = '10px';
176
+ colorPaletteEl.style.width = '180px';
177
+ }
178
+
179
+ // Initialize hit dice if needed
180
+ initializeHitDice();
181
+
182
+ // Initialize temporary HP if needed
183
+ if (data.temporaryHP === undefined) {
184
+ data.temporaryHP = 0;
185
+ }
186
+
187
+ // Initialize inspiration if needed
188
+ if (data.inspiration === undefined) {
189
+ data.inspiration = false;
190
+ }
191
+
192
+ // Initialize last roll tracking for heroic inspiration
193
+ if (data.lastRoll === undefined) {
194
+ data.lastRoll = null;
195
+ }
196
+
197
+ // Capitalize race name - handle both string and object formats
198
+ let raceName = 'Unknown';
199
+ if (data.race) {
200
+ if (typeof data.race === 'string') {
201
+ raceName = data.race.charAt(0).toUpperCase() + data.race.slice(1);
202
+ } else if (typeof data.race === 'object') {
203
+ // If race is an object, try to extract the value from various possible properties
204
+ let raceValue = data.race.value || data.race.name || data.race.text ||
205
+ data.race.variableName || data.race.displayName;
206
+
207
+ // If still no value, try to get something useful from the object
208
+ if (!raceValue) {
209
+ // Check if it has a tags property that might indicate race type
210
+ if (data.race.tags && Array.isArray(data.race.tags)) {
211
+ const raceTags = data.race.tags.filter(tag =>
212
+ !tag.toLowerCase().includes('class') &&
213
+ !tag.toLowerCase().includes('level')
214
+ );
215
+ if (raceTags.length > 0) {
216
+ raceValue = raceTags[0];
217
+ }
218
+ }
219
+
220
+ // Last resort: look for any string property that seems like a race name
221
+ if (!raceValue) {
222
+ const keys = Object.keys(data.race);
223
+ for (const key of keys) {
224
+ if (typeof data.race[key] === 'string' && data.race[key].length > 0 && data.race[key].length < 50) {
225
+ raceValue = data.race[key];
226
+ break;
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ // If we found something, capitalize it; otherwise use "Unknown"
233
+ if (raceValue && typeof raceValue === 'string') {
234
+ raceName = raceValue.charAt(0).toUpperCase() + raceValue.slice(1);
235
+ } else {
236
+ debug.warn('Could not extract race name from object:', data.race);
237
+ raceName = 'Unknown Race';
238
+ }
239
+ }
240
+ }
241
+
242
+ // Layer 1: Class, Level, Race, Hit Dice
243
+ safeSet('char-class', 'textContent', data.class || 'Unknown');
244
+ safeSet('char-level', 'textContent', data.level || 1);
245
+ safeSet('char-race', 'textContent', raceName);
246
+ // Defensive initialization for hitDice
247
+ if (!data.hitDice) {
248
+ data.hitDice = { current: 0, max: 0, type: 'd6' };
249
+ }
250
+
251
+ safeSet('char-hit-dice', 'textContent', `${data.hitDice.current || 0}/${data.hitDice.max || 0} ${data.hitDice.type || 'd6'}`);
252
+
253
+ // Layer 2: AC, Speed, Proficiency, Death Saves, Inspiration
254
+ safeSet('char-ac', 'textContent', calculateTotalAC(data));
255
+ safeSet('char-speed', 'textContent', `${data.speed || 30} ft`);
256
+ safeSet('char-proficiency', 'textContent', `+${data.proficiencyBonus || 0}`);
257
+
258
+ // Death Saves
259
+ const deathSavesDisplay = document.getElementById('death-saves-display');
260
+ const deathSavesValue = document.getElementById('death-saves-value');
261
+
262
+ // Defensive initialization for deathSaves
263
+ if (!data.deathSaves) {
264
+ data.deathSaves = { successes: 0, failures: 0 };
265
+ }
266
+
267
+ if (deathSavesValue) {
268
+ deathSavesValue.innerHTML = `
269
+ <span style="color: var(--accent-success);">✓${data.deathSaves.successes || 0}</span> /
270
+ <span style="color: var(--accent-danger);">✗${data.deathSaves.failures || 0}</span>
271
+ `;
272
+ }
273
+ if (deathSavesDisplay) {
274
+ if (data.deathSaves.successes > 0 || data.deathSaves.failures > 0) {
275
+ deathSavesDisplay.style.background = 'var(--bg-action)';
276
+ } else {
277
+ deathSavesDisplay.style.background = 'var(--bg-tertiary)';
278
+ }
279
+ }
280
+
281
+ // Inspiration
282
+ const inspirationDisplay = document.getElementById('inspiration-display');
283
+ const inspirationValue = document.getElementById('inspiration-value');
284
+ if (inspirationValue) {
285
+ if (data.inspiration) {
286
+ inspirationValue.textContent = '⭐ Active';
287
+ inspirationValue.style.color = '#f57f17';
288
+ if (inspirationDisplay) {
289
+ inspirationDisplay.style.background = '#fff9c4';
290
+ }
291
+ } else {
292
+ inspirationValue.textContent = '☆ None';
293
+ inspirationValue.style.color = 'var(--text-muted)';
294
+ if (inspirationDisplay) {
295
+ inspirationDisplay.style.background = 'var(--bg-tertiary)';
296
+ }
297
+ }
298
+ }
299
+
300
+ // Layer 3: Hit Points
301
+ const hpValue = document.getElementById('hp-value');
302
+
303
+ // Defensive initialization for hitPoints - ensure proper structure
304
+ debug.log('🔍 HP before defensive init:', { hitPoints: data.hitPoints, type: typeof data.hitPoints });
305
+ if (!data.hitPoints || typeof data.hitPoints !== 'object') {
306
+ debug.warn('⚠️ DEFENSIVE INIT TRIGGERED! Setting HP to 0/0. Original value:', data.hitPoints);
307
+ data.hitPoints = { current: 0, max: 0 };
308
+ }
309
+ // Ensure current and max exist (hitPoints might be an object but missing these)
310
+ if (data.hitPoints.current === undefined) {
311
+ debug.warn('⚠️ HP current is undefined, setting to 0');
312
+ data.hitPoints.current = 0;
313
+ }
314
+ if (data.hitPoints.max === undefined) {
315
+ debug.warn('⚠️ HP max is undefined, setting to 0');
316
+ data.hitPoints.max = 0;
317
+ }
318
+
319
+ debug.log('💚 HP display values:', { current: data.hitPoints.current, max: data.hitPoints.max, tempHP: data.temporaryHP });
320
+
321
+ if (hpValue) {
322
+ hpValue.textContent = `${data.hitPoints.current}${data.temporaryHP > 0 ? `+${data.temporaryHP}` : ''} / ${data.hitPoints.max}`;
323
+ }
324
+
325
+ // Initiative
326
+ const initiativeValue = document.getElementById('initiative-value');
327
+ if (initiativeValue) {
328
+ initiativeValue.textContent = `+${data.initiative || 0}`;
329
+ }
330
+
331
+ // Remove old event listeners by cloning and replacing elements
332
+ // This prevents duplicate listeners when buildSheet() is called multiple times
333
+ const hpDisplayOld = document.getElementById('hp-display');
334
+ let hpDisplayNew = null;
335
+ if (hpDisplayOld) {
336
+ hpDisplayNew = hpDisplayOld.cloneNode(true);
337
+ hpDisplayOld.parentNode.replaceChild(hpDisplayNew, hpDisplayOld);
338
+ }
339
+
340
+ const initiativeOld = document.getElementById('initiative-button');
341
+ let initiativeNew = null;
342
+ if (initiativeOld) {
343
+ initiativeNew = initiativeOld.cloneNode(true);
344
+ initiativeOld.parentNode.replaceChild(initiativeNew, initiativeOld);
345
+ }
346
+
347
+ const deathSavesOld = document.getElementById('death-saves-display');
348
+ let deathSavesNew = null;
349
+ if (deathSavesOld) {
350
+ deathSavesNew = deathSavesOld.cloneNode(true);
351
+ deathSavesOld.parentNode.replaceChild(deathSavesNew, deathSavesOld);
352
+ }
353
+
354
+ const inspirationOld = document.getElementById('inspiration-display');
355
+ let inspirationNew = null;
356
+ if (inspirationOld) {
357
+ inspirationNew = inspirationOld.cloneNode(true);
358
+ inspirationOld.parentNode.replaceChild(inspirationNew, inspirationOld);
359
+ }
360
+
361
+ // Add click handler for HP display
362
+ if (hpDisplayNew) {
363
+ hpDisplayNew.addEventListener('click', showHPModal);
364
+ }
365
+
366
+ // Add click handler for initiative button
367
+ if (initiativeNew) {
368
+ initiativeNew.addEventListener('click', () => {
369
+ const initiativeBonus = data.initiative || 0;
370
+
371
+ // Announce initiative roll
372
+ const announcement = `&{template:default} {{name=${getColoredBanner(data)}${data.name} rolls for initiative!}} {{Type=Initiative}} {{Bonus=+${initiativeBonus}}}`;
373
+ const messageData = {
374
+ action: 'announceSpell',
375
+ message: announcement,
376
+ color: data.notificationColor
377
+ };
378
+
379
+ sendToRoll20(messageData);
380
+
381
+ roll('Initiative', `1d20+${initiativeBonus}`);
382
+ });
383
+ }
384
+
385
+ // Add click handler for death saves display
386
+ if (deathSavesNew) {
387
+ deathSavesNew.addEventListener('click', showDeathSavesModal);
388
+ }
389
+
390
+ // Add click handler for inspiration display
391
+ if (inspirationNew) {
392
+ inspirationNew.addEventListener('click', toggleInspiration);
393
+ }
394
+
395
+ // Update HP display color based on percentage
396
+ if (hpDisplayNew) {
397
+ const hpPercent = data.hitPoints && data.hitPoints.max > 0 ? (data.hitPoints.current / data.hitPoints.max) * 100 : 0;
398
+ // Use the new hpDisplayNew element we just created above
399
+ if (hpPercent > 50) {
400
+ hpDisplayNew.style.background = 'var(--accent-success)';
401
+ } else if (hpPercent > 25) {
402
+ hpDisplayNew.style.background = 'var(--accent-warning)';
403
+ } else {
404
+ hpDisplayNew.style.background = 'var(--accent-danger)';
405
+ }
406
+ }
407
+
408
+ // Resources
409
+ buildResourcesDisplay();
410
+
411
+ // Spell Slots
412
+ buildSpellSlotsDisplay();
413
+
414
+ // Ability Scores & Saving Throws (Bundled format like OwlCloud)
415
+ const abilitiesSavesGrid = document.getElementById('abilities-saves-grid');
416
+ if (abilitiesSavesGrid) {
417
+ abilitiesSavesGrid.innerHTML = ''; // Clear existing
418
+ const abilities = ['strength', 'dexterity', 'constitution', 'intelligence', 'wisdom', 'charisma'];
419
+
420
+ abilities.forEach(ability => {
421
+ const score = data.attributes?.[ability] || 10;
422
+ const checkMod = data.attributeMods?.[ability] || 0;
423
+ const saveMod = data.saves?.[ability] || data.savingThrows?.[ability] || checkMod; // Fallback to check if no save proficiency
424
+
425
+ // Create bundled card
426
+ const card = document.createElement('div');
427
+ card.className = 'stat-card';
428
+ card.style.cssText = `
429
+ padding: 12px;
430
+ background: var(--bg-secondary, #2a2a2a);
431
+ border: 2px solid var(--border-color, #444);
432
+ border-radius: 8px;
433
+ text-align: center;
434
+ cursor: pointer;
435
+ transition: all 0.2s;
436
+ `;
437
+
438
+ const abilityShort = ability.substring(0, 3).toUpperCase();
439
+
440
+ card.innerHTML = `
441
+ <div style="font-weight: bold; font-size: 14px; margin-bottom: 8px; color: var(--text-primary, #e0e0e0);">${abilityShort}</div>
442
+ <div style="font-size: 24px; font-weight: bold; margin-bottom: 8px; color: var(--accent-primary, #9b59b6);">${score}</div>
443
+ <div style="display: flex; justify-content: space-around; gap: 8px;">
444
+ <button class="check-btn" style="flex: 1; padding: 6px; background: var(--bg-tertiary, #333); border: 1px solid var(--border-color, #555); border-radius: 4px; color: var(--text-secondary, #b0b0b0); font-size: 11px; cursor: pointer;">
445
+ <div style="font-size: 10px;">Check</div>
446
+ <div style="font-weight: bold;">${checkMod >= 0 ? '+' : ''}${checkMod}</div>
447
+ </button>
448
+ <button class="save-btn" style="flex: 1; padding: 6px; background: var(--bg-tertiary, #333); border: 1px solid var(--border-color, #555); border-radius: 4px; color: var(--text-secondary, #b0b0b0); font-size: 11px; cursor: pointer;">
449
+ <div style="font-size: 10px;">Save</div>
450
+ <div style="font-weight: bold;">${saveMod >= 0 ? '+' : ''}${saveMod}</div>
451
+ </button>
452
+ </div>
453
+ `;
454
+
455
+ // Check button click handler
456
+ const checkBtn = card.querySelector('.check-btn');
457
+ checkBtn.addEventListener('click', (e) => {
458
+ e.stopPropagation();
459
+ const announcement = `&{template:default} {{name=${getColoredBanner(data)}${data.name} makes a ${ability.charAt(0).toUpperCase() + ability.slice(1)} check!}} {{Type=Ability Check}} {{Bonus=${checkMod >= 0 ? '+' : ''}${checkMod}}}`;
460
+ const messageData = {
461
+ action: 'announceSpell',
462
+ message: announcement,
463
+ color: data.notificationColor
464
+ };
465
+
466
+ sendToRoll20(messageData);
467
+
468
+ roll(`${ability.charAt(0).toUpperCase() + ability.slice(1)} Check`, `1d20${checkMod >= 0 ? '+' : ''}${checkMod}`);
469
+ });
470
+
471
+ // Save button click handler
472
+ const saveBtn = card.querySelector('.save-btn');
473
+ saveBtn.addEventListener('click', (e) => {
474
+ e.stopPropagation();
475
+ const announcement = `&{template:default} {{name=${getColoredBanner(data)}${data.name} makes a ${abilityShort} save!}} {{Type=Saving Throw}} {{Bonus=${saveMod >= 0 ? '+' : ''}${saveMod}}}`;
476
+ const messageData = {
477
+ action: 'announceSpell',
478
+ message: announcement,
479
+ color: data.notificationColor
480
+ };
481
+
482
+ sendToRoll20(messageData);
483
+
484
+ roll(`${abilityShort} Save`, `1d20${saveMod >= 0 ? '+' : ''}${saveMod}`);
485
+ });
486
+
487
+ abilitiesSavesGrid.appendChild(card);
488
+ });
489
+ }
490
+
491
+ // Skills - deduplicate and show unique skills only
492
+ const skillsGrid = document.getElementById('skills-grid');
493
+ if (skillsGrid) {
494
+ skillsGrid.innerHTML = ''; // Clear existing
495
+
496
+ // Create a map to deduplicate skills (in case data has duplicates)
497
+ const uniqueSkills = new Map();
498
+ Object.entries(data.skills || {}).forEach(([skill, bonus]) => {
499
+ const normalizedSkill = skill.toLowerCase().trim();
500
+ // Only keep the skill if we haven't seen it, or if this bonus is higher
501
+ if (!uniqueSkills.has(normalizedSkill) || bonus > uniqueSkills.get(normalizedSkill).bonus) {
502
+ uniqueSkills.set(normalizedSkill, { skill, bonus });
503
+ }
504
+ });
505
+
506
+ // Sort skills alphabetically and display
507
+ const sortedSkills = Array.from(uniqueSkills.values()).sort((a, b) =>
508
+ a.skill.localeCompare(b.skill)
509
+ );
510
+
511
+ sortedSkills.forEach(({ skill, bonus }) => {
512
+ const displayName = skill.charAt(0).toUpperCase() + skill.slice(1).replace(/-/g, ' ');
513
+ const card = createCard(displayName, `${bonus >= 0 ? '+' : ''}${bonus}`, '', () => {
514
+ // Announce skill check
515
+ const announcement = `&{template:default} {{name=${getColoredBanner(data)}${data.name} makes a ${displayName} check!}} {{Type=Skill Check}} {{Bonus=${bonus >= 0 ? '+' : ''}${bonus}}}`;
516
+ const messageData = {
517
+ action: 'announceSpell',
518
+ message: announcement,
519
+ color: data.notificationColor
520
+ };
521
+
522
+ sendToRoll20(messageData);
523
+
524
+ roll(displayName, `1d20${bonus >= 0 ? '+' : ''}${bonus}`);
525
+ });
526
+ skillsGrid.appendChild(card);
527
+ });
528
+ }
529
+
530
+ // Actions & Attacks
531
+ const actionsContainer = document.getElementById('actions-container');
532
+ if (actionsContainer) {
533
+ debug.log('🎬 Actions display check:', {
534
+ has_actions: !!data.actions,
535
+ is_array: Array.isArray(data.actions),
536
+ length: data.actions?.length,
537
+ sample_names: data.actions?.slice(0, 5).map(a => a.name)
538
+ });
539
+ if (data.actions && Array.isArray(data.actions) && data.actions.length > 0) {
540
+ buildActionsDisplay(actionsContainer, data.actions);
541
+ } else {
542
+ actionsContainer.innerHTML = '<p style="text-align: center; color: #666;">No actions available</p>';
543
+ debug.warn('⚠️ No actions to display - showing placeholder');
544
+ }
545
+ }
546
+
547
+ // Companions (Animal Companions, Familiars, Summons, etc.)
548
+ if (data.companions && Array.isArray(data.companions) && data.companions.length > 0) {
549
+ buildCompanionsDisplay(data.companions);
550
+ } else {
551
+ // Hide companions section if character has no companions
552
+ const companionsSection = document.getElementById('companions-container');
553
+ if (companionsSection) {
554
+ companionsSection.style.display = 'none';
555
+ }
556
+ }
557
+
558
+ // Inventory & Equipment
559
+ const inventoryContainer = document.getElementById('inventory-container');
560
+ if (inventoryContainer) {
561
+ if (data.inventory && Array.isArray(data.inventory) && data.inventory.length > 0) {
562
+ buildInventoryDisplay(inventoryContainer, data.inventory);
563
+ } else {
564
+ inventoryContainer.innerHTML = '<p style="text-align: center; color: var(--text-secondary);">No items in inventory</p>';
565
+ }
566
+ }
567
+
568
+ // Spells - organized by source then level
569
+ const spellsContainer = document.getElementById('spells-container');
570
+ if (spellsContainer) {
571
+ debug.log('✨ Spells display check:', {
572
+ has_spells: !!data.spells,
573
+ is_array: Array.isArray(data.spells),
574
+ length: data.spells?.length,
575
+ sample_names: data.spells?.slice(0, 5).map(s => s.name)
576
+ });
577
+ if (data.spells && Array.isArray(data.spells) && data.spells.length > 0) {
578
+ buildSpellsBySource(spellsContainer, data.spells);
579
+ expandSectionByContainerId('spells-container');
580
+ } else {
581
+ spellsContainer.innerHTML = '<p style="text-align: center; color: var(--text-secondary);">No spells prepared</p>';
582
+ debug.warn('⚠️ No spells to display - showing placeholder');
583
+ // Collapse the section when empty
584
+ collapseSectionByContainerId('spells-container');
585
+ }
586
+ }
587
+
588
+ // Restore active effects from character data
589
+ if (typeof window.activeBuffs !== 'undefined' && typeof window.activeConditions !== 'undefined') {
590
+ if (data.activeEffects) {
591
+ window.activeBuffs = data.activeEffects.buffs || [];
592
+ window.activeConditions = data.activeEffects.debuffs || [];
593
+ debug.log('✅ Restored active effects:', { buffs: window.activeBuffs, debuffs: window.activeConditions });
594
+ } else {
595
+ window.activeBuffs = [];
596
+ window.activeConditions = [];
597
+ }
598
+
599
+ // Sync conditions from Dicecloud (if any were detected as active)
600
+ if (data.conditions && Array.isArray(data.conditions) && data.conditions.length > 0 &&
601
+ typeof window.POSITIVE_EFFECTS !== 'undefined' && typeof window.NEGATIVE_EFFECTS !== 'undefined') {
602
+ debug.log('✨ Syncing conditions from Dicecloud:', data.conditions);
603
+ data.conditions.forEach(condition => {
604
+ // Map Dicecloud condition names to our effect names
605
+ const conditionName = condition.name;
606
+ const isPositive = window.POSITIVE_EFFECTS.some(e => e.name === conditionName);
607
+ const isNegative = window.NEGATIVE_EFFECTS.some(e => e.name === conditionName);
608
+
609
+ if (isPositive && !window.activeBuffs.includes(conditionName)) {
610
+ window.activeBuffs.push(conditionName);
611
+ debug.log(` ✅ Added buff from Dicecloud: ${conditionName}`);
612
+ } else if (isNegative && !window.activeConditions.includes(conditionName)) {
613
+ window.activeConditions.push(conditionName);
614
+ debug.log(` ✅ Added debuff from Dicecloud: ${conditionName}`);
615
+ }
616
+ });
617
+ }
618
+ }
619
+
620
+ if (typeof updateEffectsDisplay === 'function') {
621
+ updateEffectsDisplay();
622
+ }
623
+
624
+ // Initialize color palette after sheet is built
625
+ if (typeof initColorPalette === 'function') {
626
+ initColorPalette();
627
+ }
628
+
629
+ // Initialize filter event listeners
630
+ if (typeof initializeFilters === 'function') {
631
+ initializeFilters();
632
+ }
633
+
634
+ // Hide loading overlay and show the sheet with fade-in effect
635
+ const loadingOverlay = document.getElementById('loading-overlay');
636
+ const container = document.querySelector('.container');
637
+ if (loadingOverlay) {
638
+ loadingOverlay.style.display = 'none';
639
+ }
640
+ if (container) {
641
+ container.classList.add('loaded');
642
+ }
643
+
644
+ debug.log('✅ Sheet built successfully');
645
+ }
646
+
647
+ // ===== EXPORTS =====
648
+
649
+ // Export function to window (for access from content script context)
650
+ window.buildSheet = buildSheet;
651
+
652
+ debug.log('✅ Sheet Builder module loaded');
653
+
654
+ })();