@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.
- package/dist/cache/CacheManager.d.ts.map +1 -0
- package/dist/cache/CacheManager.js +131 -0
- package/dist/cache/CacheManager.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/ir/index.d.ts +11 -0
- package/dist/ir/index.d.ts.map +1 -0
- package/dist/ir/index.js +9 -0
- package/dist/ir/index.js.map +1 -0
- package/dist/ir/normalize.d.ts +10 -0
- package/dist/ir/normalize.d.ts.map +1 -0
- package/dist/ir/normalize.js +207 -0
- package/dist/ir/normalize.js.map +1 -0
- package/dist/ir/persistence.d.ts +26 -0
- package/dist/ir/persistence.d.ts.map +1 -0
- package/dist/ir/persistence.js +21 -0
- package/dist/ir/persistence.js.map +1 -0
- package/dist/ir/sync.d.ts +12 -0
- package/dist/ir/sync.d.ts.map +1 -0
- package/dist/ir/sync.js +36 -0
- package/dist/ir/sync.js.map +1 -0
- package/dist/ir/types.d.ts +143 -0
- package/dist/ir/types.d.ts.map +1 -0
- package/dist/ir/types.js +13 -0
- package/dist/ir/types.js.map +1 -0
- package/dist/ir/views/dnd5e.d.ts +40 -0
- package/dist/ir/views/dnd5e.d.ts.map +1 -0
- package/dist/ir/views/dnd5e.js +50 -0
- package/dist/ir/views/dnd5e.js.map +1 -0
- package/dist/render/character.d.ts +19 -0
- package/dist/render/character.d.ts.map +1 -0
- package/dist/render/character.js +156 -0
- package/dist/render/character.js.map +1 -0
- package/dist/render/h.d.ts +27 -0
- package/dist/render/h.d.ts.map +1 -0
- package/dist/render/h.js +64 -0
- package/dist/render/h.js.map +1 -0
- package/dist/render/index.d.ts +11 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +8 -0
- package/dist/render/index.js.map +1 -0
- package/dist/render/mount.d.ts +31 -0
- package/dist/render/mount.d.ts.map +1 -0
- package/dist/render/mount.js +63 -0
- package/dist/render/mount.js.map +1 -0
- package/dist/supabase/fields.d.ts.map +1 -0
- package/dist/supabase/fields.js +120 -0
- package/dist/supabase/fields.js.map +1 -0
- package/dist/types/character.d.ts.map +1 -0
- package/dist/types/character.js +5 -0
- package/dist/types/character.js.map +1 -0
- package/package.json +73 -0
- package/src/browser.js +51 -0
- package/src/cache/CacheManager.ts +174 -0
- package/src/common/browser-polyfill.js +319 -0
- package/src/common/debug.js +123 -0
- package/src/common/html-utils.js +134 -0
- package/src/common/theme-manager.js +265 -0
- package/src/index.ts +25 -0
- package/src/ir/__fixtures__/dnd5e-character.json +75962 -0
- package/src/ir/__fixtures__/non-dnd-character.json +14218 -0
- package/src/ir/index.ts +10 -0
- package/src/ir/normalize.ts +245 -0
- package/src/ir/persistence.ts +37 -0
- package/src/ir/sync.ts +49 -0
- package/src/ir/types.ts +161 -0
- package/src/ir/views/dnd5e.ts +94 -0
- package/src/lib/indexeddb-cache.js +320 -0
- package/src/modules/action-announcements.js +102 -0
- package/src/modules/action-display.js +1557 -0
- package/src/modules/action-executor.js +860 -0
- package/src/modules/action-filters.js +167 -0
- package/src/modules/action-options.js +117 -0
- package/src/modules/card-creator.js +142 -0
- package/src/modules/character-portrait.js +169 -0
- package/src/modules/character-trait-popups.js +959 -0
- package/src/modules/character-traits.js +814 -0
- package/src/modules/class-feature-edge-cases.js +1320 -0
- package/src/modules/color-utils.js +69 -0
- package/src/modules/combat-maneuver-edge-cases.js +660 -0
- package/src/modules/companions-manager.js +178 -0
- package/src/modules/concentration-tracker.js +178 -0
- package/src/modules/data-manager.js +514 -0
- package/src/modules/dice-roller.js +719 -0
- package/src/modules/effects-manager.js +743 -0
- package/src/modules/feature-modals.js +1264 -0
- package/src/modules/formula-resolver.js +444 -0
- package/src/modules/gm-mode.js +184 -0
- package/src/modules/health-modals.js +399 -0
- package/src/modules/hp-management.js +752 -0
- package/src/modules/inventory-manager.js +242 -0
- package/src/modules/macro-system.js +825 -0
- package/src/modules/notification-system.js +92 -0
- package/src/modules/racial-feature-edge-cases.js +746 -0
- package/src/modules/resource-manager.js +775 -0
- package/src/modules/sheet-builder.js +654 -0
- package/src/modules/spell-action-modals.js +583 -0
- package/src/modules/spell-cards.js +602 -0
- package/src/modules/spell-casting.js +723 -0
- package/src/modules/spell-display.js +314 -0
- package/src/modules/spell-edge-cases.js +509 -0
- package/src/modules/spell-macros.js +201 -0
- package/src/modules/spell-modals.js +1221 -0
- package/src/modules/spell-slots.js +224 -0
- package/src/modules/status-bar-bridge.js +101 -0
- package/src/modules/ui-utilities.js +284 -0
- package/src/modules/warlock-invocations.js +219 -0
- package/src/modules/window-management.js +211 -0
- package/src/render/character.ts +234 -0
- package/src/render/h.ts +74 -0
- package/src/render/index.ts +10 -0
- package/src/render/mount.ts +94 -0
- package/src/supabase/client.js +1383 -0
- package/src/supabase/config.js +60 -0
- package/src/supabase/fields.ts +129 -0
- package/src/types/character.ts +85 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spell Cards Module
|
|
3
|
+
*
|
|
4
|
+
* Handles spell card creation, validation, and option generation.
|
|
5
|
+
* - Creates interactive spell card UI elements
|
|
6
|
+
* - Validates spell data structure
|
|
7
|
+
* - Generates spell options (attack, damage, healing, etc.)
|
|
8
|
+
*
|
|
9
|
+
* Loaded as a plain script (no ES6 modules) to export to globalThis.
|
|
10
|
+
*
|
|
11
|
+
* TODO: Refactor inline styles to CSS classes to fix CSP warnings
|
|
12
|
+
* - Replace element.style assignments with classList.add()
|
|
13
|
+
* - Replace inline event handlers with addEventListener()
|
|
14
|
+
* - This will eliminate ~100+ CSP warnings in the browser console
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
(function() {
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a spell card UI element
|
|
22
|
+
* @param {object} spell - Spell object
|
|
23
|
+
* @param {number} index - Spell index
|
|
24
|
+
* @returns {HTMLElement} Spell card element
|
|
25
|
+
*/
|
|
26
|
+
function createSpellCard(spell, index) {
|
|
27
|
+
const card = document.createElement('div');
|
|
28
|
+
card.className = 'spell-card';
|
|
29
|
+
|
|
30
|
+
const header = document.createElement('div');
|
|
31
|
+
header.className = 'spell-header';
|
|
32
|
+
|
|
33
|
+
// Build tags string
|
|
34
|
+
let tags = '';
|
|
35
|
+
if (spell.concentration) {
|
|
36
|
+
tags += '<span class="concentration-tag">🧠 Concentration</span>';
|
|
37
|
+
}
|
|
38
|
+
if (spell.ritual) {
|
|
39
|
+
tags += `<button class="ritual-tag ritual-cast-btn" data-spell-index="${index}" style="padding: 4px 8px; background: #8e44ad; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; transition: background 0.2s;" onmouseover="this.style.background='#9b59b6'" onmouseout="this.style.background='#8e44ad'" title="Cast as ritual (no spell slot required)">📖 Ritual</button>`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// All spells get a single Cast button that opens a modal with options
|
|
43
|
+
const castButtonHTML = `<button class="cast-spell-modal-btn" data-spell-index="${index}" style="padding: 6px 12px; background: #9b59b6; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">✨ Cast</button>`;
|
|
44
|
+
|
|
45
|
+
// Custom macro override button (for magic items and custom spells) - only shown if setting is enabled
|
|
46
|
+
const overrideButtonHTML = (typeof showCustomMacroButtons !== 'undefined' && showCustomMacroButtons)
|
|
47
|
+
? `<button class="custom-macro-btn" data-spell-index="${index}" style="padding: 6px 12px; background: #34495e; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;" title="Configure custom macros for this spell">⚙️</button>`
|
|
48
|
+
: '';
|
|
49
|
+
|
|
50
|
+
header.innerHTML = `
|
|
51
|
+
<div>
|
|
52
|
+
<span style="font-weight: bold;">${spell.name}</span>
|
|
53
|
+
${spell.level ? `<span style="margin-left: 10px; color: #666;">Level ${spell.level}</span>` : ''}
|
|
54
|
+
${tags}
|
|
55
|
+
</div>
|
|
56
|
+
<div style="display: flex; gap: 8px;">
|
|
57
|
+
${castButtonHTML}
|
|
58
|
+
${overrideButtonHTML}
|
|
59
|
+
<button class="toggle-btn">▼ Details</button>
|
|
60
|
+
</div>
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const desc = document.createElement('div');
|
|
64
|
+
desc.className = 'spell-description';
|
|
65
|
+
desc.id = `spell-desc-${index}`;
|
|
66
|
+
|
|
67
|
+
const debug = window.debug || console;
|
|
68
|
+
|
|
69
|
+
// Debug spell data
|
|
70
|
+
if (spell.attackRoll || spell.damage) {
|
|
71
|
+
debug.log(`📝 Spell "${spell.name}" has attack/damage:`, { attackRoll: spell.attackRoll, damage: spell.damage, damageType: spell.damageType });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Resolve DiceCloud inline calculations ({#spellList.dc}, {max(slotLevel,1)},
|
|
75
|
+
// {120 * (1 + spellSniper)}, etc.) in any displayed text so players see real
|
|
76
|
+
// numbers instead of raw template syntax. Falls back to the raw text if the
|
|
77
|
+
// resolver isn't loaded.
|
|
78
|
+
const rv = (text) => (text && typeof resolveVariablesInFormula === 'function')
|
|
79
|
+
? resolveVariablesInFormula(String(text))
|
|
80
|
+
: text;
|
|
81
|
+
|
|
82
|
+
// Build full description from summary and description fields
|
|
83
|
+
let fullDescription = '';
|
|
84
|
+
if (spell.summary && spell.description) {
|
|
85
|
+
fullDescription = `${rv(spell.summary)}<br><br>${rv(spell.description)}`;
|
|
86
|
+
} else if (spell.summary) {
|
|
87
|
+
fullDescription = rv(spell.summary);
|
|
88
|
+
} else if (spell.description) {
|
|
89
|
+
fullDescription = rv(spell.description);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
desc.innerHTML = `
|
|
93
|
+
${spell.castingTime ? `<div><strong>Casting Time:</strong> ${rv(spell.castingTime)}</div>` : ''}
|
|
94
|
+
${spell.range ? `<div><strong>Range:</strong> ${rv(spell.range)}</div>` : ''}
|
|
95
|
+
${spell.components ? `<div><strong>Components:</strong> ${rv(spell.components)}</div>` : ''}
|
|
96
|
+
${spell.duration ? `<div><strong>Duration:</strong> ${rv(spell.duration)}</div>` : ''}
|
|
97
|
+
${spell.school ? `<div><strong>School:</strong> ${spell.school}</div>` : ''}
|
|
98
|
+
${spell.source ? `<div><strong>Source:</strong> ${spell.source}</div>` : ''}
|
|
99
|
+
${fullDescription ? `<div style="margin-top: 10px;"><strong>Summary:</strong> ${fullDescription}</div>` : ''}
|
|
100
|
+
${spell.formula ? `<button class="roll-btn">🎲 Roll ${spell.formula}</button>` : ''}
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
// Toggle functionality
|
|
104
|
+
const toggleBtn = header.querySelector('.toggle-btn');
|
|
105
|
+
header.addEventListener('click', (e) => {
|
|
106
|
+
if (!e.target.classList.contains('roll-btn') &&
|
|
107
|
+
!e.target.classList.contains('cast-spell-modal-btn') &&
|
|
108
|
+
!e.target.classList.contains('ritual-cast-btn')) {
|
|
109
|
+
desc.classList.toggle('expanded');
|
|
110
|
+
toggleBtn.textContent = desc.classList.contains('expanded') ? '▲ Hide' : '▼ Details';
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Roll button
|
|
115
|
+
const rollBtn = desc.querySelector('.roll-btn');
|
|
116
|
+
if (rollBtn && spell.formula && typeof roll === 'function') {
|
|
117
|
+
rollBtn.addEventListener('click', (e) => {
|
|
118
|
+
e.stopPropagation();
|
|
119
|
+
roll(spell.name, spell.formula);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Ritual cast button
|
|
124
|
+
const ritualBtn = header.querySelector('.ritual-cast-btn');
|
|
125
|
+
if (ritualBtn) {
|
|
126
|
+
ritualBtn.addEventListener('click', (e) => {
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
debug.log(`📖 Ritual cast button clicked for: ${spell.name}`);
|
|
129
|
+
|
|
130
|
+
// Cast as ritual - no spell slot consumed
|
|
131
|
+
if (typeof announceSpellDescription === 'function') {
|
|
132
|
+
// Announce with ritual note
|
|
133
|
+
const ritualSpell = { ...spell, name: `${spell.name} (Ritual)` };
|
|
134
|
+
announceSpellDescription(ritualSpell);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Cast spell with skipSlotConsumption = true for rituals
|
|
138
|
+
if (typeof castSpell === 'function') {
|
|
139
|
+
castSpell(spell, index, null, spell.level, [], true, true); // skipSlotConsumption = true, skipAnnouncement = true
|
|
140
|
+
} else {
|
|
141
|
+
debug.error('❌ castSpell function not available');
|
|
142
|
+
if (typeof showNotification === 'function') {
|
|
143
|
+
showNotification('❌ Cannot cast spell', 'error');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Cast spell modal button
|
|
150
|
+
const castModalBtn = header.querySelector('.cast-spell-modal-btn');
|
|
151
|
+
if (castModalBtn) {
|
|
152
|
+
castModalBtn.addEventListener('click', (e) => {
|
|
153
|
+
e.stopPropagation();
|
|
154
|
+
|
|
155
|
+
// Check for Divine Smite special handling
|
|
156
|
+
if (spell.name.toLowerCase().includes('divine smite')) {
|
|
157
|
+
debug.log(`⚡ Divine Smite cast button clicked: ${spell.name}, showing custom modal`);
|
|
158
|
+
if (typeof announceSpellDescription === 'function') {
|
|
159
|
+
announceSpellDescription(spell);
|
|
160
|
+
}
|
|
161
|
+
if (typeof showDivineSmiteModal === 'function') {
|
|
162
|
+
showDivineSmiteModal(spell);
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check for Lay on Hands: Heal special handling
|
|
168
|
+
const normalizedSpellName = spell.name.toLowerCase()
|
|
169
|
+
.replace(/[^a-z0-9\s:]/g, '') // Remove special chars except colon and space
|
|
170
|
+
.replace(/\s+/g, ' ') // Normalize spaces
|
|
171
|
+
.trim();
|
|
172
|
+
const normalizedSearch = 'lay on hands: heal';
|
|
173
|
+
|
|
174
|
+
if (normalizedSpellName === normalizedSearch) {
|
|
175
|
+
debug.log(`💚 Lay on Hands: Heal cast button clicked: ${spell.name}, showing custom modal`);
|
|
176
|
+
debug.log(`💚 Normalized match: "${normalizedSpellName}" === "${normalizedSearch}"`);
|
|
177
|
+
if (typeof announceSpellDescription === 'function') {
|
|
178
|
+
announceSpellDescription(spell);
|
|
179
|
+
}
|
|
180
|
+
if (typeof getLayOnHandsResource === 'function') {
|
|
181
|
+
const layOnHandsPool = getLayOnHandsResource();
|
|
182
|
+
if (layOnHandsPool && typeof showLayOnHandsModal === 'function') {
|
|
183
|
+
showLayOnHandsModal(layOnHandsPool);
|
|
184
|
+
} else if (typeof showNotification === 'function') {
|
|
185
|
+
showNotification('❌ No Lay on Hands pool resource found', 'error');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Fallback: Catch ANY Lay on Hands action for debugging
|
|
192
|
+
if (spell.name.toLowerCase().includes('lay on hands')) {
|
|
193
|
+
debug.log(`🚨 FALLBACK: Caught Lay on Hands spell: "${spell.name}"`);
|
|
194
|
+
debug.log(`🚨 This spell didn't match 'lay on hands: heal' but contains 'lay on hands'`);
|
|
195
|
+
debug.log(`🚨 Showing modal anyway for debugging`);
|
|
196
|
+
if (typeof announceSpellDescription === 'function') {
|
|
197
|
+
announceSpellDescription(spell);
|
|
198
|
+
}
|
|
199
|
+
if (typeof getLayOnHandsResource === 'function') {
|
|
200
|
+
const layOnHandsPool = getLayOnHandsResource();
|
|
201
|
+
if (layOnHandsPool && typeof showLayOnHandsModal === 'function') {
|
|
202
|
+
showLayOnHandsModal(layOnHandsPool);
|
|
203
|
+
} else if (typeof showNotification === 'function') {
|
|
204
|
+
showNotification('❌ No Lay on Hands pool resource found', 'error');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const spellOptionsResult = getSpellOptions(spell);
|
|
211
|
+
const options = spellOptionsResult.options;
|
|
212
|
+
|
|
213
|
+
// Check if this is a "too complicated" spell that should only announce
|
|
214
|
+
if (spellOptionsResult.skipNormalButtons) {
|
|
215
|
+
if (typeof announceSpellDescription === 'function') {
|
|
216
|
+
announceSpellDescription(spell);
|
|
217
|
+
}
|
|
218
|
+
if (typeof castSpell === 'function') {
|
|
219
|
+
castSpell(spell, index, null, null, [], false, true); // skipAnnouncement = true
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (options.length === 0) {
|
|
225
|
+
// No rolls - announce description and cast immediately with base spell level
|
|
226
|
+
if (typeof announceSpellDescription === 'function') {
|
|
227
|
+
announceSpellDescription(spell);
|
|
228
|
+
}
|
|
229
|
+
if (typeof castSpell === 'function') {
|
|
230
|
+
// Auto-select base spell level for direct casting (so slots decrement)
|
|
231
|
+
const baseLevel = spell.level && spell.level > 0 ? parseInt(spell.level) : null;
|
|
232
|
+
castSpell(spell, index, null, baseLevel, [], false, true); // skipAnnouncement = true
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
// Has rolls - show modal with options
|
|
236
|
+
debug.log(`✨ Spell "${spell.name}" has ${options.length} options, showing modal`);
|
|
237
|
+
|
|
238
|
+
// Check if concentration recast option will exist in modal
|
|
239
|
+
const hasConcentrationRecast = spell.concentration && typeof concentratingSpell !== 'undefined' && concentratingSpell === spell.name;
|
|
240
|
+
|
|
241
|
+
if (!hasConcentrationRecast) {
|
|
242
|
+
// No concentration recast option - announce description immediately
|
|
243
|
+
if (typeof announceSpellDescription === 'function') {
|
|
244
|
+
announceSpellDescription(spell);
|
|
245
|
+
} else {
|
|
246
|
+
debug.warn('⚠️ announceSpellDescription not available');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (typeof showSpellModal === 'function') {
|
|
250
|
+
debug.log('📋 Calling showSpellModal with descriptionAnnounced=true');
|
|
251
|
+
try {
|
|
252
|
+
showSpellModal(spell, index, options, true); // descriptionAnnounced = true
|
|
253
|
+
} catch (error) {
|
|
254
|
+
debug.error('❌ Error calling showSpellModal:', error);
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
debug.error('❌ showSpellModal function not available!');
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
// Has concentration recast - announce from modal button handlers
|
|
261
|
+
if (typeof showSpellModal === 'function') {
|
|
262
|
+
debug.log('📋 Calling showSpellModal with descriptionAnnounced=false (concentration recast)');
|
|
263
|
+
try {
|
|
264
|
+
showSpellModal(spell, index, options, false); // descriptionAnnounced = false
|
|
265
|
+
} catch (error) {
|
|
266
|
+
debug.error('❌ Error calling showSpellModal:', error);
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
debug.error('❌ showSpellModal function not available!');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Custom macro override button
|
|
277
|
+
const customMacroBtn = header.querySelector('.custom-macro-btn');
|
|
278
|
+
if (customMacroBtn && typeof showCustomMacroModal === 'function') {
|
|
279
|
+
customMacroBtn.addEventListener('click', (e) => {
|
|
280
|
+
e.stopPropagation();
|
|
281
|
+
showCustomMacroModal(spell, index);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
card.appendChild(header);
|
|
286
|
+
card.appendChild(desc);
|
|
287
|
+
return card;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Validate spell data and log any issues
|
|
292
|
+
* Cross-checks parsed data against spell description
|
|
293
|
+
* @param {object} spell - Spell object
|
|
294
|
+
* @returns {object} Validation result with valid flag, issues, and warnings
|
|
295
|
+
*/
|
|
296
|
+
function validateSpellData(spell) {
|
|
297
|
+
const issues = [];
|
|
298
|
+
const warnings = [];
|
|
299
|
+
|
|
300
|
+
// Check if spell has children data
|
|
301
|
+
if (!spell.damageRolls && !spell.attackRoll) {
|
|
302
|
+
console.log(`ℹ️ Spell "${spell.name}" has no attack or damage data (utility spell)`);
|
|
303
|
+
return { valid: true, issues: [], warnings: [] };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Validate attack roll
|
|
307
|
+
if (spell.attackRoll && spell.attackRoll !== '(none)') {
|
|
308
|
+
if (typeof spell.attackRoll !== 'string' || spell.attackRoll.trim() === '') {
|
|
309
|
+
issues.push(`Attack roll is invalid: ${spell.attackRoll}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Validate damage rolls
|
|
314
|
+
if (spell.damageRolls && Array.isArray(spell.damageRolls)) {
|
|
315
|
+
spell.damageRolls.forEach((roll, index) => {
|
|
316
|
+
if (!roll.damage) {
|
|
317
|
+
issues.push(`Damage roll ${index} missing formula`);
|
|
318
|
+
} else if (typeof roll.damage !== 'string' || roll.damage.trim() === '') {
|
|
319
|
+
issues.push(`Damage roll ${index} has invalid formula: ${roll.damage}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!roll.damageType) {
|
|
323
|
+
warnings.push(`Damage roll ${index} missing damage type (will show as "untyped")`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Check for dice notation
|
|
327
|
+
const hasDice = /d\d+/i.test(roll.damage);
|
|
328
|
+
if (!hasDice) {
|
|
329
|
+
warnings.push(`Damage roll "${roll.damage}" doesn't contain dice notation - might be a variable reference`);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Cross-check against description
|
|
335
|
+
const description = (spell.description || '').toLowerCase();
|
|
336
|
+
const summary = (spell.summary || '').toLowerCase();
|
|
337
|
+
const fullText = `${summary} ${description}`;
|
|
338
|
+
|
|
339
|
+
if (fullText) {
|
|
340
|
+
// Check for attack mention (use word boundaries to avoid false positives like Shield's "triggering attack")
|
|
341
|
+
const hasAttackMention = /\b(spell attack|attack roll)\b/i.test(fullText);
|
|
342
|
+
const hasAttackData = spell.attackRoll && spell.attackRoll !== '(none)';
|
|
343
|
+
|
|
344
|
+
if (hasAttackMention && !hasAttackData) {
|
|
345
|
+
warnings.push(`Description mentions attack but no attack roll found`);
|
|
346
|
+
} else if (!hasAttackMention && hasAttackData) {
|
|
347
|
+
warnings.push(`Has attack roll but description doesn't mention attack`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Check for damage mention
|
|
351
|
+
const damageMentions = fullText.match(/(\d+d\d+)/g);
|
|
352
|
+
const hasDamageMention = damageMentions && damageMentions.length > 0;
|
|
353
|
+
const hasDamageData = spell.damageRolls && spell.damageRolls.length > 0;
|
|
354
|
+
|
|
355
|
+
if (hasDamageMention && !hasDamageData) {
|
|
356
|
+
warnings.push(`Description mentions ${damageMentions.join(', ')} but no damage rolls found`);
|
|
357
|
+
} else if (hasDamageData && !hasDamageMention) {
|
|
358
|
+
// This is fine - description might use variables like "spell level" instead of exact dice
|
|
359
|
+
console.log(`ℹ️ "${spell.name}" has ${spell.damageRolls.length} damage rolls but description doesn't show explicit dice`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (issues.length > 0) {
|
|
364
|
+
console.warn(`❌ Validation issues for spell "${spell.name}":`, issues);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (warnings.length > 0) {
|
|
368
|
+
console.warn(`⚠️ Validation warnings for spell "${spell.name}":`, warnings);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (issues.length === 0 && warnings.length === 0) {
|
|
372
|
+
console.log(`✅ Spell "${spell.name}" validated successfully`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return { valid: issues.length === 0, issues, warnings };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Get available spell options (attack/damage rolls)
|
|
380
|
+
* @param {object} spell - Spell object
|
|
381
|
+
* @returns {object} Options object with options array and skipNormalButtons flag
|
|
382
|
+
*/
|
|
383
|
+
function getSpellOptions(spell) {
|
|
384
|
+
// Validate spell data first
|
|
385
|
+
const validation = validateSpellData(spell);
|
|
386
|
+
|
|
387
|
+
// Detailed debug logging to trace damage data
|
|
388
|
+
console.log(`🔮 getSpellOptions for "${spell.name}":`, {
|
|
389
|
+
attackRoll: spell.attackRoll,
|
|
390
|
+
damageRolls: spell.damageRolls,
|
|
391
|
+
damageRollsLength: spell.damageRolls ? spell.damageRolls.length : 'undefined',
|
|
392
|
+
damageRollsContent: JSON.stringify(spell.damageRolls),
|
|
393
|
+
concentration: spell.concentration
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const options = [];
|
|
397
|
+
|
|
398
|
+
// Check for attack (exclude defensive spells which should never have attack button)
|
|
399
|
+
const spellNameLower = (spell.name || '').toLowerCase();
|
|
400
|
+
const isDefensiveSpell = spellNameLower === 'shield' ||
|
|
401
|
+
spellNameLower.startsWith('shield ') ||
|
|
402
|
+
spellNameLower === 'absorb elements' ||
|
|
403
|
+
spellNameLower === 'counterspell';
|
|
404
|
+
|
|
405
|
+
if (spell.attackRoll && spell.attackRoll !== '(none)' && !isDefensiveSpell) {
|
|
406
|
+
// Handle special flag from dicecloud.js that indicates we should use spell attack bonus
|
|
407
|
+
let attackFormula = spell.attackRoll;
|
|
408
|
+
if (attackFormula === 'use_spell_attack_bonus' && typeof getSpellAttackBonus === 'function') {
|
|
409
|
+
const attackBonus = getSpellAttackBonus();
|
|
410
|
+
attackFormula = attackBonus >= 0 ? `1d20+${attackBonus}` : `1d20${attackBonus}`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
options.push({
|
|
414
|
+
type: 'attack',
|
|
415
|
+
label: '⚔️ Spell Attack',
|
|
416
|
+
formula: attackFormula,
|
|
417
|
+
icon: '⚔️',
|
|
418
|
+
color: '#e74c3c'
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Check for damage/healing rolls
|
|
423
|
+
if (spell.damageRolls && spell.damageRolls.length > 0) {
|
|
424
|
+
// Handle lifesteal spells specially (damage + healing based on damage dealt)
|
|
425
|
+
if (spell.isLifesteal) {
|
|
426
|
+
const damageRoll = spell.damageRolls.find(r => r.damageType && r.damageType.toLowerCase() !== 'healing');
|
|
427
|
+
const healingRoll = spell.damageRolls.find(r => r.damageType && r.damageType.toLowerCase() === 'healing');
|
|
428
|
+
|
|
429
|
+
if (damageRoll && healingRoll) {
|
|
430
|
+
// Resolve formula for display
|
|
431
|
+
let displayFormula = damageRoll.damage;
|
|
432
|
+
if (displayFormula.includes('~target.level') && characterData.level) {
|
|
433
|
+
displayFormula = displayFormula.replace(/~target\.level/g, characterData.level);
|
|
434
|
+
}
|
|
435
|
+
if (typeof resolveVariablesInFormula === 'function') {
|
|
436
|
+
displayFormula = resolveVariablesInFormula(displayFormula);
|
|
437
|
+
}
|
|
438
|
+
if (typeof evaluateMathInFormula === 'function') {
|
|
439
|
+
displayFormula = evaluateMathInFormula(displayFormula);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Format damage type
|
|
443
|
+
let damageTypeLabel = '';
|
|
444
|
+
if (damageRoll.damageType && damageRoll.damageType !== 'untyped') {
|
|
445
|
+
damageTypeLabel = damageRoll.damageType.charAt(0).toUpperCase() + damageRoll.damageType.slice(1);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Check healing formula to determine healing ratio
|
|
449
|
+
const healingFormula = healingRoll.damage.toLowerCase();
|
|
450
|
+
let healingRatio = 'full';
|
|
451
|
+
if (healingFormula.includes('/ 2') || healingFormula.includes('*0.5') || healingFormula.includes('half')) {
|
|
452
|
+
healingRatio = 'half';
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
options.push({
|
|
456
|
+
type: 'lifesteal',
|
|
457
|
+
label: `${displayFormula} ${damageTypeLabel} + Heal (${healingRatio})`,
|
|
458
|
+
damageFormula: damageRoll.damage,
|
|
459
|
+
healingFormula: healingRoll.damage,
|
|
460
|
+
damageType: damageRoll.damageType,
|
|
461
|
+
healingRatio: healingRatio,
|
|
462
|
+
icon: '💉',
|
|
463
|
+
color: 'linear-gradient(135deg, #c0392b 0%, #27ae60 100%)'
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
// Normal spells - show separate buttons for each damage/healing type
|
|
468
|
+
spell.damageRolls.forEach((roll, index) => {
|
|
469
|
+
// Skip rolls that are part of an OR group (they'll be represented by the main roll)
|
|
470
|
+
if (roll.isOrGroupMember) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const isHealing = roll.damageType && roll.damageType.toLowerCase() === 'healing';
|
|
475
|
+
const isTempHP = roll.damageType && (
|
|
476
|
+
roll.damageType.toLowerCase() === 'temphp' ||
|
|
477
|
+
roll.damageType.toLowerCase() === 'temporary' ||
|
|
478
|
+
roll.damageType.toLowerCase().includes('temp')
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
// Resolve non-slot-dependent variables for display (character level, ability mods, etc.)
|
|
482
|
+
// Keep slotLevel as-is since we don't know what slot will be used yet
|
|
483
|
+
let displayFormula = roll.damage || roll.formula || '';
|
|
484
|
+
let actualFormula = roll.damage || roll.formula || ''; // Keep separate from display formula
|
|
485
|
+
|
|
486
|
+
// Skip if no formula
|
|
487
|
+
if (!displayFormula) {
|
|
488
|
+
debug.warn(`⚠️ Skipping damage roll for "${spell.name}" - no formula found`);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Apply warlock invocation modifications to damage
|
|
493
|
+
if (typeof getActiveInvocations === 'function' && typeof applyInvocationToDamage === 'function') {
|
|
494
|
+
const activeInvocations = getActiveInvocations(characterData);
|
|
495
|
+
if (activeInvocations.length > 0) {
|
|
496
|
+
const modified = applyInvocationToDamage(spell.name, displayFormula, activeInvocations, characterData);
|
|
497
|
+
if (modified.modified) {
|
|
498
|
+
displayFormula = modified.display;
|
|
499
|
+
actualFormula = modified.formula;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Replace ~target.level with character level (for cantrips like Toll the Dead)
|
|
505
|
+
if (displayFormula && displayFormula.includes('~target.level') && characterData.level) {
|
|
506
|
+
displayFormula = displayFormula.replace(/~target\.level/g, characterData.level);
|
|
507
|
+
actualFormula = actualFormula.replace(/~target\.level/g, characterData.level);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (typeof resolveVariablesInFormula === 'function') {
|
|
511
|
+
displayFormula = resolveVariablesInFormula(displayFormula);
|
|
512
|
+
}
|
|
513
|
+
if (typeof evaluateMathInFormula === 'function') {
|
|
514
|
+
displayFormula = evaluateMathInFormula(displayFormula);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// If this roll has OR choices, create separate buttons for each choice
|
|
518
|
+
if (roll.orChoices && roll.orChoices.length > 1) {
|
|
519
|
+
roll.orChoices.forEach(choice => {
|
|
520
|
+
// Format damage type nicely
|
|
521
|
+
let damageTypeLabel = '';
|
|
522
|
+
if (choice.damageType && choice.damageType !== 'untyped') {
|
|
523
|
+
damageTypeLabel = choice.damageType.charAt(0).toUpperCase() + choice.damageType.slice(1);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const label = damageTypeLabel ? `${displayFormula} ${damageTypeLabel}` : displayFormula;
|
|
527
|
+
|
|
528
|
+
const choiceIsTempHP = choice.damageType === 'temphp' || choice.damageType === 'temporary' ||
|
|
529
|
+
(choice.damageType && choice.damageType.toLowerCase().includes('temp'));
|
|
530
|
+
|
|
531
|
+
options.push({
|
|
532
|
+
type: choiceIsTempHP ? 'temphp' : (isHealing ? 'healing' : 'damage'),
|
|
533
|
+
label: label,
|
|
534
|
+
formula: actualFormula, // Use actualFormula which includes Agonizing Blast modifier
|
|
535
|
+
damageType: choice.damageType,
|
|
536
|
+
index: index,
|
|
537
|
+
icon: choiceIsTempHP ? '🛡️' : (isHealing ? '💚' : '💥'),
|
|
538
|
+
color: choiceIsTempHP ? '#3498db' : (isHealing ? '#27ae60' : '#e67e22')
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
} else {
|
|
542
|
+
// Single damage type - create one button
|
|
543
|
+
// Format damage type nicely
|
|
544
|
+
let damageTypeLabel = '';
|
|
545
|
+
if (roll.damageType && roll.damageType !== 'untyped') {
|
|
546
|
+
// Capitalize first letter
|
|
547
|
+
damageTypeLabel = roll.damageType.charAt(0).toUpperCase() + roll.damageType.slice(1);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Build label: formula + damage type
|
|
551
|
+
const label = damageTypeLabel ? `${displayFormula} ${damageTypeLabel}` : displayFormula;
|
|
552
|
+
|
|
553
|
+
options.push({
|
|
554
|
+
type: isTempHP ? 'temphp' : (isHealing ? 'healing' : 'damage'),
|
|
555
|
+
label: label,
|
|
556
|
+
formula: actualFormula, // Use actualFormula which includes Agonizing Blast modifier
|
|
557
|
+
damageType: roll.damageType,
|
|
558
|
+
index: index,
|
|
559
|
+
icon: isTempHP ? '🛡️' : (isHealing ? '💚' : '💥'),
|
|
560
|
+
color: isTempHP ? '#3498db' : (isHealing ? '#27ae60' : '#e67e22')
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Log options before edge case modifications
|
|
568
|
+
console.log(`📋 getSpellOptions "${spell.name}" - options before edge cases:`, options.map(o => `${o.type}: ${o.label}`));
|
|
569
|
+
|
|
570
|
+
// If spell has BOTH attack AND damage options, add a "Cast Spell" button first
|
|
571
|
+
// This allows users to cast the spell (consume slot) without immediately rolling attack or damage
|
|
572
|
+
const hasAttack = options.some(opt => opt.type === 'attack');
|
|
573
|
+
const hasDamage = options.some(opt => opt.type === 'damage' || opt.type === 'healing');
|
|
574
|
+
if (hasAttack && hasDamage) {
|
|
575
|
+
options.unshift({
|
|
576
|
+
type: 'cast',
|
|
577
|
+
label: 'Cast Spell',
|
|
578
|
+
icon: '✨',
|
|
579
|
+
color: '#9b59b6',
|
|
580
|
+
edgeCaseNote: 'Cast without rolling - then click Attack or Damage'
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Apply edge case modifications
|
|
585
|
+
const result = typeof applyEdgeCaseModifications === 'function'
|
|
586
|
+
? applyEdgeCaseModifications(spell, options)
|
|
587
|
+
: { options, skipNormalButtons: false };
|
|
588
|
+
|
|
589
|
+
console.log(`📋 getSpellOptions "${spell.name}" - final options:`, result.options?.map(o => `${o.type}: ${o.label}`), 'skipNormalButtons:', result.skipNormalButtons);
|
|
590
|
+
return result;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Export functions to globalThis
|
|
594
|
+
Object.assign(globalThis, {
|
|
595
|
+
createSpellCard,
|
|
596
|
+
validateSpellData,
|
|
597
|
+
getSpellOptions
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
console.log('✅ Spell Cards module loaded');
|
|
601
|
+
|
|
602
|
+
})();
|