@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,860 @@
1
+ /**
2
+ * Action Executor - Central D&D Logic Module
3
+ *
4
+ * This module consolidates all D&D game logic into a single importable file:
5
+ * - All 4 edge case modules (spell, class feature, racial, combat maneuver)
6
+ * - Metamagic system (costs, detection, resolution)
7
+ * - Resource detection (sorcery points, ki, pact magic, channel divinity)
8
+ * - Spell slot resolution and consumption logic
9
+ * - Action option building (attack/damage/healing buttons + edge cases)
10
+ * - Execution functions that return { text, rolls, effects }
11
+ *
12
+ * Consumers:
13
+ * - popup-sheet.html (loads edge cases as scripts, then this file)
14
+ * - background.js (service worker loads edge cases via importScripts, then this file)
15
+ *
16
+ * All functions accept characterData as a parameter (no closures/globals).
17
+ *
18
+ * Note: Edge case modules must be loaded BEFORE this file:
19
+ * - popup-sheet.html: loads via <script> tags
20
+ * - background.js: loads via importScripts()
21
+ */
22
+
23
+ // Edge case functions are available on globalThis/window from previously loaded modules
24
+
25
+
26
+ // ===== METAMAGIC SYSTEM =====
27
+
28
+ /**
29
+ * Official Sorcerer metamagic costs (in sorcery points)
30
+ */
31
+ const METAMAGIC_COSTS = {
32
+ 'Careful Spell': 1,
33
+ 'Distant Spell': 1,
34
+ 'Empowered Spell': 1,
35
+ 'Extended Spell': 1,
36
+ 'Heightened Spell': 3,
37
+ 'Quickened Spell': 2,
38
+ 'Subtle Spell': 1,
39
+ 'Twinned Spell': 'variable' // Cost equals spell level (min 1 for cantrips)
40
+ };
41
+
42
+ /**
43
+ * Calculate metamagic sorcery point cost
44
+ * @param {string} metamagicName - Name of the metamagic option
45
+ * @param {number} spellLevel - Level of the spell being cast
46
+ * @returns {number} Cost in sorcery points
47
+ */
48
+ function calculateMetamagicCost(metamagicName, spellLevel) {
49
+ const cost = METAMAGIC_COSTS[metamagicName];
50
+ if (cost === 'variable') {
51
+ // Twinned Spell costs spell level (minimum 1 for cantrips)
52
+ return Math.max(1, spellLevel);
53
+ }
54
+ return cost || 0;
55
+ }
56
+
57
+ /**
58
+ * Find available metamagic options from character features
59
+ * @param {Object} characterData - Full character data object
60
+ * @returns {Array<{name: string, cost: number|string, description: string}>}
61
+ */
62
+ function getAvailableMetamagic(characterData) {
63
+ if (!characterData || !characterData.features) {
64
+ return [];
65
+ }
66
+
67
+ const metamagicOptions = characterData.features.filter(feature => {
68
+ const name = feature.name.trim();
69
+ let matchedName = null;
70
+
71
+ if (METAMAGIC_COSTS.hasOwnProperty(name)) {
72
+ matchedName = name;
73
+ } else {
74
+ matchedName = Object.keys(METAMAGIC_COSTS).find(key =>
75
+ key.toLowerCase() === name.toLowerCase()
76
+ );
77
+ }
78
+
79
+ if (matchedName) {
80
+ feature._matchedName = matchedName;
81
+ return true;
82
+ }
83
+ return false;
84
+ }).map(feature => {
85
+ const matchedName = feature._matchedName || feature.name.trim();
86
+ return {
87
+ name: matchedName,
88
+ cost: METAMAGIC_COSTS[matchedName],
89
+ description: feature.description || ''
90
+ };
91
+ });
92
+
93
+ return metamagicOptions;
94
+ }
95
+
96
+ /**
97
+ * Find sorcery points resource in character data
98
+ * @param {Object} characterData - Full character data object
99
+ * @returns {Object|null} Resource object with { name, current, max } or null
100
+ */
101
+ function getSorceryPointsResource(characterData) {
102
+ if (!characterData || !characterData.resources) {
103
+ return null;
104
+ }
105
+
106
+ const sorceryResource = characterData.resources.find(r => {
107
+ const lowerName = r.name.toLowerCase().trim();
108
+ return (
109
+ lowerName.includes('sorcery point') ||
110
+ lowerName === 'sorcery points' ||
111
+ lowerName === 'sorcery' ||
112
+ lowerName.includes('sorcerer point')
113
+ );
114
+ });
115
+
116
+ return sorceryResource || null;
117
+ }
118
+
119
+
120
+ // ===== RESOURCE DETECTION =====
121
+
122
+ /**
123
+ * Check if a spell comes from a magic item (doesn't consume spell slots)
124
+ * @param {Object} spell - Spell data object
125
+ * @returns {boolean}
126
+ */
127
+ function isMagicItemSpell(spell) {
128
+ if (!spell.source) return false;
129
+ const src = spell.source.toLowerCase();
130
+ return (
131
+ src.includes('amulet') ||
132
+ src.includes('ring') ||
133
+ src.includes('wand') ||
134
+ src.includes('staff') ||
135
+ src.includes('rod') ||
136
+ src.includes('cloak') ||
137
+ src.includes('boots') ||
138
+ src.includes('bracers') ||
139
+ src.includes('gauntlets') ||
140
+ src.includes('helm') ||
141
+ src.includes('armor') ||
142
+ src.includes('weapon') ||
143
+ src.includes('talisman') ||
144
+ src.includes('orb') ||
145
+ src.includes('scroll') ||
146
+ src.includes('potion')
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Check if a spell is free (has item consumption but no slot cost)
152
+ * @param {Object} spell - Spell data object
153
+ * @returns {boolean}
154
+ */
155
+ function isFreeSpell(spell) {
156
+ return !!(
157
+ spell.resources &&
158
+ spell.resources.itemsConsumed &&
159
+ spell.resources.itemsConsumed.length > 0
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Detect class-specific resources available for casting (Ki, Pact Magic, Channel Divinity)
165
+ * @param {Object} characterData - Full character data object
166
+ * @returns {Array<{name: string, current: number, max: number, varName: string, variableName: string}>}
167
+ */
168
+ function detectClassResources(characterData) {
169
+ const resources = [];
170
+ const otherVars = (characterData && characterData.otherVariables) || {};
171
+
172
+ // Check for Ki (Monk)
173
+ if (otherVars.ki !== undefined || otherVars.kiPoints !== undefined) {
174
+ const ki = otherVars.ki || otherVars.kiPoints || 0;
175
+ const kiMax = otherVars.kiMax || otherVars.kiPointsMax || 0;
176
+ const kiVarName = otherVars.ki !== undefined ? 'ki' : 'kiPoints';
177
+ if (kiMax > 0) {
178
+ resources.push({
179
+ name: 'Ki',
180
+ current: ki,
181
+ max: kiMax,
182
+ varName: kiVarName,
183
+ variableName: kiVarName
184
+ });
185
+ }
186
+ }
187
+
188
+ // NOTE: Sorcery Points are NOT a casting resource - they're only used for metamagic
189
+
190
+ // Check for Pact Magic slots (Warlock)
191
+ if (otherVars.pactMagicSlots !== undefined) {
192
+ const slots = otherVars.pactMagicSlots || 0;
193
+ const slotsMax = otherVars.pactMagicSlotsMax || 0;
194
+ if (slotsMax > 0) {
195
+ resources.push({
196
+ name: 'Pact Magic',
197
+ current: slots,
198
+ max: slotsMax,
199
+ varName: 'pactMagicSlots',
200
+ variableName: 'pactMagicSlots'
201
+ });
202
+ }
203
+ }
204
+
205
+ // Check for Channel Divinity (Cleric/Paladin)
206
+ let channelDivinityVarName = null;
207
+ let channelDivinityUses = 0;
208
+ let channelDivinityMax = 0;
209
+
210
+ if (otherVars.channelDivinityCleric !== undefined) {
211
+ channelDivinityVarName = 'channelDivinityCleric';
212
+ channelDivinityUses = otherVars.channelDivinityCleric || 0;
213
+ channelDivinityMax = otherVars.channelDivinityClericMax || 0;
214
+ } else if (otherVars.channelDivinityPaladin !== undefined) {
215
+ channelDivinityVarName = 'channelDivinityPaladin';
216
+ channelDivinityUses = otherVars.channelDivinityPaladin || 0;
217
+ channelDivinityMax = otherVars.channelDivinityPaladinMax || 0;
218
+ } else if (otherVars.channelDivinity !== undefined) {
219
+ channelDivinityVarName = 'channelDivinity';
220
+ channelDivinityUses = otherVars.channelDivinity || 0;
221
+ channelDivinityMax = otherVars.channelDivinityMax || 0;
222
+ }
223
+
224
+ if (channelDivinityVarName && channelDivinityMax > 0) {
225
+ resources.push({
226
+ name: 'Channel Divinity',
227
+ current: channelDivinityUses,
228
+ max: channelDivinityMax,
229
+ varName: channelDivinityVarName,
230
+ variableName: channelDivinityVarName
231
+ });
232
+ }
233
+
234
+ return resources;
235
+ }
236
+
237
+
238
+ // ===== ACTION OPTIONS (edge case application) =====
239
+
240
+ /**
241
+ * Determine the action options (attack/damage/heal buttons) for an action,
242
+ * with edge case modifications applied.
243
+ *
244
+ * @param {Object} action - Action data { name, attackRoll, damage, damageType, actionType, ... }
245
+ * @param {Object} [characterData] - Character data (used for ruleset detection in edge cases)
246
+ * @returns {{ options: Array, skipNormalButtons: boolean }}
247
+ */
248
+ function getActionOptions(action, characterData = null) {
249
+ const options = [];
250
+
251
+ // Check for attack
252
+ if (action.attackRoll) {
253
+ let formula = action.attackRoll;
254
+ if (typeof formula === 'number' || !formula.includes('d20')) {
255
+ const bonus = parseInt(formula);
256
+ formula = bonus >= 0 ? `1d20+${bonus}` : `1d20${bonus}`;
257
+ }
258
+
259
+ options.push({
260
+ type: 'attack',
261
+ label: 'Attack',
262
+ formula: formula,
263
+ icon: 'attack',
264
+ color: '#e74c3c'
265
+ });
266
+ }
267
+
268
+ // Check for damage/healing rolls
269
+ const isValidDiceFormula = action.damage && (
270
+ /\d*d\d+/.test(action.damage) ||
271
+ /\d*d\d+/.test(action.damage.replace(/\s*\+\s*/g, '+'))
272
+ );
273
+
274
+ if (isValidDiceFormula) {
275
+ const isHealing = action.damageType && action.damageType.toLowerCase().includes('heal');
276
+ const isTempHP = action.damageType && (
277
+ action.damageType.toLowerCase() === 'temphp' ||
278
+ action.damageType.toLowerCase() === 'temporary' ||
279
+ action.damageType.toLowerCase().includes('temp')
280
+ );
281
+
282
+ let btnText;
283
+ if (isHealing) {
284
+ btnText = 'Heal';
285
+ } else if (action.actionType === 'feature' || !action.attackRoll) {
286
+ btnText = 'Roll';
287
+ } else {
288
+ btnText = 'Damage';
289
+ }
290
+
291
+ options.push({
292
+ type: isHealing ? 'healing' : (isTempHP ? 'temphp' : 'damage'),
293
+ label: btnText,
294
+ formula: action.damage,
295
+ icon: isTempHP ? 'shield' : (isHealing ? 'heal' : 'damage'),
296
+ color: isTempHP ? '#3498db' : (isHealing ? '#27ae60' : '#e67e22')
297
+ });
298
+ }
299
+
300
+ // Apply edge case modifications
301
+ let edgeCaseResult;
302
+
303
+ if (isClassFeatureEdgeCase(action.name)) {
304
+ edgeCaseResult = applyClassFeatureEdgeCaseModifications(action, options);
305
+ } else if (isRacialFeatureEdgeCase(action.name)) {
306
+ edgeCaseResult = applyRacialFeatureEdgeCaseModifications(action, options);
307
+ } else if (isCombatManeuverEdgeCase(action.name)) {
308
+ edgeCaseResult = applyCombatManeuverEdgeCaseModifications(action, options);
309
+ } else {
310
+ edgeCaseResult = { options, skipNormalButtons: false };
311
+ }
312
+
313
+ return edgeCaseResult;
314
+ }
315
+
316
+
317
+ // ===== SPELL EXECUTION LOGIC =====
318
+
319
+ /**
320
+ * Resolve what happens when a spell is cast. Returns a structured result
321
+ * describing the effects without performing any side effects (no UI, no storage).
322
+ *
323
+ * @param {Object} spell - Spell data { name, level, source, resources, concentration, damage, attackRoll, ... }
324
+ * @param {Object} characterData - Full character data
325
+ * @param {Object} [options] - Cast options
326
+ * @param {number|string|null} [options.selectedSlotLevel] - Chosen slot level (null = auto)
327
+ * @param {Array} [options.selectedMetamagic] - Array of { name, cost } metamagic selections
328
+ * @param {boolean} [options.skipSlotConsumption] - Skip slot usage (concentration recast)
329
+ * @returns {{ text: string, rolls: Array, effects: Array, slotUsed: Object|null, metamagicUsed: Array, isCantrip: boolean, isFreecast: boolean, resourceChanges: Array }}
330
+ */
331
+ function resolveSpellCast(spell, characterData, options = {}) {
332
+ const {
333
+ selectedSlotLevel = null,
334
+ selectedMetamagic = [],
335
+ skipSlotConsumption = false
336
+ } = options;
337
+
338
+ const result = {
339
+ text: '',
340
+ rolls: [],
341
+ effects: [],
342
+ slotUsed: null,
343
+ metamagicUsed: [],
344
+ isCantrip: false,
345
+ isFreecast: false,
346
+ resourceChanges: []
347
+ };
348
+
349
+ const magicItem = isMagicItemSpell(spell);
350
+ const freeCast = isFreeSpell(spell);
351
+ const isCantrip = !spell.level || spell.level === 0 || spell.level === '0';
352
+
353
+ // Determine if this cast is free (no slot needed)
354
+ if (isCantrip || magicItem || freeCast || skipSlotConsumption) {
355
+ result.isCantrip = isCantrip;
356
+ result.isFreecast = magicItem || freeCast || skipSlotConsumption;
357
+
358
+ const reason = skipSlotConsumption ? 'concentration recast' :
359
+ (magicItem ? 'magic item' : (freeCast ? 'free spell' : 'cantrip'));
360
+
361
+ result.text = `Cast ${spell.name} (${reason})`;
362
+
363
+ if (spell.concentration && !skipSlotConsumption) {
364
+ result.effects.push({ type: 'concentration', spell: spell.name });
365
+ }
366
+
367
+ if (isReuseableSpell(spell.name, characterData) && !skipSlotConsumption) {
368
+ result.effects.push({ type: 'track_reusable', spell: spell.name });
369
+ }
370
+
371
+ // Collect rolls from spell data
372
+ if (spell.attackRoll && spell.attackRoll !== '(none)') {
373
+ result.rolls.push({ type: 'attack', formula: spell.attackRoll, name: `${spell.name} - Attack` });
374
+ }
375
+ if (spell.damageRolls && Array.isArray(spell.damageRolls)) {
376
+ spell.damageRolls.forEach(roll => {
377
+ if (roll.damage) {
378
+ const damageType = roll.damageType || 'damage';
379
+ const isHealing = damageType.toLowerCase() === 'healing';
380
+ result.rolls.push({
381
+ type: isHealing ? 'healing' : 'damage',
382
+ formula: roll.damage,
383
+ name: `${spell.name} - ${isHealing ? 'Healing' : damageType}`,
384
+ damageType
385
+ });
386
+ }
387
+ });
388
+ } else if (spell.damage) {
389
+ const damageType = spell.damageType || 'damage';
390
+ const isHealing = damageType.toLowerCase() === 'healing';
391
+ result.rolls.push({
392
+ type: isHealing ? 'healing' : 'damage',
393
+ formula: spell.damage,
394
+ name: `${spell.name} - ${isHealing ? 'Healing' : damageType}`,
395
+ damageType
396
+ });
397
+ }
398
+
399
+ return result;
400
+ }
401
+
402
+ // Spell needs a slot
403
+ const spellLevel = parseInt(spell.level);
404
+
405
+ if (selectedSlotLevel !== null) {
406
+ const isPactMagicSlot = typeof selectedSlotLevel === 'string' && selectedSlotLevel.startsWith('pact:');
407
+ let actualLevel, slotVar, slotLabel;
408
+
409
+ if (isPactMagicSlot) {
410
+ actualLevel = parseInt(selectedSlotLevel.split(':')[1]);
411
+ slotVar = 'pactMagicSlots';
412
+ slotLabel = `Pact Magic (level ${actualLevel})`;
413
+ } else {
414
+ actualLevel = parseInt(selectedSlotLevel);
415
+ slotVar = `level${actualLevel}SpellSlots`;
416
+ slotLabel = actualLevel > spellLevel
417
+ ? `Level ${actualLevel} slot (upcast from ${spell.level})`
418
+ : `Level ${actualLevel} slot`;
419
+ }
420
+
421
+ result.slotUsed = {
422
+ level: actualLevel,
423
+ slotVar,
424
+ isPactMagic: isPactMagicSlot,
425
+ label: slotLabel
426
+ };
427
+ result.resourceChanges.push({ type: 'spell_slot', slotVar, delta: -1 });
428
+
429
+ // Handle metamagic
430
+ if (selectedMetamagic && selectedMetamagic.length > 0) {
431
+ let totalCost = 0;
432
+ selectedMetamagic.forEach(meta => {
433
+ const cost = typeof meta.cost === 'number' ? meta.cost : calculateMetamagicCost(meta.name, actualLevel);
434
+ totalCost += cost;
435
+ result.metamagicUsed.push({ name: meta.name, cost });
436
+ });
437
+ result.resourceChanges.push({ type: 'sorcery_points', delta: -totalCost });
438
+ result.text = `Cast ${spell.name} using ${slotLabel} + ${result.metamagicUsed.map(m => m.name).join(', ')} (${totalCost} SP)`;
439
+ } else {
440
+ result.text = `Cast ${spell.name} using ${slotLabel}`;
441
+ }
442
+ } else {
443
+ // No slot selected - caller should prompt for upcast
444
+ result.text = `Cast ${spell.name} (needs level ${spellLevel}+ slot)`;
445
+ result.effects.push({ type: 'needs_slot_selection', minLevel: spellLevel });
446
+ }
447
+
448
+ if (spell.concentration) {
449
+ result.effects.push({ type: 'concentration', spell: spell.name });
450
+ }
451
+
452
+ if (isReuseableSpell(spell.name, characterData)) {
453
+ result.effects.push({ type: 'track_reusable', spell: spell.name });
454
+ }
455
+
456
+ // Collect rolls
457
+ if (spell.attackRoll && spell.attackRoll !== '(none)') {
458
+ result.rolls.push({ type: 'attack', formula: spell.attackRoll, name: `${spell.name} - Attack` });
459
+ }
460
+ if (spell.damageRolls && Array.isArray(spell.damageRolls)) {
461
+ spell.damageRolls.forEach(roll => {
462
+ if (roll.damage) {
463
+ const damageType = roll.damageType || 'damage';
464
+ const isHealing = damageType.toLowerCase() === 'healing';
465
+ result.rolls.push({
466
+ type: isHealing ? 'healing' : 'damage',
467
+ formula: roll.damage,
468
+ name: `${spell.name} - ${isHealing ? 'Healing' : damageType}`,
469
+ damageType
470
+ });
471
+ }
472
+ });
473
+ } else if (spell.damage) {
474
+ const damageType = spell.damageType || 'damage';
475
+ const isHealing = damageType.toLowerCase() === 'healing';
476
+ result.rolls.push({
477
+ type: isHealing ? 'healing' : 'damage',
478
+ formula: spell.damage,
479
+ name: `${spell.name} - ${isHealing ? 'Healing' : damageType}`,
480
+ damageType
481
+ });
482
+ }
483
+
484
+ // Check for spell edge cases
485
+ if (isTooComplicatedSpell(spell.name, characterData)) {
486
+ result.effects.push({ type: 'too_complicated', description: 'Requires DM intervention' });
487
+ }
488
+
489
+ return result;
490
+ }
491
+
492
+ /**
493
+ * Resolve what happens when an action/ability is used. Returns a structured result.
494
+ *
495
+ * @param {Object} action - Action data { name, attackRoll, damage, damageType, actionType, description, range, ... }
496
+ * @param {Object} characterData - Full character data
497
+ * @returns {{ text: string, rolls: Array, effects: Array, edgeCase: Object|null }}
498
+ */
499
+ function resolveActionUse(action, characterData = null) {
500
+ const result = {
501
+ text: `${action.name}`,
502
+ rolls: [],
503
+ effects: [],
504
+ edgeCase: null
505
+ };
506
+
507
+ // Get options with edge cases applied
508
+ const actionResult = getActionOptions(action, characterData);
509
+ result.edgeCase = actionResult.skipNormalButtons ? actionResult : null;
510
+
511
+ // Build rolls from options
512
+ actionResult.options.forEach(opt => {
513
+ if (opt.formula) {
514
+ result.rolls.push({
515
+ type: opt.type,
516
+ formula: opt.formula,
517
+ name: `${action.name} - ${opt.label}`,
518
+ damageType: action.damageType
519
+ });
520
+ }
521
+ });
522
+
523
+ // Add description if available
524
+ if (action.description) {
525
+ result.effects.push({ type: 'description', text: action.description });
526
+ }
527
+
528
+ return result;
529
+ }
530
+
531
+
532
+ // ===== DISCORD INTEGRATION HELPERS =====
533
+
534
+ /**
535
+ * Prepare spell list metadata for Discord bot autocomplete and command options.
536
+ * Takes raw character data (as stored in Supabase raw_dicecloud_data) and returns
537
+ * an enriched spell list that tells the bot what options to offer.
538
+ *
539
+ * @param {Object|string} rawCharacterData - Character data (object or JSON string)
540
+ * @returns {{ spells: Array<{name, baseLevel, upcastable, maxUpcastLevel, edgeOverride, edgeCaseType, concentration, school, castingTime, range, components, duration, hasAttack, hasDamage, hasHealing}>, metamagic: Array<{name, cost}>, maxSpellSlotLevel: number }}
541
+ */
542
+ function prepareSpellsForDiscord(rawCharacterData) {
543
+ const data = typeof rawCharacterData === 'string'
544
+ ? JSON.parse(rawCharacterData)
545
+ : rawCharacterData;
546
+
547
+ const characterData = data || {};
548
+ const spells = characterData.spells || [];
549
+ const spellSlots = characterData.spellSlots || {};
550
+
551
+ // Determine highest spell slot level available
552
+ let maxSpellSlotLevel = 0;
553
+ for (let i = 9; i >= 1; i--) {
554
+ const maxKey = `level${i}SpellSlotsMax`;
555
+ if (spellSlots[maxKey] && spellSlots[maxKey] > 0) {
556
+ maxSpellSlotLevel = i;
557
+ break;
558
+ }
559
+ }
560
+ // Also check pact magic
561
+ const otherVars = characterData.otherVariables || {};
562
+ if (otherVars.pactMagicSlotsMax && otherVars.pactMagicSlotsMax > 0) {
563
+ const pactLevel = otherVars.pactMagicSlotLevel || 1;
564
+ maxSpellSlotLevel = Math.max(maxSpellSlotLevel, pactLevel);
565
+ }
566
+
567
+ const enrichedSpells = spells.map(spell => {
568
+ const baseLevel = parseInt(spell.level) || 0;
569
+ const isCantrip = baseLevel === 0;
570
+ const spellEdge = isEdgeCase(spell.name);
571
+ let edgeCaseType = null;
572
+ if (spellEdge) {
573
+ const ec = getEdgeCase(spell.name);
574
+ edgeCaseType = ec ? ec.type : null;
575
+ }
576
+
577
+ return {
578
+ name: spell.name,
579
+ baseLevel,
580
+ upcastable: !isCantrip && baseLevel < 9,
581
+ maxUpcastLevel: isCantrip ? 0 : Math.max(baseLevel, maxSpellSlotLevel),
582
+ edgeOverride: spellEdge,
583
+ edgeCaseType,
584
+ concentration: !!spell.concentration,
585
+ ritual: !!spell.ritual,
586
+ school: spell.school || null,
587
+ castingTime: spell.castingTime || null,
588
+ range: spell.range || null,
589
+ components: spell.components || null,
590
+ duration: spell.duration || null,
591
+ hasAttack: !!(spell.attackRoll && spell.attackRoll !== '(none)'),
592
+ hasDamage: !!(spell.damage || (spell.damageRolls && spell.damageRolls.length > 0)),
593
+ hasHealing: !!(spell.damageType && spell.damageType.toLowerCase().includes('heal'))
594
+ };
595
+ });
596
+
597
+ // Metamagic options available to this character
598
+ const metamagic = getAvailableMetamagic(characterData);
599
+
600
+ return {
601
+ spells: enrichedSpells,
602
+ metamagic,
603
+ maxSpellSlotLevel
604
+ };
605
+ }
606
+
607
+ /**
608
+ * Prepare action list metadata for Discord bot autocomplete.
609
+ *
610
+ * @param {Object|string} rawCharacterData - Character data (object or JSON string)
611
+ * @returns {{ actions: Array<{name, actionType, hasAttack, hasDamage, hasHealing, edgeOverride, edgeCaseType, range, description}> }}
612
+ */
613
+ function prepareActionsForDiscord(rawCharacterData) {
614
+ const data = typeof rawCharacterData === 'string'
615
+ ? JSON.parse(rawCharacterData)
616
+ : rawCharacterData;
617
+
618
+ const characterData = data || {};
619
+ const actions = characterData.actions || [];
620
+
621
+ const enrichedActions = actions.map(action => {
622
+ const classEdge = isClassFeatureEdgeCase(action.name);
623
+ const racialEdge = isRacialFeatureEdgeCase(action.name);
624
+ const combatEdge = isCombatManeuverEdgeCase(action.name);
625
+ const edgeOverride = classEdge || racialEdge || combatEdge;
626
+
627
+ const isHealing = action.damageType && action.damageType.toLowerCase().includes('heal');
628
+ const hasDamage = !!(action.damage && /\d*d\d+/.test(action.damage));
629
+
630
+ return {
631
+ name: action.name,
632
+ actionType: action.actionType || 'action',
633
+ hasAttack: !!action.attackRoll,
634
+ hasDamage: hasDamage && !isHealing,
635
+ hasHealing: hasDamage && isHealing,
636
+ edgeOverride,
637
+ edgeCaseType: classEdge ? 'class_feature' : (racialEdge ? 'racial_feature' : (combatEdge ? 'combat_maneuver' : null)),
638
+ range: action.range || null,
639
+ description: action.description || null
640
+ };
641
+ });
642
+
643
+ return { actions: enrichedActions };
644
+ }
645
+
646
+ /**
647
+ * Execute a Discord /cast command. Takes the command_data payload from the bot
648
+ * and returns a full execution result without any side effects.
649
+ *
650
+ * @param {Object} commandData - { spell_name, spell_level, cast_level, character_name, character_id, notification_color, spell_data, metamagic? }
651
+ * @param {Object} characterData - Full character data from raw_dicecloud_data
652
+ * @returns {{ text: string, rolls: Array, effects: Array, slotUsed: Object|null, metamagicUsed: Array, embed: Object }}
653
+ */
654
+ function executeDiscordCast(commandData, characterData) {
655
+ const spell = commandData.spell_data || {};
656
+ const castLevel = commandData.cast_level || commandData.spell_level || spell.level;
657
+ const charName = commandData.character_name || 'Character';
658
+ const metamagicName = commandData.metamagic || null;
659
+
660
+ // Check if this is a too-complicated spell
661
+ if (isTooComplicatedSpell(spell.name, characterData)) {
662
+ return {
663
+ text: `${charName} casts ${spell.name}! (DM adjudication required)`,
664
+ rolls: [],
665
+ effects: [{ type: 'too_complicated', description: spell.description || 'Requires DM intervention' }],
666
+ slotUsed: null,
667
+ metamagicUsed: [],
668
+ embed: {
669
+ title: `${charName} casts ${spell.name}`,
670
+ description: 'This spell requires DM adjudication.',
671
+ spellLevel: parseInt(spell.level) || 0,
672
+ castLevel: parseInt(castLevel) || 0
673
+ }
674
+ };
675
+ }
676
+
677
+ // Resolve metamagic if specified
678
+ const selectedMetamagic = [];
679
+ if (metamagicName) {
680
+ const cost = calculateMetamagicCost(metamagicName, parseInt(castLevel) || 0);
681
+ selectedMetamagic.push({ name: metamagicName, cost });
682
+ }
683
+
684
+ // Use resolveSpellCast for the actual logic
685
+ const result = resolveSpellCast(spell, characterData, {
686
+ selectedSlotLevel: parseInt(castLevel) || null,
687
+ selectedMetamagic,
688
+ skipSlotConsumption: false
689
+ });
690
+
691
+ // Build embed data for the Discord response
692
+ const spellLevel = parseInt(spell.level) || 0;
693
+ const isUpcast = parseInt(castLevel) > spellLevel;
694
+
695
+ result.embed = {
696
+ title: `${charName} casts ${spell.name}`,
697
+ description: formatSpellSummary(spell, parseInt(castLevel)),
698
+ spellLevel,
699
+ castLevel: parseInt(castLevel) || spellLevel,
700
+ isUpcast,
701
+ metamagic: selectedMetamagic.map(m => m.name)
702
+ };
703
+
704
+ return result;
705
+ }
706
+
707
+ /**
708
+ * Execute a Discord /use command. Takes command_data and returns execution result.
709
+ *
710
+ * @param {Object} commandData - { action_name, action_type, character_name, character_id, notification_color, action_data }
711
+ * @param {Object} characterData - Full character data from raw_dicecloud_data
712
+ * @returns {{ text: string, rolls: Array, effects: Array, edgeCase: Object|null, embed: Object }}
713
+ */
714
+ function executeDiscordAction(commandData, characterData) {
715
+ const action = commandData.action_data || commandData.action || {};
716
+ const charName = commandData.character_name || 'Character';
717
+
718
+ const result = resolveActionUse(action, characterData);
719
+
720
+ result.embed = {
721
+ title: `${charName} uses ${action.name || commandData.action_name}`,
722
+ description: formatActionSummary(action),
723
+ actionType: action.actionType || commandData.action_type || 'action'
724
+ };
725
+
726
+ return result;
727
+ }
728
+
729
+
730
+ // ===== FORMATTING HELPERS (for Discord embed descriptions) =====
731
+
732
+ /**
733
+ * Format a spell summary for Discord embed. Uses the actual field names from
734
+ * DiceCloud data (damage, damageRolls, damageType) rather than the mismatched
735
+ * ones (damageRoll, healingRoll).
736
+ */
737
+ function formatSpellSummary(spell, castLevel) {
738
+ let desc = '';
739
+
740
+ if (spell.castingTime) desc += `**Casting Time:** ${spell.castingTime}\n`;
741
+ if (spell.range) desc += `**Range:** ${spell.range}\n`;
742
+ if (spell.duration && spell.duration !== 'Instantaneous') desc += `**Duration:** ${spell.duration}\n`;
743
+ if (spell.components) desc += `**Components:** ${spell.components}\n`;
744
+ if (spell.concentration) desc += `**Concentration:** Yes\n`;
745
+
746
+ // Use correct field names from DiceCloud extraction
747
+ if (spell.damageRolls && Array.isArray(spell.damageRolls) && spell.damageRolls.length > 0) {
748
+ spell.damageRolls.forEach(roll => {
749
+ if (roll.damage) {
750
+ const type = roll.damageType || 'damage';
751
+ const isHealing = type.toLowerCase() === 'healing';
752
+ const label = isHealing ? 'Healing' : type.charAt(0).toUpperCase() + type.slice(1);
753
+ desc += `**${label}:** ${roll.damage}`;
754
+ if (castLevel && parseInt(spell.level) > 0 && castLevel > parseInt(spell.level)) {
755
+ desc += ` (upcast to level ${castLevel})`;
756
+ }
757
+ desc += '\n';
758
+ }
759
+ });
760
+ } else if (spell.damage) {
761
+ const type = spell.damageType || 'damage';
762
+ const isHealing = type.toLowerCase() === 'healing';
763
+ const label = isHealing ? 'Healing' : 'Damage';
764
+ desc += `**${label}:** ${spell.damage}`;
765
+ if (castLevel && parseInt(spell.level) > 0 && castLevel > parseInt(spell.level)) {
766
+ desc += ` (upcast to level ${castLevel})`;
767
+ }
768
+ desc += '\n';
769
+ }
770
+
771
+ // Backward compat: also check damageRoll/healingRoll if present
772
+ if (!spell.damage && !spell.damageRolls) {
773
+ if (spell.damageRoll) desc += `**Damage:** ${spell.damageRoll}\n`;
774
+ if (spell.healingRoll) desc += `**Healing:** ${spell.healingRoll}\n`;
775
+ }
776
+
777
+ if (spell.attackRoll && spell.attackRoll !== '(none)') {
778
+ desc += `**Attack:** ${spell.attackRoll}\n`;
779
+ }
780
+
781
+ return desc || 'Spell cast.';
782
+ }
783
+
784
+ /**
785
+ * Format an action summary for Discord embed.
786
+ */
787
+ function formatActionSummary(action) {
788
+ let desc = '';
789
+
790
+ if (action.attackRoll || action.attackBonus) {
791
+ const formula = action.attackRoll || `+${action.attackBonus}`;
792
+ desc += `**Attack:** ${formula}\n`;
793
+ }
794
+
795
+ if (action.damage && /\d*d\d+/.test(action.damage)) {
796
+ const type = action.damageType || 'damage';
797
+ const isHealing = type.toLowerCase().includes('heal');
798
+ desc += `**${isHealing ? 'Healing' : 'Damage'}:** ${action.damage}`;
799
+ if (action.damageType && !isHealing) desc += ` ${action.damageType}`;
800
+ desc += '\n';
801
+ }
802
+
803
+ if (action.range) desc += `**Range:** ${action.range}\n`;
804
+ if (action.duration && action.duration !== 'Instantaneous') desc += `**Duration:** ${action.duration}\n`;
805
+
806
+ return desc || 'Action used.';
807
+ }
808
+
809
+ // Expose all functions and constants to globalThis for importScripts usage
810
+ if (typeof globalThis !== 'undefined') {
811
+ // Edge case modules (re-exported from the individual edge case files)
812
+ globalThis.SPELL_EDGE_CASES = globalThis.SPELL_EDGE_CASES;
813
+ globalThis.isEdgeCase = globalThis.isEdgeCase;
814
+ globalThis.getEdgeCase = globalThis.getEdgeCase;
815
+ globalThis.applyEdgeCaseModifications = globalThis.applyEdgeCaseModifications;
816
+ globalThis.isReuseableSpell = globalThis.isReuseableSpell;
817
+ globalThis.isTooComplicatedSpell = globalThis.isTooComplicatedSpell;
818
+ globalThis.detectRulesetFromCharacterData = globalThis.detectRulesetFromCharacterData;
819
+
820
+ globalThis.CLASS_FEATURE_EDGE_CASES = globalThis.CLASS_FEATURE_EDGE_CASES;
821
+ globalThis.isClassFeatureEdgeCase = globalThis.isClassFeatureEdgeCase;
822
+ globalThis.getClassFeatureEdgeCase = globalThis.getClassFeatureEdgeCase;
823
+ globalThis.applyClassFeatureEdgeCaseModifications = globalThis.applyClassFeatureEdgeCaseModifications;
824
+ globalThis.getClassFeaturesByType = globalThis.getClassFeaturesByType;
825
+ globalThis.getAllClassFeatureEdgeCaseTypes = globalThis.getAllClassFeatureEdgeCaseTypes;
826
+
827
+ globalThis.RACIAL_FEATURE_EDGE_CASES = globalThis.RACIAL_FEATURE_EDGE_CASES;
828
+ globalThis.isRacialFeatureEdgeCase = globalThis.isRacialFeatureEdgeCase;
829
+ globalThis.getRacialFeatureEdgeCase = globalThis.getRacialFeatureEdgeCase;
830
+ globalThis.applyRacialFeatureEdgeCaseModifications = globalThis.applyRacialFeatureEdgeCaseModifications;
831
+
832
+ globalThis.COMBAT_MANEUVER_EDGE_CASES = globalThis.COMBAT_MANEUVER_EDGE_CASES;
833
+ globalThis.isCombatManeuverEdgeCase = globalThis.isCombatManeuverEdgeCase;
834
+ globalThis.getCombatManeuverEdgeCase = globalThis.getCombatManeuverEdgeCase;
835
+ globalThis.applyCombatManeuverEdgeCaseModifications = globalThis.applyCombatManeuverEdgeCaseModifications;
836
+
837
+ // Metamagic system
838
+ globalThis.METAMAGIC_COSTS = METAMAGIC_COSTS;
839
+ globalThis.calculateMetamagicCost = calculateMetamagicCost;
840
+ globalThis.getAvailableMetamagic = getAvailableMetamagic;
841
+ globalThis.getSorceryPointsResource = getSorceryPointsResource;
842
+
843
+ // Resource detection
844
+ globalThis.isMagicItemSpell = isMagicItemSpell;
845
+ globalThis.isFreeSpell = isFreeSpell;
846
+ globalThis.detectClassResources = detectClassResources;
847
+
848
+ // Action options
849
+ globalThis.getActionOptions = getActionOptions;
850
+
851
+ // Spell execution
852
+ globalThis.resolveSpellCast = resolveSpellCast;
853
+ globalThis.resolveActionUse = resolveActionUse;
854
+
855
+ // Discord integration
856
+ globalThis.prepareSpellsForDiscord = prepareSpellsForDiscord;
857
+ globalThis.prepareActionsForDiscord = prepareActionsForDiscord;
858
+ globalThis.executeDiscordCast = executeDiscordCast;
859
+ globalThis.executeDiscordAction = executeDiscordAction;
860
+ }