@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,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
|
+
}
|