@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,219 @@
1
+ /**
2
+ * Warlock Invocations Module
3
+ *
4
+ * Handles detection and application of Warlock invocations that modify spells and abilities.
5
+ * Provides a centralized system for checking active invocations and applying their effects.
6
+ *
7
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
8
+ *
9
+ * Functions exported to globalThis:
10
+ * - getActiveInvocations(characterData)
11
+ * - applyInvocationToSpell(spell, invocations, characterData)
12
+ * - applyInvocationToDamage(spellName, damageFormula, invocations, characterData)
13
+ */
14
+
15
+ (function() {
16
+ 'use strict';
17
+
18
+ /**
19
+ * Warlock invocations that modify spell behavior
20
+ * Each entry defines the invocation name pattern and the effect it has
21
+ */
22
+ const INVOCATION_DEFINITIONS = {
23
+ // Agonizing Blast: Add CHA modifier to Eldritch Blast damage
24
+ AGONIZING_BLAST: {
25
+ namePattern: /agonizing blast/i,
26
+ affectsSpells: ['eldritch blast'],
27
+ modifyDamage: (damage, characterData) => {
28
+ const charismaMod = characterData.abilityMods?.charismaMod || 0;
29
+ if (charismaMod === 0) return damage;
30
+
31
+ const modifier = charismaMod >= 0 ? `+${charismaMod}` : `${charismaMod}`;
32
+ return `${damage}${modifier}`;
33
+ },
34
+ description: 'Adds Charisma modifier to each Eldritch Blast beam'
35
+ },
36
+
37
+ // Repelling Blast: Push creatures 10 feet with Eldritch Blast
38
+ REPELLING_BLAST: {
39
+ namePattern: /repelling blast/i,
40
+ affectsSpells: ['eldritch blast'],
41
+ modifyDescription: (description) => {
42
+ return description + '\n\n**Repelling Blast:** On a hit, push the target up to 10 feet away from you in a straight line.';
43
+ },
44
+ description: 'Push Eldritch Blast targets 10 feet away'
45
+ },
46
+
47
+ // Grasp of Hadar: Pull creatures 10 feet with Eldritch Blast
48
+ GRASP_OF_HADAR: {
49
+ namePattern: /grasp of hadar/i,
50
+ affectsSpells: ['eldritch blast'],
51
+ modifyDescription: (description) => {
52
+ return description + '\n\n**Grasp of Hadar:** Once per turn when you hit with Eldritch Blast, pull the target up to 10 feet closer to you in a straight line.';
53
+ },
54
+ description: 'Pull Eldritch Blast target 10 feet closer (once per turn)'
55
+ },
56
+
57
+ // Lance of Lethargy: Reduce speed with Eldritch Blast
58
+ LANCE_OF_LETHARGY: {
59
+ namePattern: /lance of lethargy/i,
60
+ affectsSpells: ['eldritch blast'],
61
+ modifyDescription: (description) => {
62
+ return description + '\n\n**Lance of Lethargy:** Once per turn when you hit with Eldritch Blast, reduce the target\'s speed by 10 feet until the end of your next turn.';
63
+ },
64
+ description: 'Reduce Eldritch Blast target speed by 10 feet (once per turn)'
65
+ },
66
+
67
+ // Eldritch Spear: Increase Eldritch Blast range to 300 feet
68
+ ELDRITCH_SPEAR: {
69
+ namePattern: /eldritch spear/i,
70
+ affectsSpells: ['eldritch blast'],
71
+ modifyRange: () => '300 feet',
72
+ description: 'Increases Eldritch Blast range to 300 feet'
73
+ }
74
+ };
75
+
76
+ /**
77
+ * Get all active invocations for a character
78
+ * @param {Object} characterData - The character data object
79
+ * @returns {Array} Array of active invocation objects with their definitions
80
+ */
81
+ function getActiveInvocations(characterData) {
82
+ if (!characterData || !characterData.features) {
83
+ return [];
84
+ }
85
+
86
+ const activeInvocations = [];
87
+
88
+ // Search through character features for invocations
89
+ characterData.features.forEach(feature => {
90
+ if (!feature.name) return;
91
+
92
+ // Check if this feature matches any known invocation
93
+ for (const [key, invocation] of Object.entries(INVOCATION_DEFINITIONS)) {
94
+ if (invocation.namePattern.test(feature.name)) {
95
+ activeInvocations.push({
96
+ name: feature.name,
97
+ key: key,
98
+ definition: invocation
99
+ });
100
+ debug.log(`🔮 Detected warlock invocation: ${feature.name}`);
101
+ }
102
+ }
103
+ });
104
+
105
+ return activeInvocations;
106
+ }
107
+
108
+ /**
109
+ * Check if a specific invocation is active
110
+ * @param {String} invocationKey - The invocation key (e.g., 'AGONIZING_BLAST')
111
+ * @param {Array} activeInvocations - Array of active invocations from getActiveInvocations()
112
+ * @returns {Boolean} True if the invocation is active
113
+ */
114
+ function hasInvocation(invocationKey, activeInvocations) {
115
+ return activeInvocations.some(inv => inv.key === invocationKey);
116
+ }
117
+
118
+ /**
119
+ * Apply invocation modifications to a spell
120
+ * This modifies spell properties based on active invocations
121
+ * @param {Object} spell - The spell object to modify
122
+ * @param {Array} invocations - Array of active invocations
123
+ * @param {Object} characterData - The character data object
124
+ * @returns {Object} Modified spell object (creates a shallow copy)
125
+ */
126
+ function applyInvocationToSpell(spell, invocations, characterData) {
127
+ if (!spell || !invocations || invocations.length === 0) {
128
+ return spell;
129
+ }
130
+
131
+ const spellNameLower = (spell.name || '').toLowerCase();
132
+ let modifiedSpell = { ...spell };
133
+ let hasModifications = false;
134
+
135
+ // Check each active invocation
136
+ invocations.forEach(invocation => {
137
+ const def = invocation.definition;
138
+
139
+ // Check if this invocation affects this spell
140
+ if (!def.affectsSpells || !def.affectsSpells.some(s => spellNameLower.includes(s))) {
141
+ return;
142
+ }
143
+
144
+ // Apply description modifications
145
+ if (def.modifyDescription && modifiedSpell.description) {
146
+ modifiedSpell.description = def.modifyDescription(modifiedSpell.description);
147
+ hasModifications = true;
148
+ debug.log(`🔮 Applied ${invocation.name} description to ${spell.name}`);
149
+ }
150
+
151
+ // Apply range modifications
152
+ if (def.modifyRange) {
153
+ modifiedSpell.range = def.modifyRange();
154
+ hasModifications = true;
155
+ debug.log(`🔮 Applied ${invocation.name} range to ${spell.name}`);
156
+ }
157
+ });
158
+
159
+ return hasModifications ? modifiedSpell : spell;
160
+ }
161
+
162
+ /**
163
+ * Apply invocation modifications to a damage formula
164
+ * This is specifically for damage rolls that need modification
165
+ * @param {String} spellName - Name of the spell being cast
166
+ * @param {String} damageFormula - The base damage formula
167
+ * @param {Array} invocations - Array of active invocations
168
+ * @param {Object} characterData - The character data object
169
+ * @returns {Object} { formula: modified formula, display: display formula }
170
+ */
171
+ function applyInvocationToDamage(spellName, damageFormula, invocations, characterData) {
172
+ if (!damageFormula || !invocations || invocations.length === 0) {
173
+ return { formula: damageFormula, display: damageFormula };
174
+ }
175
+
176
+ const spellNameLower = (spellName || '').toLowerCase();
177
+ let modifiedFormula = damageFormula;
178
+ let modifiedDisplay = damageFormula;
179
+ let hasModifications = false;
180
+
181
+ // Check each active invocation
182
+ invocations.forEach(invocation => {
183
+ const def = invocation.definition;
184
+
185
+ // Check if this invocation affects this spell
186
+ if (!def.affectsSpells || !def.affectsSpells.some(s => spellNameLower.includes(s))) {
187
+ return;
188
+ }
189
+
190
+ // Apply damage modifications
191
+ if (def.modifyDamage) {
192
+ const modified = def.modifyDamage(modifiedFormula, characterData);
193
+ if (modified !== modifiedFormula) {
194
+ modifiedFormula = modified;
195
+ modifiedDisplay = modified;
196
+ hasModifications = true;
197
+ debug.log(`🔮 Applied ${invocation.name} damage to ${spellName}: ${damageFormula} → ${modified}`);
198
+ }
199
+ }
200
+ });
201
+
202
+ return {
203
+ formula: modifiedFormula,
204
+ display: modifiedDisplay,
205
+ modified: hasModifications
206
+ };
207
+ }
208
+
209
+ // ===== EXPORTS =====
210
+
211
+ // Export functions to globalThis
212
+ globalThis.getActiveInvocations = getActiveInvocations;
213
+ globalThis.hasInvocation = hasInvocation;
214
+ globalThis.applyInvocationToSpell = applyInvocationToSpell;
215
+ globalThis.applyInvocationToDamage = applyInvocationToDamage;
216
+
217
+ debug.log('✅ Warlock Invocations module loaded');
218
+
219
+ })();
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Window Management Module
3
+ *
4
+ * Handles popup window features:
5
+ * - Window size persistence (save/restore dimensions)
6
+ * - Status bar integration (toggle and data sync)
7
+ * - Window state management
8
+ *
9
+ * This module manages the popup window's appearance and communication
10
+ * with the Roll20 status bar overlay system.
11
+ *
12
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
13
+ *
14
+ * Functions exported to globalThis:
15
+ * - saveWindowSize()
16
+ * - loadWindowSize()
17
+ * - initWindowSizeTracking()
18
+ * - initStatusBarButton()
19
+ * - sendStatusUpdate(targetWindow)
20
+ *
21
+ * State exported to globalThis:
22
+ * - statusBarWindow (via getter/setter)
23
+ */
24
+
25
+ (function() {
26
+ 'use strict';
27
+
28
+ // ===== WINDOW SIZE PERSISTENCE =====
29
+
30
+ /**
31
+ * Save current window dimensions to storage
32
+ */
33
+ function saveWindowSize() {
34
+ // Check if browserAPI is available
35
+ if (typeof browserAPI === 'undefined' || !browserAPI) {
36
+ debug.log('⚠️ browserAPI not available, skipping window size save');
37
+ return;
38
+ }
39
+
40
+ const width = window.outerWidth;
41
+ const height = window.outerHeight;
42
+
43
+ browserAPI.storage.local.set({
44
+ popupWindowSize: { width, height }
45
+ });
46
+
47
+ debug.log(`💾 Saved window size: ${width}x${height}`);
48
+ }
49
+
50
+ /**
51
+ * Load and apply saved window dimensions
52
+ */
53
+ async function loadWindowSize() {
54
+ // Check if browserAPI is available
55
+ if (typeof browserAPI === 'undefined' || !browserAPI) {
56
+ debug.log('⚠️ browserAPI not available, skipping window size restore');
57
+ return;
58
+ }
59
+
60
+ try {
61
+ const result = await browserAPI.storage.local.get(['popupWindowSize']);
62
+ if (result.popupWindowSize) {
63
+ const { width, height } = result.popupWindowSize;
64
+ window.resizeTo(width, height);
65
+ debug.log(`📐 Restored window size: ${width}x${height}`);
66
+ }
67
+ } catch (error) {
68
+ debug.warn('⚠️ Could not restore window size:', error);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Initialize window size tracking
74
+ * Loads saved size on startup and tracks changes
75
+ */
76
+ function initWindowSizeTracking() {
77
+ // Load saved size on startup
78
+ loadWindowSize();
79
+
80
+ // Save size when window is resized
81
+ let resizeTimeout;
82
+ window.addEventListener('resize', () => {
83
+ clearTimeout(resizeTimeout);
84
+ resizeTimeout = setTimeout(() => {
85
+ saveWindowSize();
86
+ }, 500); // Debounce to avoid excessive saves
87
+ });
88
+
89
+ debug.log('📐 Window size tracking initialized');
90
+ }
91
+
92
+ // ===== STATUS BAR INTEGRATION =====
93
+
94
+ // Reference to status bar window (if using window-based status bar)
95
+ let statusBarWindow = null;
96
+
97
+ /**
98
+ * Initialize status bar button
99
+ * Sets up click handler to toggle Roll20 GM panel overlay (status bar)
100
+ */
101
+ function initStatusBarButton() {
102
+ const statusBarBtn = document.getElementById('status-bar-btn');
103
+ if (statusBarBtn) {
104
+ statusBarBtn.addEventListener('click', async () => {
105
+ // Send message to Roll20 tabs to toggle the status bar overlay
106
+ try {
107
+ const tabs = await browserAPI.tabs.query({ url: '*://app.roll20.net/*' });
108
+
109
+ if (tabs.length === 0) {
110
+ showNotification('⚠️ No Roll20 tabs found. Open Roll20 to use the status bar.', 'warning');
111
+ return;
112
+ }
113
+
114
+ // Send toggle message to all Roll20 tabs
115
+ for (const tab of tabs) {
116
+ try {
117
+ await browserAPI.tabs.sendMessage(tab.id, {
118
+ action: 'toggleStatusBar',
119
+ enabled: undefined // Toggle current state
120
+ });
121
+
122
+ showNotification('📊 Status bar toggled', 'success');
123
+ debug.log('📊 Status bar overlay toggled');
124
+ } catch (error) {
125
+ debug.warn('⚠️ Could not toggle status bar on tab:', error.message);
126
+ }
127
+ }
128
+ } catch (error) {
129
+ debug.error('❌ Failed to toggle status bar:', error);
130
+ showNotification('❌ Failed to toggle status bar', 'error');
131
+ }
132
+ });
133
+ debug.log('✅ Status bar button initialized');
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Send status update to status bar window
139
+ * @param {Window} targetWindow - Optional target window (defaults to statusBarWindow)
140
+ */
141
+ function sendStatusUpdate(targetWindow = null) {
142
+ // Use provided target window or the stored statusBarWindow
143
+ const target = targetWindow || statusBarWindow;
144
+
145
+ if (!target || target.closed) {
146
+ debug.log('📊 No valid status bar window to send to');
147
+ return;
148
+ }
149
+
150
+ if (!characterData) {
151
+ debug.log('📊 No character data available to send');
152
+ return;
153
+ }
154
+
155
+ const statusData = {
156
+ action: 'updateStatusData',
157
+ data: {
158
+ name: characterData.name || characterData.character_name,
159
+ hitPoints: characterData.hitPoints || characterData.hit_points,
160
+ temporaryHP: characterData.temporaryHP || 0,
161
+ concentrating: !!concentratingSpell,
162
+ concentrationSpell: concentratingSpell || '',
163
+ activeBuffs: activeBuffs || [],
164
+ activeDebuffs: activeConditions || [],
165
+ spellSlots: characterData.spellSlots || {}
166
+ }
167
+ };
168
+
169
+ target.postMessage(statusData, '*');
170
+ debug.log('📊 Sent status update to status bar', statusData.data);
171
+ }
172
+
173
+ // ===== EXPORTS =====
174
+
175
+ // Export statusBarWindow state variable
176
+ Object.defineProperty(globalThis, 'statusBarWindow', {
177
+ get: () => statusBarWindow,
178
+ set: (value) => { statusBarWindow = value; },
179
+ configurable: true
180
+ });
181
+
182
+ // Export functions to globalThis
183
+ globalThis.saveWindowSize = saveWindowSize;
184
+ globalThis.loadWindowSize = loadWindowSize;
185
+ globalThis.initWindowSizeTracking = initWindowSizeTracking;
186
+ globalThis.initStatusBarButton = initStatusBarButton;
187
+ globalThis.sendStatusUpdate = sendStatusUpdate;
188
+
189
+ debug.log('✅ Window Management module loaded');
190
+
191
+ // ===== AUTO-INITIALIZATION =====
192
+
193
+ // Initialize window size tracking when module loads
194
+ // Check if we're in the main popup window context
195
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
196
+ // Initialize immediately if DOM is ready, otherwise wait
197
+ if (document.readyState === 'loading') {
198
+ document.addEventListener('DOMContentLoaded', () => {
199
+ initWindowSizeTracking();
200
+ });
201
+ } else {
202
+ // DOM already ready, but use pendingOperations if available
203
+ if (typeof pendingOperations !== 'undefined' && !domReady) {
204
+ pendingOperations.push(initWindowSizeTracking);
205
+ } else {
206
+ initWindowSizeTracking();
207
+ }
208
+ }
209
+ }
210
+
211
+ })();
@@ -0,0 +1,234 @@
1
+ /**
2
+ * System-agnostic character renderer. Builds a sheet DOM from an IRCharacter
3
+ * using the `h()` builder (never innerHTML). Adapters mount the returned element
4
+ * and supply roll/use callbacks.
5
+ *
6
+ * D&D characters get the familiar ability grid (derived view); every character -
7
+ * D&D or not - also gets generic Resources / Attributes / Actions sections, which
8
+ * is where custom stats and charge-with-reset abilities finally show up.
9
+ */
10
+ import type { IRAction, IRAttribute, IRCharacter } from '../ir/types';
11
+ import { deriveDnd, DND_ABILITIES } from '../ir/views/dnd5e';
12
+ import { h } from './h';
13
+
14
+ export interface RenderOpts {
15
+ /** Called when a rollable element (ability/save/skill) is clicked. */
16
+ onRoll?: (label: string, modifier: number) => void;
17
+ /** Called when an action/spell "use" is clicked. */
18
+ onUse?: (action: IRAction) => void;
19
+ }
20
+
21
+ const RESET_LABEL: Record<string, string> = { shortRest: 'SR', longRest: 'LR' };
22
+ const signed = (n: number) => `${n >= 0 ? '+' : ''}${n}`;
23
+
24
+ function sectionHeader(title: string): HTMLElement {
25
+ return h('div', { class: 'section-header', text: title });
26
+ }
27
+
28
+ /** Small badge showing a reset period (SR / LR / raw). */
29
+ function resetBadge(reset: string | null | undefined): HTMLElement | null {
30
+ if (!reset) return null;
31
+ return h('span', { class: 'cc-reset-badge', text: RESET_LABEL[reset] ?? reset });
32
+ }
33
+
34
+ /** current / max pill, e.g. "2 / 2". */
35
+ function poolPill(current: number, max: number): HTMLElement {
36
+ return h('span', { class: 'cc-pool' },
37
+ h('span', { class: 'cc-pool-current', text: String(current) }),
38
+ ' / ',
39
+ h('span', { class: 'cc-pool-max', text: String(max) }));
40
+ }
41
+
42
+ /** Top combat-stats strip (HP / AC / Speed / Init / Prof) - whichever exist. */
43
+ function combatStats(ir: IRCharacter): HTMLElement | null {
44
+ const { byVar } = ir;
45
+ // DiceCloud sheets name these variably; try common aliases.
46
+ const pick = (...names: string[]) => names.map((n) => byVar[n]).find(Boolean);
47
+ const items: [string, string][] = [];
48
+
49
+ const hp = pick('hitPoints', 'hp');
50
+ if (hp) items.push(['HP', `${hp.total - hp.damage}/${hp.total}`]);
51
+ const ac = pick('armorClass', 'armor', 'ac');
52
+ if (ac && ac.value) items.push(['AC', String(ac.value)]);
53
+ const speed = pick('speed', 'walkingSpeed');
54
+ if (speed && speed.value) items.push(['Speed', String(speed.value)]);
55
+ const init = pick('initiative', 'initiativeBonus', 'initiativeMod');
56
+ if (init) items.push(['Init', signed(init.value)]);
57
+ const prof = pick('proficiencyBonus', 'proficiency');
58
+ if (prof && prof.value) items.push(['Prof', signed(prof.value)]);
59
+
60
+ if (items.length === 0) return null;
61
+ return h('div', { class: 'cc-combat' },
62
+ ...items.map(([label, val]) =>
63
+ h('div', { class: 'cc-stat' },
64
+ h('div', { class: 'cc-stat-label', text: label }),
65
+ h('div', { class: 'cc-stat-value', text: val }))));
66
+ }
67
+
68
+ /** Trained skills (skillType 'skill'), clickable to roll. */
69
+ function skillsSection(ir: IRCharacter, opts: RenderOpts): HTMLElement | null {
70
+ const skills = ir.skills.filter((s) => s.skillType === 'skill' && s.active && s.variableName);
71
+ if (skills.length === 0) return null;
72
+
73
+ const list = h('div', { class: 'cc-skill-list' });
74
+ for (const s of skills) {
75
+ list.appendChild(
76
+ h('div', {
77
+ class: 'cc-skill' + (s.proficiency > 0 ? ' cc-proficient' : ''),
78
+ title: `Roll ${s.name}`,
79
+ onClick: () => opts.onRoll?.(s.name, s.value),
80
+ },
81
+ h('span', { class: 'cc-skill-name', text: s.name }),
82
+ h('span', { class: 'cc-skill-bonus', text: signed(s.value) })),
83
+ );
84
+ }
85
+ return h('div', {}, sectionHeader('Skills'), list);
86
+ }
87
+
88
+ /** D&D ability grid (only when the derived view has abilities). */
89
+ function abilityGrid(ir: IRCharacter, opts: RenderOpts): HTMLElement | null {
90
+ const dnd = deriveDnd(ir);
91
+ if (Object.keys(dnd.abilities).length === 0) return null;
92
+
93
+ const grid = h('div', { class: 'ability-grid' });
94
+ for (const ab of DND_ABILITIES) {
95
+ const a = dnd.abilities[ab];
96
+ if (!a) continue;
97
+ const label = ab.slice(0, 3).toUpperCase();
98
+ const save = dnd.saves[ab];
99
+
100
+ const rollCell = (kind: string, value: number) =>
101
+ h('div', {
102
+ class: 'cc-ability-roll',
103
+ title: `Roll ${label} ${kind === 'CHK' ? 'check' : 'save'}`,
104
+ onClick: () => opts.onRoll?.(`${label} ${kind === 'CHK' ? 'check' : 'save'}`, value),
105
+ },
106
+ h('div', { class: 'cc-roll-label', text: kind }),
107
+ h('div', { class: 'cc-roll-val', text: signed(value) }));
108
+
109
+ grid.appendChild(
110
+ h('div', { class: 'ability-box' },
111
+ h('div', { class: 'ability-name', text: label }),
112
+ h('div', { class: 'ability-score', text: String(a.score) }),
113
+ h('div', { class: 'cc-ability-rolls' },
114
+ rollCell('CHK', a.modifier),
115
+ save !== undefined ? rollCell('SAV', save) : null)),
116
+ );
117
+ }
118
+ return h('div', {}, sectionHeader('Abilities'), grid);
119
+ }
120
+
121
+ /** Resources: anything with a max + (usually) a reset - charges, slots, hit dice. */
122
+ function resourcesSection(ir: IRCharacter): HTMLElement | null {
123
+ const isResourceLike = (a: IRAttribute) =>
124
+ (a.type === 'resource' || a.type === 'spellSlot' || a.type === 'hitDice') && a.total > 0;
125
+ const resources = ir.attributes.filter(isResourceLike);
126
+ if (resources.length === 0) return null;
127
+
128
+ const list = h('div', { class: 'cc-resource-list' });
129
+ for (const r of resources) {
130
+ const current = r.total - r.damage;
131
+ const sizeNote = r.hitDiceSize ? ` ${r.hitDiceSize}` : '';
132
+ list.appendChild(
133
+ h('div', { class: 'cc-resource' + (r.active ? '' : ' cc-inactive') },
134
+ h('span', { class: 'cc-resource-name', text: r.name + sizeNote }),
135
+ poolPill(current, r.total),
136
+ resetBadge(r.reset)),
137
+ );
138
+ }
139
+ return h('div', {}, sectionHeader('Resources'), list);
140
+ }
141
+
142
+ /**
143
+ * Generic attributes: custom stats with no D&D analog (sanity, glory, ...). We
144
+ * skip the ones already shown elsewhere (abilities, resources, HP, modifiers).
145
+ */
146
+ function attributesSection(ir: IRCharacter): HTMLElement | null {
147
+ // Hide structural types (shown elsewhere) and 'utility' (internal computed
148
+ // values like Class DC / proficiency ranks). What's left is deliberate custom
149
+ // stats - sanity, glory, corruption, etc.
150
+ const hidden = new Set([
151
+ 'ability', 'modifier', 'healthBar', 'resource', 'spellSlot', 'hitDice', 'utility',
152
+ ]);
153
+ // Also drop zero-valued entries - on sparse sheets these are unset internals
154
+ // (Speed 0, Size 0, Level 0) rather than meaningful custom stats.
155
+ const custom = ir.attributes.filter((a) => !hidden.has(a.type) && a.variableName && a.value !== 0);
156
+ if (custom.length === 0) return null;
157
+
158
+ const list = h('div', { class: 'cc-attr-list' });
159
+ for (const a of custom) {
160
+ list.appendChild(
161
+ h('div', { class: 'cc-attr' + (a.active ? '' : ' cc-inactive') },
162
+ h('span', { class: 'cc-attr-name', text: a.name }),
163
+ h('span', { class: 'cc-attr-value', text: String(a.value) })),
164
+ );
165
+ }
166
+ return h('div', {}, sectionHeader('Attributes'), list);
167
+ }
168
+
169
+ /** Actions & spells, showing real uses (current/max + reset). */
170
+ function actionsSection(ir: IRCharacter, opts: RenderOpts): HTMLElement | null {
171
+ if (ir.actions.length === 0) return null;
172
+
173
+ const list = h('div', { class: 'cc-action-list' });
174
+ for (const action of ir.actions) {
175
+ const meta: HTMLElement[] = [];
176
+ if (action.kind === 'spell' && action.spell) {
177
+ meta.push(h('span', { class: 'cc-action-tag', text: `L${action.spell.level}` }));
178
+ }
179
+ if (action.attack) {
180
+ meta.push(h('span', { class: 'cc-action-attack', title: 'Attack bonus', text: signed(action.attack.bonus) }));
181
+ }
182
+ for (const d of action.damage) {
183
+ meta.push(h('span', { class: 'cc-action-damage', text: d.type ? `${d.formula} ${d.type}` : d.formula }));
184
+ }
185
+ const usesEl = action.uses
186
+ ? h('span', { class: 'cc-action-uses' }, poolPill(action.uses.current, action.uses.max), resetBadge(action.uses.reset))
187
+ : null;
188
+
189
+ list.appendChild(
190
+ h('div', {
191
+ class: `cc-action cc-action-${action.kind}` + (action.active ? '' : ' cc-inactive'),
192
+ onClick: opts.onUse ? () => opts.onUse!(action) : undefined,
193
+ },
194
+ h('span', { class: 'cc-action-name', text: action.name }),
195
+ ...meta,
196
+ usesEl),
197
+ );
198
+ }
199
+ return h('div', {}, sectionHeader('Actions & Spells'), list);
200
+ }
201
+
202
+ /** Inventory list with quantity + equipped marker. */
203
+ function inventorySection(ir: IRCharacter): HTMLElement | null {
204
+ if (ir.inventory.length === 0) return null;
205
+ const list = h('div', { class: 'cc-item-list' });
206
+ for (const item of ir.inventory) {
207
+ list.appendChild(
208
+ h('div', { class: 'cc-item' + (item.equipped ? ' cc-equipped' : '') },
209
+ item.equipped ? h('span', { class: 'cc-equipped-dot', title: 'Equipped' }) : null,
210
+ h('span', { class: 'cc-item-name', text: item.name }),
211
+ item.quantity !== 1 ? h('span', { class: 'cc-item-qty', text: `x${item.quantity}` }) : null),
212
+ );
213
+ }
214
+ return h('div', {}, sectionHeader('Inventory'), list);
215
+ }
216
+
217
+ /** Build the full character sheet element from an IR. */
218
+ export function renderCharacterSheet(ir: IRCharacter, opts: RenderOpts = {}): HTMLElement {
219
+ const header = h('div', { class: 'cc-header' },
220
+ ir.portrait ? h('img', { class: 'cc-portrait', src: ir.portrait, alt: ir.name }) : null,
221
+ h('div', { class: 'cc-title' },
222
+ h('div', { class: 'cc-name', text: ir.name || 'Unnamed' }),
223
+ h('span', { class: 'cc-system', text: ir.systemHint })));
224
+
225
+ return h('div', { class: 'cc-sheet', dataset: { system: ir.systemHint } },
226
+ header,
227
+ combatStats(ir),
228
+ abilityGrid(ir, opts),
229
+ skillsSection(ir, opts),
230
+ resourcesSection(ir),
231
+ actionsSection(ir, opts),
232
+ attributesSection(ir),
233
+ inventorySection(ir));
234
+ }