@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,719 @@
1
+ /**
2
+ * Dice Roller Module
3
+ *
4
+ * Core dice rolling system with math evaluation, effect modifiers, and VTT integration.
5
+ * Handles all dice roll execution, advantage/disadvantage, and optional effects.
6
+ *
7
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
8
+ *
9
+ * Functions exported to globalThis:
10
+ * - safeMathEval(expr)
11
+ * - evaluateMathInFormula(formula)
12
+ * - applyEffectModifiers(rollName, formula)
13
+ * - checkOptionalEffects(rollName, formula, onApply)
14
+ * - showOptionalEffectPopup(effects, rollName, formula, onApply)
15
+ * - roll(name, formula, prerolledResult)
16
+ * - applyAdvantageToFormula(formula, effectNotes)
17
+ * - executeRoll(name, formula, effectNotes, prerolledResult)
18
+ */
19
+
20
+ (function() {
21
+ 'use strict';
22
+
23
+ // ===== CORE FUNCTIONS =====
24
+
25
+ /**
26
+ * Safe math expression evaluator that doesn't use eval() or Function()
27
+ * CSP-compliant parser for basic arithmetic and Math functions
28
+ */
29
+ function safeMathEval(expr) {
30
+ // Tokenize the expression
31
+ const tokens = [];
32
+ let i = 0;
33
+ expr = expr.replace(/\s+/g, ''); // Remove whitespace
34
+
35
+ while (i < expr.length) {
36
+ // Check for Math functions and max/min
37
+ if (expr.substr(i, 10) === 'Math.floor') {
38
+ tokens.push({ type: 'function', value: 'floor' });
39
+ i += 10;
40
+ } else if (expr.substr(i, 9) === 'Math.ceil') {
41
+ tokens.push({ type: 'function', value: 'ceil' });
42
+ i += 9;
43
+ } else if (expr.substr(i, 10) === 'Math.round') {
44
+ tokens.push({ type: 'function', value: 'round' });
45
+ i += 10;
46
+ } else if (expr.substr(i, 3) === 'max') {
47
+ tokens.push({ type: 'function', value: 'max' });
48
+ i += 3;
49
+ } else if (expr.substr(i, 3) === 'min') {
50
+ tokens.push({ type: 'function', value: 'min' });
51
+ i += 3;
52
+ } else if (expr[i] >= '0' && expr[i] <= '9' || expr[i] === '.') {
53
+ // Parse number
54
+ let num = '';
55
+ while (i < expr.length && (expr[i] >= '0' && expr[i] <= '9' || expr[i] === '.')) {
56
+ num += expr[i];
57
+ i++;
58
+ }
59
+ tokens.push({ type: 'number', value: parseFloat(num) });
60
+ } else if ('+-*/(),'.includes(expr[i])) {
61
+ tokens.push({ type: 'operator', value: expr[i] });
62
+ i++;
63
+ } else {
64
+ throw new Error(`Unexpected character: ${expr[i]}`);
65
+ }
66
+ }
67
+
68
+ // Parse and evaluate using recursive descent
69
+ let pos = 0;
70
+
71
+ function parseExpression() {
72
+ let left = parseTerm();
73
+
74
+ while (pos < tokens.length && tokens[pos].type === 'operator' && (tokens[pos].value === '+' || tokens[pos].value === '-')) {
75
+ const op = tokens[pos].value;
76
+ pos++;
77
+ const right = parseTerm();
78
+ left = op === '+' ? left + right : left - right;
79
+ }
80
+
81
+ return left;
82
+ }
83
+
84
+ function parseTerm() {
85
+ let left = parseFactor();
86
+
87
+ while (pos < tokens.length && tokens[pos].type === 'operator' && (tokens[pos].value === '*' || tokens[pos].value === '/')) {
88
+ const op = tokens[pos].value;
89
+ pos++;
90
+ const right = parseFactor();
91
+ left = op === '*' ? left * right : left / right;
92
+ }
93
+
94
+ return left;
95
+ }
96
+
97
+ function parseFactor() {
98
+ const token = tokens[pos];
99
+
100
+ // Handle numbers
101
+ if (token.type === 'number') {
102
+ pos++;
103
+ return token.value;
104
+ }
105
+
106
+ // Handle Math functions
107
+ if (token.type === 'function') {
108
+ const funcName = token.value;
109
+ pos++;
110
+ if (pos >= tokens.length || tokens[pos].value !== '(') {
111
+ throw new Error('Expected ( after function name');
112
+ }
113
+ pos++; // Skip (
114
+
115
+ // Handle multiple arguments for max/min functions
116
+ const args = [];
117
+ if (funcName === 'max' || funcName === 'min') {
118
+ // Parse comma-separated arguments
119
+ args.push(parseExpression());
120
+ while (pos < tokens.length && tokens[pos].value === ',') {
121
+ pos++; // Skip comma
122
+ args.push(parseExpression());
123
+ }
124
+ } else {
125
+ // Single argument for other functions
126
+ args.push(parseExpression());
127
+ }
128
+
129
+ if (pos >= tokens.length || tokens[pos].value !== ')') {
130
+ throw new Error('Expected ) after function argument');
131
+ }
132
+ pos++; // Skip )
133
+
134
+ if (funcName === 'floor') return Math.floor(args[0]);
135
+ if (funcName === 'ceil') return Math.ceil(args[0]);
136
+ if (funcName === 'round') return Math.round(args[0]);
137
+ if (funcName === 'max') return Math.max(...args);
138
+ if (funcName === 'min') return Math.min(...args);
139
+ throw new Error(`Unknown function: ${funcName}`);
140
+ }
141
+
142
+ // Handle parentheses
143
+ if (token.type === 'operator' && token.value === '(') {
144
+ pos++;
145
+ const result = parseExpression();
146
+ if (pos >= tokens.length || tokens[pos].value !== ')') {
147
+ throw new Error('Mismatched parentheses');
148
+ }
149
+ pos++;
150
+ return result;
151
+ }
152
+
153
+ // Handle unary minus
154
+ if (token.type === 'operator' && token.value === '-') {
155
+ pos++;
156
+ return -parseFactor();
157
+ }
158
+
159
+ throw new Error(`Unexpected token: ${JSON.stringify(token)}`);
160
+ }
161
+
162
+ return parseExpression();
163
+ }
164
+
165
+ /**
166
+ * Evaluate simple mathematical expressions in formulas
167
+ * Converts things like "5*5" to "25" before rolling dice
168
+ * CSP-compliant - does not use eval() or Function() constructor
169
+ */
170
+ function evaluateMathInFormula(formula) {
171
+ if (!formula || typeof formula !== 'string') {
172
+ return formula;
173
+ }
174
+
175
+ let currentFormula = formula;
176
+ let previousFormula = null;
177
+ let iterations = 0;
178
+ const maxIterations = 10; // Prevent infinite loops
179
+
180
+ // Keep simplifying until formula doesn't change or max iterations reached
181
+ while (currentFormula !== previousFormula && iterations < maxIterations) {
182
+ previousFormula = currentFormula;
183
+ iterations++;
184
+
185
+ // Replace floor() with Math.floor() for parsing
186
+ let processedFormula = currentFormula.replace(/floor\(/g, 'Math.floor(');
187
+ processedFormula = processedFormula.replace(/ceil\(/g, 'Math.ceil(');
188
+ processedFormula = processedFormula.replace(/round\(/g, 'Math.round(');
189
+
190
+ // Check if the formula is just a simple math expression (no dice)
191
+ // Pattern: numbers and operators only (e.g., "5*5", "10+5", "20/4")
192
+ const simpleMathPattern = /^[\d\s+\-*/().]+$/;
193
+
194
+ if (simpleMathPattern.test(processedFormula)) {
195
+ try {
196
+ const result = safeMathEval(processedFormula);
197
+ if (typeof result === 'number' && !isNaN(result)) {
198
+ debug.log(`✅ Evaluated simple math: ${currentFormula} = ${result} (iteration ${iterations})`);
199
+ currentFormula = String(result);
200
+ continue;
201
+ }
202
+ } catch (e) {
203
+ debug.log(`⚠️ Could not evaluate math expression: ${currentFormula}`, e);
204
+ }
205
+ }
206
+
207
+ // Handle formulas with dice notation like "(floor((9 + 1) / 6) + 1)d8" or "(3 * 1)d6"
208
+ // Extract math before the dice, evaluate it, then reconstruct
209
+ const dicePattern = /^(.+?)(d\d+.*)$/i;
210
+ const match = processedFormula.match(dicePattern);
211
+
212
+ if (match) {
213
+ const mathPart = match[1]; // e.g., "(Math.floor((9 + 1) / 6) + 1)" or "(3 * 1)"
214
+ const dicePart = match[2]; // e.g., "d8" or "d6"
215
+
216
+ // Check if the math part is evaluable (allows numbers, operators, parens, and Math functions)
217
+ const mathOnlyPattern = /^[\d\s+\-*/().\w]+$/;
218
+ if (mathOnlyPattern.test(mathPart)) {
219
+ try {
220
+ const result = safeMathEval(mathPart);
221
+ if (typeof result === 'number' && !isNaN(result)) {
222
+ debug.log(`✅ Evaluated dice formula math: ${mathPart} = ${result} (iteration ${iterations})`);
223
+ currentFormula = String(result) + dicePart;
224
+ continue;
225
+ }
226
+ } catch (e) {
227
+ debug.log(`⚠️ Could not evaluate dice formula math: ${mathPart}`, e);
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ if (iterations > 1) {
234
+ debug.log(`🔄 Formula simplified in ${iterations} iterations: "${formula}" -> "${currentFormula}"`);
235
+ }
236
+
237
+ return currentFormula;
238
+ }
239
+
240
+ /**
241
+ * Apply active effect modifiers to a roll
242
+ * @param {string} rollName - Name of the roll (e.g., "Attack", "Perception", "Strength Save")
243
+ * @param {string} formula - Original formula
244
+ * @returns {object} - { modifiedFormula, effectNotes }
245
+ */
246
+ function applyEffectModifiers(rollName, formula) {
247
+ const rollLower = rollName.toLowerCase();
248
+ let modifiedFormula = formula;
249
+ const effectNotes = [];
250
+
251
+ // Combine all active effects
252
+ const allEffects = [
253
+ ...activeBuffs.map(name => ({ ...POSITIVE_EFFECTS.find(e => e.name === name), type: 'buff' })),
254
+ ...activeConditions.map(name => ({ ...NEGATIVE_EFFECTS.find(e => e.name === name), type: 'debuff' }))
255
+ ].filter(e => e && e.autoApply);
256
+
257
+ debug.log(`🎲 Checking effects for roll: ${rollName}`, allEffects);
258
+
259
+ for (const effect of allEffects) {
260
+ if (!effect.modifier) continue;
261
+
262
+ let applied = false;
263
+
264
+ // Check for attack roll modifiers
265
+ if (rollLower.includes('attack') && effect.modifier.attack) {
266
+ const mod = effect.modifier.attack;
267
+ if (mod === 'advantage') {
268
+ effectNotes.push(`[${effect.icon} ${effect.name}: Advantage]`);
269
+ applied = true;
270
+ } else if (mod === 'disadvantage') {
271
+ effectNotes.push(`[${effect.icon} ${effect.name}: Disadvantage]`);
272
+ applied = true;
273
+ } else {
274
+ modifiedFormula += ` + ${mod}`;
275
+ effectNotes.push(`[${effect.icon} ${effect.name}: ${mod}]`);
276
+ applied = true;
277
+ }
278
+ }
279
+
280
+ // Check for saving throw modifiers
281
+ if (rollLower.includes('save') && (effect.modifier.save || effect.modifier.strSave || effect.modifier.dexSave)) {
282
+ const mod = effect.modifier.save ||
283
+ (rollLower.includes('strength') && effect.modifier.strSave) ||
284
+ (rollLower.includes('dexterity') && effect.modifier.dexSave);
285
+
286
+ if (mod === 'advantage') {
287
+ effectNotes.push(`[${effect.icon} ${effect.name}: Advantage]`);
288
+ applied = true;
289
+ } else if (mod === 'disadvantage') {
290
+ effectNotes.push(`[${effect.icon} ${effect.name}: Disadvantage]`);
291
+ applied = true;
292
+ } else if (mod === 'fail') {
293
+ effectNotes.push(`[${effect.icon} ${effect.name}: Auto-fail]`);
294
+ applied = true;
295
+ } else if (mod) {
296
+ modifiedFormula += ` + ${mod}`;
297
+ effectNotes.push(`[${effect.icon} ${effect.name}: ${mod}]`);
298
+ applied = true;
299
+ }
300
+ }
301
+
302
+ // Check for skill check modifiers
303
+ if ((rollLower.includes('check') || rollLower.includes('perception') ||
304
+ rollLower.includes('stealth') || rollLower.includes('investigation') ||
305
+ rollLower.includes('insight') || rollLower.includes('persuasion') ||
306
+ rollLower.includes('deception') || rollLower.includes('intimidation') ||
307
+ rollLower.includes('athletics') || rollLower.includes('acrobatics')) &&
308
+ effect.modifier.skill) {
309
+ const mod = effect.modifier.skill;
310
+ if (mod === 'advantage') {
311
+ effectNotes.push(`[${effect.icon} ${effect.name}: Advantage]`);
312
+ applied = true;
313
+ } else if (mod === 'disadvantage') {
314
+ effectNotes.push(`[${effect.icon} ${effect.name}: Disadvantage]`);
315
+ applied = true;
316
+ } else {
317
+ modifiedFormula += ` + ${mod}`;
318
+ effectNotes.push(`[${effect.icon} ${effect.name}: ${mod}]`);
319
+ applied = true;
320
+ }
321
+ }
322
+
323
+ // Check for damage modifiers
324
+ if (rollLower.includes('damage') && effect.modifier.damage) {
325
+ modifiedFormula += ` + ${effect.modifier.damage}`;
326
+ effectNotes.push(`[${effect.icon} ${effect.name}: +${effect.modifier.damage}]`);
327
+ applied = true;
328
+ }
329
+
330
+ if (applied) {
331
+ debug.log(`✅ Applied ${effect.name} (${effect.type}) to ${rollName}`);
332
+ }
333
+ }
334
+
335
+ return { modifiedFormula, effectNotes };
336
+ }
337
+
338
+ /**
339
+ * Check for optional effects that could apply to a roll and show popup if found
340
+ * @param {string} rollName - Name of the roll
341
+ * @param {string} formula - Original formula
342
+ * @param {function} onApply - Callback function to apply the effect
343
+ */
344
+ function checkOptionalEffects(rollName, formula, onApply) {
345
+ const rollLower = rollName.toLowerCase();
346
+
347
+ // Combine all active effects that are NOT autoApply
348
+ const optionalEffects = [
349
+ ...activeBuffs.map(name => ({ ...POSITIVE_EFFECTS.find(e => e.name === name), type: 'buff' })),
350
+ ...activeConditions.map(name => ({ ...NEGATIVE_EFFECTS.find(e => e.name === name), type: 'debuff' }))
351
+ ].filter(e => e && !e.autoApply && e.modifier);
352
+
353
+ if (optionalEffects.length === 0) return;
354
+
355
+ debug.log(`🎲 Checking optional effects for roll: ${rollName}`, optionalEffects);
356
+
357
+ const applicableEffects = [];
358
+
359
+ for (const effect of optionalEffects) {
360
+ let applicable = false;
361
+
362
+ // Check for skill check modifiers (for Guidance) - ALL skills
363
+ const isSkillCheck = rollLower.includes('check') ||
364
+ rollLower.includes('acrobatics') || rollLower.includes('animal') ||
365
+ rollLower.includes('arcana') || rollLower.includes('athletics') ||
366
+ rollLower.includes('deception') || rollLower.includes('history') ||
367
+ rollLower.includes('insight') || rollLower.includes('intimidation') ||
368
+ rollLower.includes('investigation') || rollLower.includes('medicine') ||
369
+ rollLower.includes('nature') || rollLower.includes('perception') ||
370
+ rollLower.includes('performance') || rollLower.includes('persuasion') ||
371
+ rollLower.includes('religion') || rollLower.includes('sleight') ||
372
+ rollLower.includes('stealth') || rollLower.includes('survival');
373
+
374
+ if (isSkillCheck && effect.modifier.skill) {
375
+ applicable = true;
376
+ }
377
+
378
+ // Check for attack roll modifiers
379
+ if (rollLower.includes('attack') && effect.modifier.attack) {
380
+ applicable = true;
381
+ }
382
+
383
+ // Check for saving throw modifiers
384
+ if (rollLower.includes('save') && effect.modifier.save) {
385
+ applicable = true;
386
+ }
387
+
388
+ // Special handling for Bardic Inspiration - applies to checks, attacks, and saves
389
+ if (effect.name.startsWith('Bardic Inspiration')) {
390
+ if (rollLower.includes('check') || rollLower.includes('perception') ||
391
+ rollLower.includes('stealth') || rollLower.includes('investigation') ||
392
+ rollLower.includes('insight') || rollLower.includes('persuasion') ||
393
+ rollLower.includes('deception') || rollLower.includes('intimidation') ||
394
+ rollLower.includes('athletics') || rollLower.includes('acrobatics') ||
395
+ rollLower.includes('attack') || rollLower.includes('save')) {
396
+ applicable = true;
397
+ }
398
+ }
399
+
400
+ if (applicable) {
401
+ applicableEffects.push(effect);
402
+ }
403
+ }
404
+
405
+ if (applicableEffects.length > 0) {
406
+ showOptionalEffectPopup(applicableEffects, rollName, formula, onApply);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Show popup for optional effects
412
+ */
413
+ function showOptionalEffectPopup(effects, rollName, formula, onApply) {
414
+ debug.log('🎯 Showing optional effect popup for:', effects);
415
+
416
+ if (!document.body) {
417
+ debug.error('❌ document.body not available for optional effect popup');
418
+ return;
419
+ }
420
+
421
+ // Get theme-aware colors
422
+ const colors = getPopupThemeColors();
423
+
424
+ // Create modal overlay
425
+ const popupOverlay = document.createElement('div');
426
+ popupOverlay.style.cssText = `
427
+ position: fixed;
428
+ top: 0;
429
+ left: 0;
430
+ width: 100%;
431
+ height: 100%;
432
+ background: rgba(0, 0, 0, 0.6);
433
+ backdrop-filter: blur(2px);
434
+ z-index: 10000;
435
+ display: flex;
436
+ align-items: center;
437
+ justify-content: center;
438
+ `;
439
+
440
+ // Create popup content
441
+ const popupContent = document.createElement('div');
442
+ popupContent.style.cssText = `
443
+ background: ${colors.background};
444
+ border-radius: 12px;
445
+ padding: 24px;
446
+ max-width: 400px;
447
+ width: 90%;
448
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
449
+ text-align: center;
450
+ border: 2px solid var(--accent-primary);
451
+ `;
452
+
453
+ // Build effects list
454
+ const effectsList = effects.map(effect => `
455
+ <div style="margin: 12px 0; padding: 12px; background: ${effect.color}20; border: 2px solid ${effect.color}; border-radius: 8px; cursor: pointer; transition: all 0.2s;"
456
+ onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.15)'"
457
+ onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='none'"
458
+ data-effect="${effect.name}" data-type="${effect.type}">
459
+ <div style="display: flex; align-items: center; gap: 8px;">
460
+ <span style="font-size: 1.2em;">${effect.icon}</span>
461
+ <div style="flex: 1; text-align: left;">
462
+ <div style="font-weight: bold; color: var(--text-primary);">${effect.name}</div>
463
+ <div style="font-size: 0.85em; color: var(--text-secondary); margin-top: 2px;">${effect.description}</div>
464
+ </div>
465
+ </div>
466
+ </div>
467
+ `).join('');
468
+
469
+ popupContent.innerHTML = `
470
+ <div style="font-size: 24px; margin-bottom: 16px;">🎯</div>
471
+ <h2 style="margin: 0 0 8px 0; color: ${colors.heading};">Optional Effect Available!</h2>
472
+ <p style="margin: 0 0 16px 0; color: ${colors.text};">
473
+ You can apply an optional effect to your <strong>${rollName}</strong> roll:
474
+ </p>
475
+ ${effectsList}
476
+ <div style="margin-top: 20px; display: flex; gap: 10px; justify-content: center;">
477
+ <button id="skip-effect" style="background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color); padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: 600;">
478
+ Skip
479
+ </button>
480
+ </div>
481
+ `;
482
+
483
+ popupOverlay.appendChild(popupContent);
484
+ document.body.appendChild(popupOverlay);
485
+
486
+ // Add click handlers for effect options
487
+ popupContent.querySelectorAll('[data-effect]').forEach(effectDiv => {
488
+ effectDiv.addEventListener('click', () => {
489
+ const effectName = effectDiv.dataset.effect;
490
+ const effectType = effectDiv.dataset.type;
491
+ const effect = effects.find(e => e.name === effectName);
492
+
493
+ if (effect && onApply) {
494
+ onApply(effect);
495
+ }
496
+
497
+ document.body.removeChild(popupOverlay);
498
+ });
499
+ });
500
+
501
+ // Skip button
502
+ document.getElementById('skip-effect').addEventListener('click', () => {
503
+ document.body.removeChild(popupOverlay);
504
+ });
505
+
506
+ // Close on overlay click
507
+ popupOverlay.addEventListener('click', (e) => {
508
+ if (e.target === popupOverlay) {
509
+ document.body.removeChild(popupOverlay);
510
+ }
511
+ });
512
+
513
+ debug.log('🎯 Optional effect popup displayed');
514
+ }
515
+
516
+ function roll(name, formula, prerolledResult = null) {
517
+ debug.log('🎲 Rolling:', name, formula, prerolledResult ? `(prerolled: ${prerolledResult})` : '');
518
+
519
+ // Resolve any variables in the formula
520
+ let resolvedFormula = resolveVariablesInFormula(formula);
521
+
522
+ // Check if there are any optional effects that could apply to this roll
523
+ const rollLower = name.toLowerCase();
524
+ const optionalEffects = [
525
+ ...activeBuffs.map(name => ({ ...POSITIVE_EFFECTS.find(e => e.name === name), type: 'buff' })),
526
+ ...activeConditions.map(name => ({ ...NEGATIVE_EFFECTS.find(e => e.name === name), type: 'debuff' }))
527
+ ].filter(e => e && !e.autoApply && e.modifier);
528
+
529
+ const hasApplicableOptionalEffects = optionalEffects.some(effect => {
530
+ // Check if this is a skill check (any skill name or the word "check")
531
+ const isSkillCheck = rollLower.includes('check') ||
532
+ rollLower.includes('acrobatics') || rollLower.includes('animal') ||
533
+ rollLower.includes('arcana') || rollLower.includes('athletics') ||
534
+ rollLower.includes('deception') || rollLower.includes('history') ||
535
+ rollLower.includes('insight') || rollLower.includes('intimidation') ||
536
+ rollLower.includes('investigation') || rollLower.includes('medicine') ||
537
+ rollLower.includes('nature') || rollLower.includes('perception') ||
538
+ rollLower.includes('performance') || rollLower.includes('persuasion') ||
539
+ rollLower.includes('religion') || rollLower.includes('sleight') ||
540
+ rollLower.includes('stealth') || rollLower.includes('survival');
541
+
542
+ return (isSkillCheck && effect.modifier.skill) ||
543
+ (rollLower.includes('attack') && effect.modifier.attack);
544
+ });
545
+
546
+ // If there are applicable optional effects, show popup and wait for user choice
547
+ if (hasApplicableOptionalEffects) {
548
+ debug.log('🎯 Found applicable optional effects, showing popup...');
549
+ checkOptionalEffects(name, resolvedFormula, (chosenEffect) => {
550
+ // Apply the chosen effect and then roll
551
+ const { modifiedFormula, effectNotes } = applyEffectModifiers(name, resolvedFormula);
552
+ let finalFormula = modifiedFormula;
553
+
554
+ // Check if this is a skill/ability check (same logic as popup detection)
555
+ const isSkillOrAbilityCheck = rollLower.includes('check') ||
556
+ rollLower.includes('acrobatics') || rollLower.includes('animal') ||
557
+ rollLower.includes('arcana') || rollLower.includes('athletics') ||
558
+ rollLower.includes('deception') || rollLower.includes('history') ||
559
+ rollLower.includes('insight') || rollLower.includes('intimidation') ||
560
+ rollLower.includes('investigation') || rollLower.includes('medicine') ||
561
+ rollLower.includes('nature') || rollLower.includes('perception') ||
562
+ rollLower.includes('performance') || rollLower.includes('persuasion') ||
563
+ rollLower.includes('religion') || rollLower.includes('sleight') ||
564
+ rollLower.includes('stealth') || rollLower.includes('survival') ||
565
+ rollLower.includes('strength') || rollLower.includes('dexterity') ||
566
+ rollLower.includes('constitution') || rollLower.includes('intelligence') ||
567
+ rollLower.includes('wisdom') || rollLower.includes('charisma');
568
+
569
+ debug.log(`🎯 Applying chosen effect: ${chosenEffect.name}`, {
570
+ modifier: chosenEffect.modifier,
571
+ rollLower: rollLower,
572
+ hasSkillMod: !!chosenEffect.modifier?.skill,
573
+ isSkillOrAbilityCheck: isSkillOrAbilityCheck,
574
+ formulaBefore: finalFormula
575
+ });
576
+
577
+ // Add the chosen effect's modifier
578
+ if (chosenEffect.modifier?.skill && isSkillOrAbilityCheck) {
579
+ finalFormula += ` + ${chosenEffect.modifier.skill}`;
580
+ effectNotes.push(`[${chosenEffect.icon} ${chosenEffect.name}: ${chosenEffect.modifier.skill}]`);
581
+ debug.log(`✅ Added skill modifier: ${chosenEffect.modifier.skill}, formula now: ${finalFormula}`);
582
+ } else if (chosenEffect.modifier?.attack && rollLower.includes('attack')) {
583
+ finalFormula += ` + ${chosenEffect.modifier.attack}`;
584
+ effectNotes.push(`[${chosenEffect.icon} ${chosenEffect.name}: ${chosenEffect.modifier.attack}]`);
585
+ debug.log(`✅ Added attack modifier: ${chosenEffect.modifier.attack}, formula now: ${finalFormula}`);
586
+ } else {
587
+ debug.log(`⚠️ No modifier applied - skill: ${chosenEffect.modifier?.skill}, check: ${rollLower.includes('check')}, attack: ${chosenEffect.modifier?.attack}`);
588
+ }
589
+
590
+ // Remove the chosen effect from active effects since it's been used
591
+ if (chosenEffect.type === 'buff') {
592
+ activeBuffs = activeBuffs.filter(e => e !== chosenEffect.name);
593
+ debug.log(`🗑️ Removed buff: ${chosenEffect.name}`);
594
+ } else if (chosenEffect.type === 'debuff') {
595
+ activeConditions = activeConditions.filter(e => e !== chosenEffect.name);
596
+ debug.log(`🗑️ Removed debuff: ${chosenEffect.name}`);
597
+ }
598
+ updateEffectsDisplay();
599
+
600
+ // Apply advantage/disadvantage state
601
+ const formulaWithAdvantage = applyAdvantageToFormula(finalFormula, effectNotes);
602
+
603
+ // Proceed with the roll
604
+ executeRoll(name, formulaWithAdvantage, effectNotes, prerolledResult);
605
+ });
606
+ // Return early - don't execute the roll yet, wait for popup response
607
+ return;
608
+ }
609
+
610
+ // No optional effects, proceed with normal roll
611
+ const { modifiedFormula, effectNotes } = applyEffectModifiers(name, resolvedFormula);
612
+
613
+ // Apply advantage/disadvantage state
614
+ const formulaWithAdvantage = applyAdvantageToFormula(modifiedFormula, effectNotes);
615
+
616
+ executeRoll(name, formulaWithAdvantage, effectNotes, prerolledResult);
617
+ }
618
+
619
+ /**
620
+ * Apply advantage/disadvantage state to dice formula
621
+ */
622
+ function applyAdvantageToFormula(formula, effectNotes) {
623
+ if (advantageState === 'normal') {
624
+ return formula;
625
+ }
626
+
627
+ // Check if this is a d20 roll
628
+ if (!formula.includes('1d20') && !formula.includes('d20')) {
629
+ return formula; // Not a d20 roll, don't modify
630
+ }
631
+
632
+ let modifiedFormula = formula;
633
+
634
+ if (advantageState === 'advantage') {
635
+ // Replace 1d20 with 2d20kh1 (keep highest)
636
+ modifiedFormula = modifiedFormula.replace(/1d20/g, '2d20kh1');
637
+ modifiedFormula = modifiedFormula.replace(/(?<!\d)d20/g, '2d20kh1');
638
+ effectNotes.push('[⚡ Advantage]');
639
+ debug.log('⚡ Applied advantage to roll');
640
+ } else if (advantageState === 'disadvantage') {
641
+ // Replace 1d20 with 2d20kl1 (keep lowest)
642
+ modifiedFormula = modifiedFormula.replace(/1d20/g, '2d20kl1');
643
+ modifiedFormula = modifiedFormula.replace(/(?<!\d)d20/g, '2d20kl1');
644
+ effectNotes.push('[⚠️ Disadvantage]');
645
+ debug.log('⚠️ Applied disadvantage to roll');
646
+ }
647
+
648
+ // Reset advantage state after use
649
+ setTimeout(() => setAdvantageState('normal'), 100);
650
+
651
+ return modifiedFormula;
652
+ }
653
+
654
+ /**
655
+ * Execute the roll after optional effects have been handled
656
+ */
657
+ function executeRoll(name, formula, effectNotes, prerolledResult = null) {
658
+ // Format: "CharacterName rolls Initiative" (Roll20 adds color indicator)
659
+ let rollName = `${characterData.name} rolls ${name}`;
660
+
661
+ // Add effect notes to roll name if any
662
+ if (effectNotes.length > 0) {
663
+ rollName += ` ${effectNotes.join(' ')}`;
664
+ }
665
+
666
+ // Save this as the character's last roll (for heroic inspiration reroll)
667
+ if (characterData) {
668
+ characterData.lastRoll = {
669
+ name: name,
670
+ formula: formula,
671
+ effectNotes: effectNotes
672
+ };
673
+ saveCharacterData();
674
+ }
675
+
676
+ // If we have a prerolled result (e.g., from death saves), include it
677
+ const messageData = {
678
+ action: 'rollFromPopout',
679
+ name: rollName,
680
+ formula: formula,
681
+ color: characterData.notificationColor,
682
+ characterName: characterData.name
683
+ };
684
+
685
+ if (prerolledResult !== null) {
686
+ messageData.prerolledResult = prerolledResult;
687
+ }
688
+
689
+ // Send roll to Roll20 content script
690
+ if (typeof sendToRoll20 === 'function') {
691
+ sendToRoll20(messageData);
692
+ debug.log('🎲 Roll sent to Roll20:', messageData);
693
+ } else {
694
+ debug.warn('⚠️ sendToRoll20 not available, trying window.opener directly');
695
+ if (window.opener && !window.opener.closed) {
696
+ window.opener.postMessage(messageData, '*');
697
+ }
698
+ }
699
+
700
+ // TODO: Add Owlbear Rodeo integration for dice rolls
701
+ showNotification(`🎲 Rolling ${name}...`);
702
+ debug.log('✅ Roll executed');
703
+ }
704
+
705
+ // ===== EXPORTS =====
706
+
707
+ // Export functions to globalThis
708
+ globalThis.safeMathEval = safeMathEval;
709
+ globalThis.evaluateMathInFormula = evaluateMathInFormula;
710
+ globalThis.applyEffectModifiers = applyEffectModifiers;
711
+ globalThis.checkOptionalEffects = checkOptionalEffects;
712
+ globalThis.showOptionalEffectPopup = showOptionalEffectPopup;
713
+ globalThis.roll = roll;
714
+ globalThis.applyAdvantageToFormula = applyAdvantageToFormula;
715
+ globalThis.executeRoll = executeRoll;
716
+
717
+ debug.log('✅ Dice Roller module loaded');
718
+
719
+ })();