@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,444 @@
1
+ /**
2
+ * Formula Resolver Module
3
+ *
4
+ * Handles variable substitution and formula resolution for DiceCloud formulas.
5
+ * This is the core formula parsing engine that resolves variables like:
6
+ * - Bare variables (e.g., "breathWeaponDamage")
7
+ * - DiceCloud references (e.g., "#spellList.abilityMod")
8
+ * - Attribute modifiers (e.g., "strength.modifier")
9
+ * - Math expressions (e.g., "ceil(level/2)")
10
+ * - Inline calculations (e.g., "{varName + 2}")
11
+ *
12
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
13
+ *
14
+ * Functions exported to globalThis:
15
+ * - resolveVariablesInFormula(formula)
16
+ */
17
+
18
+ (function() {
19
+ 'use strict';
20
+
21
+ /**
22
+ * Resolves variables in a formula string
23
+ * @param {string} formula - Formula with variable references
24
+ * @returns {string} Formula with variables resolved to their values
25
+ */
26
+ function resolveVariablesInFormula(formula) {
27
+ if (!formula || typeof formula !== 'string') {
28
+ return formula;
29
+ }
30
+
31
+ debug.log(`🔧 resolveVariablesInFormula called with: "${formula}"`);
32
+
33
+ // Check if characterData is available
34
+ if (typeof characterData === 'undefined' || !characterData) {
35
+ debug.warn('⚠️ characterData not available for formula resolution');
36
+ return formula;
37
+ }
38
+
39
+ // Check if characterData has otherVariables
40
+ if (!characterData.otherVariables || typeof characterData.otherVariables !== 'object') {
41
+ debug.log('⚠️ No otherVariables available for formula resolution');
42
+ return formula;
43
+ }
44
+
45
+ // NOTE: we intentionally do NOT bail when the text contains slotLevel.
46
+ // Bare slotLevel in a damage formula (e.g. "2d8 + slotLevel") is preserved
47
+ // because no pattern below resolves it — the cast modal substitutes the
48
+ // chosen level. But slotLevel inside a { ... } inline calc in description
49
+ // text is resolved to the base level (1) so summaries don't show raw
50
+ // template syntax.
51
+ let resolvedFormula = formula;
52
+ let variablesResolved = [];
53
+
54
+ // Pattern 0: Check if the entire formula is just a bare variable name (e.g., "breathWeaponDamage")
55
+ // This must be checked BEFORE other patterns to handle cases like action.damage = "breathWeaponDamage"
56
+ const bareVariablePattern = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
57
+ if (bareVariablePattern.test(formula.trim())) {
58
+ const varName = formula.trim();
59
+ if (characterData.otherVariables.hasOwnProperty(varName)) {
60
+ const variableValue = characterData.otherVariables[varName];
61
+
62
+ // Extract the value
63
+ let value = null;
64
+ if (typeof variableValue === 'number') {
65
+ value = variableValue;
66
+ } else if (typeof variableValue === 'string') {
67
+ value = variableValue;
68
+ } else if (typeof variableValue === 'object' && variableValue.value !== undefined) {
69
+ value = variableValue.value;
70
+ }
71
+
72
+ if (value !== null && value !== undefined) {
73
+ debug.log(`✅ Resolved bare variable: ${varName} = ${value}`);
74
+ return String(value);
75
+ }
76
+ }
77
+ debug.log(`⚠️ Bare variable not found in otherVariables: ${varName}`);
78
+ }
79
+
80
+ // Helper function to get variable value (handles dot notation like "bard.level")
81
+ const getVariableValue = (varPath) => {
82
+ // Strip # prefix if present (DiceCloud reference notation)
83
+ const cleanPath = varPath.startsWith('#') ? varPath.substring(1) : varPath;
84
+
85
+ // Handle attribute modifiers like "strength.modifier", "wisdom.modifier"
86
+ if (cleanPath.includes('.modifier')) {
87
+ const attrName = cleanPath.replace('.modifier', '');
88
+ if (characterData.attributeMods && characterData.attributeMods[attrName] !== undefined) {
89
+ const modifier = characterData.attributeMods[attrName];
90
+ debug.log(`✅ Resolved attribute modifier: ${cleanPath} = ${modifier}`);
91
+ return modifier;
92
+ }
93
+ }
94
+
95
+ // Handle attribute scores like "strength", "wisdom"
96
+ if (characterData.attributes && characterData.attributes[cleanPath] !== undefined) {
97
+ const score = characterData.attributes[cleanPath];
98
+ debug.log(`✅ Resolved attribute score: ${cleanPath} = ${score}`);
99
+ return score;
100
+ }
101
+
102
+ // Handle proficiency bonus
103
+ if (cleanPath === 'proficiencyBonus' && characterData.proficiencyBonus !== undefined) {
104
+ const profBonus = characterData.proficiencyBonus;
105
+ debug.log(`✅ Resolved proficiency bonus: ${cleanPath} = ${profBonus}`);
106
+ return profBonus;
107
+ }
108
+
109
+ // Handle special DiceCloud spell references (e.g., "spellList.abilityMod")
110
+ // These reference the spellcasting ability modifier for the character's class
111
+ if (cleanPath === 'spellList.abilityMod' || cleanPath === 'spellList.ability') {
112
+ // Determine spellcasting ability based on character class
113
+ const charClass = (characterData.class || '').toLowerCase();
114
+ let spellcastingAbility = null;
115
+
116
+ // Map classes to their spellcasting abilities
117
+ if (charClass.includes('cleric') || charClass.includes('druid') || charClass.includes('ranger')) {
118
+ spellcastingAbility = 'wisdom';
119
+ } else if (charClass.includes('wizard') || charClass.includes('artificer')) {
120
+ spellcastingAbility = 'intelligence';
121
+ } else if (charClass.includes('bard') || charClass.includes('paladin') || charClass.includes('sorcerer') || charClass.includes('warlock')) {
122
+ spellcastingAbility = 'charisma';
123
+ }
124
+
125
+ if (spellcastingAbility) {
126
+ // Prefer a precomputed modifier when present.
127
+ if (characterData.attributeMods && characterData.attributeMods[spellcastingAbility] !== undefined) {
128
+ const modifier = characterData.attributeMods[spellcastingAbility];
129
+ debug.log(`✅ Resolved ${cleanPath} to ${spellcastingAbility} modifier: ${modifier}`);
130
+ return modifier;
131
+ }
132
+ // Fall back to computing it from the ability score. attributeMods isn't
133
+ // always populated on the data feeding the sheet (e.g. cloud-loaded
134
+ // characters), but the raw score is — so #spellList.abilityMod / .dc
135
+ // still resolve instead of showing raw template text.
136
+ const score = characterData.attributes && characterData.attributes[spellcastingAbility];
137
+ if (typeof score === 'number') {
138
+ const modifier = Math.floor((score - 10) / 2);
139
+ debug.log(`✅ Computed ${cleanPath} from ${spellcastingAbility} score ${score}: ${modifier}`);
140
+ return modifier;
141
+ }
142
+ }
143
+ }
144
+
145
+ // Handle spellList.dc (spell save DC)
146
+ if (cleanPath === 'spellList.dc') {
147
+ // Spell Save DC = 8 + proficiency bonus + spellcasting ability modifier
148
+ const profBonus = characterData.proficiencyBonus || 0;
149
+ const spellMod = getVariableValue('#spellList.abilityMod');
150
+ if (spellMod !== null) {
151
+ const spellDC = 8 + profBonus + spellMod;
152
+ debug.log(`✅ Calculated spell DC: 8 + ${profBonus} + ${spellMod} = ${spellDC}`);
153
+ return spellDC;
154
+ }
155
+ }
156
+
157
+ // Handle spellList.attackBonus (spell attack bonus)
158
+ if (cleanPath === 'spellList.attackBonus') {
159
+ // Spell Attack Bonus = proficiency bonus + spellcasting ability modifier
160
+ const profBonus = characterData.proficiencyBonus || 0;
161
+ const spellMod = getVariableValue('#spellList.abilityMod');
162
+ if (spellMod !== null) {
163
+ const attackBonus = profBonus + spellMod;
164
+ debug.log(`✅ Calculated spell attack bonus: ${profBonus} + ${spellMod} = ${attackBonus}`);
165
+ return attackBonus;
166
+ }
167
+ }
168
+
169
+ // Try direct lookup first
170
+ if (characterData.otherVariables.hasOwnProperty(cleanPath)) {
171
+ const val = characterData.otherVariables[cleanPath];
172
+ if (typeof val === 'number') return val;
173
+ if (typeof val === 'boolean') return val;
174
+ if (typeof val === 'object' && val.value !== undefined) return val.value;
175
+ if (typeof val === 'string') return val;
176
+ }
177
+
178
+ // Try converting dot notation (e.g., "bard.level" -> "bardLevel")
179
+ const camelCase = cleanPath.replace(/\.([a-z])/g, (_, letter) => letter.toUpperCase());
180
+ if (characterData.otherVariables.hasOwnProperty(camelCase)) {
181
+ const val = characterData.otherVariables[camelCase];
182
+ if (typeof val === 'number') return val;
183
+ if (typeof val === 'boolean') return val;
184
+ if (typeof val === 'object' && val.value !== undefined) return val.value;
185
+ }
186
+
187
+ // Try other common patterns
188
+ const alternatives = [
189
+ cleanPath.replace(/\./g, ''), // Remove dots
190
+ cleanPath.split('.').pop(), // Just the last part
191
+ cleanPath.replace(/\./g, '_') // Underscores instead
192
+ ];
193
+
194
+ for (const alt of alternatives) {
195
+ if (characterData.otherVariables.hasOwnProperty(alt)) {
196
+ const val = characterData.otherVariables[alt];
197
+ if (typeof val === 'number') return val;
198
+ if (typeof val === 'boolean') return val;
199
+ if (typeof val === 'object' && val.value !== undefined) return val.value;
200
+ }
201
+ }
202
+
203
+ return null;
204
+ };
205
+
206
+ // Resolve DiceCloud inline calculations wrapped in { ... } FIRST — before the
207
+ // bracket/parenthesis patterns below, which would otherwise mangle arrays
208
+ // inside them. DiceCloud uses { ... } for any calc embedded in description
209
+ // text, e.g. {#spellList.dc}, {#spellList.abilityMod}, {max(slotLevel, 1)},
210
+ // {[2,3,...][slotLevel]d8}. Identifiers resolve to their value (unknown -> 0);
211
+ // slotLevel -> base level (1); ceil/floor/round -> Math.*; DiceCloud arrays
212
+ // are 1-based.
213
+ const MATH_FUNC_MAP = { ceil: 'Math.ceil', floor: 'Math.floor', round: 'Math.round' };
214
+ resolvedFormula = resolvedFormula.replace(/\{([^}]+)\}/g, (fullMatch, expression) => {
215
+ try {
216
+ let expr = expression.replace(/#?[a-zA-Z_][a-zA-Z0-9_.]*/g, (token, offset, str) => {
217
+ const name = token.replace(/^#/, '');
218
+ const isCall = /^\s*\(/.test(str.slice(offset + token.length));
219
+ if (isCall) {
220
+ if (MATH_FUNC_MAP[name]) return MATH_FUNC_MAP[name];
221
+ if (name === 'max' || name === 'min' || name.startsWith('Math.')) return name;
222
+ return name;
223
+ }
224
+ if (name === 'slotLevel') return '1'; // base slot level for display
225
+ const value = getVariableValue(name);
226
+ return (value !== null && typeof value === 'number') ? String(value) : '0';
227
+ });
228
+ // DiceCloud arrays are 1-based: [a,b,c,...][n] -> the nth element.
229
+ expr = expr.replace(/\[\s*([^\[\]]+?)\s*\]\s*\[\s*(\d+)\s*\]/g, (m, list, idx) => {
230
+ const arr = list.split(',').map(s => s.trim());
231
+ const i = parseInt(idx, 10) - 1;
232
+ return (i >= 0 && i < arr.length) ? arr[i] : m;
233
+ });
234
+ const result = safeMathEval(expr);
235
+ if (typeof result === 'number' && isFinite(result)) {
236
+ debug.log(`✅ Evaluated inline calculation: {${expression}} = ${result}`);
237
+ return String(result);
238
+ }
239
+ } catch (e) {
240
+ debug.log(`⚠️ Failed to evaluate inline calculation: {${expression}}`, e);
241
+ }
242
+ return fullMatch;
243
+ });
244
+
245
+ // Pattern 1a: Find DiceCloud references in parentheses like (#spellList.abilityMod)
246
+ const diceCloudRefPattern = /\((#[a-zA-Z_][a-zA-Z0-9_.]*)\)/g;
247
+ let match;
248
+
249
+ while ((match = diceCloudRefPattern.exec(formula)) !== null) {
250
+ const varRef = match[1]; // e.g., "#spellList.abilityMod"
251
+ const fullMatch = match[0]; // e.g., "(#spellList.abilityMod)"
252
+
253
+ // Use getVariableValue which handles # prefix and dot notation
254
+ const value = getVariableValue(varRef);
255
+
256
+ if (value !== null && typeof value === 'number') {
257
+ resolvedFormula = resolvedFormula.replace(fullMatch, value);
258
+ variablesResolved.push(`${varRef}=${value}`);
259
+ debug.log(`✅ Resolved DiceCloud reference: ${varRef} = ${value}`);
260
+ } else {
261
+ debug.log(`⚠️ Could not resolve DiceCloud reference: ${varRef}, value: ${value}`);
262
+ }
263
+ }
264
+
265
+ // Pattern 1a-bare: Find bare DiceCloud references like #spellList.abilityMod (not in parentheses)
266
+ // This handles cases like "2d8 + #spellList.abilityMod" in spell damage formulas
267
+ const bareDiceCloudRefPattern = /#([a-zA-Z_][a-zA-Z0-9_.]*)/g;
268
+
269
+ while ((match = bareDiceCloudRefPattern.exec(resolvedFormula)) !== null) {
270
+ const varRef = '#' + match[1]; // e.g., "#spellList.abilityMod"
271
+ const fullMatch = match[0]; // e.g., "#spellList.abilityMod"
272
+
273
+ // Use getVariableValue which handles # prefix and dot notation
274
+ const value = getVariableValue(varRef);
275
+
276
+ if (value !== null && typeof value === 'number') {
277
+ resolvedFormula = resolvedFormula.replace(fullMatch, value);
278
+ variablesResolved.push(`${varRef}=${value}`);
279
+ debug.log(`✅ Resolved bare DiceCloud reference: ${varRef} = ${value}`);
280
+ } else {
281
+ debug.log(`⚠️ Could not resolve bare DiceCloud reference: ${varRef}, value: ${value}`);
282
+ }
283
+ }
284
+
285
+ // Pattern 1b: Find simple variables in parentheses like (variableName)
286
+ const parenthesesPattern = /\(([a-zA-Z_][a-zA-Z0-9_]*)\)/g;
287
+
288
+ while ((match = parenthesesPattern.exec(formula)) !== null) {
289
+ const variableName = match[1];
290
+ const fullMatch = match[0]; // e.g., "(sneakAttackDieAmount)"
291
+
292
+ // Look up the variable value
293
+ if (characterData.otherVariables.hasOwnProperty(variableName)) {
294
+ const variableValue = characterData.otherVariables[variableName];
295
+
296
+ // Extract numeric value
297
+ let numericValue = null;
298
+ if (typeof variableValue === 'number') {
299
+ numericValue = variableValue;
300
+ } else if (typeof variableValue === 'object' && variableValue.value !== undefined) {
301
+ numericValue = variableValue.value;
302
+ }
303
+
304
+ if (numericValue !== null) {
305
+ resolvedFormula = resolvedFormula.replace(fullMatch, numericValue);
306
+ variablesResolved.push(`${variableName}=${numericValue}`);
307
+ debug.log(`✅ Resolved variable: ${variableName} = ${numericValue}`);
308
+ } else {
309
+ debug.log(`⚠️ Variable ${variableName} has non-numeric value:`, variableValue);
310
+ }
311
+ }
312
+ }
313
+
314
+ // Pattern 2: DiceCloud expressions in square brackets like [ceil(level/2)]
315
+ // These support math functions like ceil, floor, round, abs
316
+ const bracketExprPattern = /\[([^\]]+)\]/g;
317
+
318
+ while ((match = bracketExprPattern.exec(formula)) !== null) {
319
+ const expression = match[1]; // e.g., "ceil(level/2)"
320
+ const fullMatch = match[0]; // e.g., "[ceil(level/2)]"
321
+
322
+ // Remove whitespace for easier parsing
323
+ const cleanExpr = expression.replace(/\s+/g, '');
324
+
325
+ try {
326
+ // Check if it's a math function (ceil, floor, round, abs)
327
+ const mathFuncPattern = /^(ceil|floor|round|abs)\((.+)\)$/;
328
+ const funcMatch = mathFuncPattern.exec(cleanExpr);
329
+
330
+ if (funcMatch) {
331
+ const funcName = funcMatch[1];
332
+ const funcExpression = funcMatch[2];
333
+
334
+ // Replace variables in the expression
335
+ let evalExpression = funcExpression;
336
+
337
+ // Find all variable names and replace with values
338
+ const varPattern = /[a-zA-Z_][a-zA-Z0-9_.]*/g;
339
+ let varMatch;
340
+ const replacements = [];
341
+
342
+ while ((varMatch = varPattern.exec(funcExpression)) !== null) {
343
+ const varName = varMatch[0];
344
+ const value = getVariableValue(varName);
345
+ if (value !== null && typeof value === 'number') {
346
+ replacements.push({ name: varName, value: value });
347
+ }
348
+ }
349
+
350
+ // Sort by length (longest first) to avoid partial replacements
351
+ replacements.sort((a, b) => b.name.length - a.name.length);
352
+
353
+ for (const {name, value} of replacements) {
354
+ evalExpression = evalExpression.replace(new RegExp(name.replace(/\./g, '\\.'), 'g'), value);
355
+ }
356
+
357
+ // Evaluate the expression using safeMathEval
358
+ if (/^[\d\s+\-*/().]+$/.test(evalExpression)) {
359
+ const evalResult = safeMathEval(evalExpression);
360
+ let result;
361
+
362
+ switch (funcName) {
363
+ case 'ceil':
364
+ result = Math.ceil(evalResult);
365
+ break;
366
+ case 'floor':
367
+ result = Math.floor(evalResult);
368
+ break;
369
+ case 'round':
370
+ result = Math.round(evalResult);
371
+ break;
372
+ case 'abs':
373
+ result = Math.abs(evalResult);
374
+ break;
375
+ default:
376
+ result = evalResult;
377
+ }
378
+
379
+ resolvedFormula = resolvedFormula.replace(fullMatch, result);
380
+ variablesResolved.push(`${funcName}(${expression})=${result}`);
381
+ debug.log(`✅ Resolved math function: ${funcName}(${expression}) = ${result}`);
382
+ continue;
383
+ }
384
+ }
385
+ } catch (e) {
386
+ debug.log(`⚠️ Failed to resolve ${cleanExpr}`, e);
387
+ }
388
+
389
+ // Try to evaluate as math expression
390
+ let evalExpression = cleanExpr;
391
+
392
+ // Replace all variable names with their values (sorted by length to avoid partial matches)
393
+ const varPattern = /[a-zA-Z_][a-zA-Z0-9_.]*/g;
394
+ let varMatch;
395
+ const replacements = [];
396
+
397
+ while ((varMatch = varPattern.exec(cleanExpr)) !== null) {
398
+ const varName = varMatch[0];
399
+ const value = getVariableValue(varName);
400
+ if (value !== null && typeof value === 'number') {
401
+ replacements.push({ name: varName, value: value });
402
+ }
403
+ }
404
+
405
+ // Sort by length (longest first) to avoid partial replacements
406
+ replacements.sort((a, b) => b.name.length - a.name.length);
407
+
408
+ for (const {name, value} of replacements) {
409
+ evalExpression = evalExpression.replace(new RegExp(name.replace(/\./g, '\\.'), 'g'), value);
410
+ }
411
+
412
+ // Try to evaluate the expression using safeMathEval
413
+ try {
414
+ if (/^[\d\s+\-*/().]+$/.test(evalExpression)) {
415
+ const result = safeMathEval(evalExpression);
416
+ resolvedFormula = resolvedFormula.replace(fullMatch, Math.floor(result));
417
+ variablesResolved.push(`${cleanExpr}=${Math.floor(result)}`);
418
+ debug.log(`✅ Resolved expression: ${cleanExpr} = ${Math.floor(result)}`);
419
+ } else {
420
+ debug.log(`⚠️ Could not resolve expression: ${cleanExpr} (eval: ${evalExpression})`);
421
+ }
422
+ } catch (e) {
423
+ debug.log(`⚠️ Failed to evaluate expression: ${cleanExpr}`, e);
424
+ }
425
+ }
426
+
427
+ if (variablesResolved.length > 0) {
428
+ debug.log(`🔧 Formula resolution: "${formula}" -> "${resolvedFormula}" (${variablesResolved.join(', ')})`);
429
+ }
430
+
431
+ // Strip remaining markdown formatting
432
+ resolvedFormula = resolvedFormula.replace(/\*\*/g, ''); // Remove bold markers
433
+
434
+ return resolvedFormula;
435
+ }
436
+
437
+ // ===== EXPORTS =====
438
+
439
+ // Export function to globalThis
440
+ globalThis.resolveVariablesInFormula = resolveVariablesInFormula;
441
+
442
+ debug.log('✅ Formula Resolver module loaded');
443
+
444
+ })();
@@ -0,0 +1,184 @@
1
+ /**
2
+ * GM Mode Module
3
+ *
4
+ * Handles GM (Game Master) mode features for Roll20 integration:
5
+ * - GM Mode toggle (enables/disables GM panel overlay on Roll20)
6
+ * - Character sharing (broadcasts full character sheet to GM via Roll20 chat)
7
+ * - Read-only mode (hides controls when sheet is opened from GM panel)
8
+ *
9
+ * GM Mode allows the DM to:
10
+ * - View a persistent overlay panel on Roll20 with player character stats
11
+ * - Receive character broadcasts via encoded chat messages
12
+ * - Open read-only character sheets from the GM panel
13
+ *
14
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
15
+ *
16
+ * Functions exported to globalThis:
17
+ * - hideGMControls()
18
+ * - initGMMode()
19
+ * - initShowToGM()
20
+ */
21
+
22
+ (function() {
23
+ 'use strict';
24
+
25
+ /**
26
+ * Hide GM controls when opened from GM panel (read-only mode)
27
+ * This function is called when a character sheet is opened from the GM panel
28
+ * to prevent the GM from modifying player character data.
29
+ */
30
+ function hideGMControls() {
31
+ // Hide GM mode toggle
32
+ const gmModeContainer = document.querySelector('.gm-mode-container');
33
+ if (gmModeContainer) {
34
+ gmModeContainer.style.display = 'none';
35
+ debug.log('👑 Hidden GM mode toggle');
36
+ }
37
+
38
+ // Hide settings button
39
+ const settingsBtn = document.getElementById('settings-btn');
40
+ if (settingsBtn) {
41
+ settingsBtn.style.display = 'none';
42
+ debug.log('👑 Hidden settings button');
43
+ }
44
+
45
+ // Hide color picker
46
+ const colorPickerContainer = document.querySelector('.color-picker-container');
47
+ if (colorPickerContainer) {
48
+ colorPickerContainer.style.display = 'none';
49
+ debug.log('👑 Hidden color picker');
50
+ }
51
+
52
+ // Update title to indicate read-only mode
53
+ const titleElement = document.querySelector('.char-name-section');
54
+ if (titleElement) {
55
+ titleElement.innerHTML = titleElement.innerHTML.replace('🎲 Character Sheet', '🎲 Character Sheet (Read Only)');
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Initialize GM Mode toggle button
61
+ * Toggles the GM panel overlay on Roll20 tabs
62
+ */
63
+ function initGMMode() {
64
+ const gmModeToggle = document.getElementById('gm-mode-toggle');
65
+
66
+ if (gmModeToggle) {
67
+ gmModeToggle.addEventListener('click', () => {
68
+ const isActive = gmModeToggle.classList.contains('active');
69
+
70
+ // Send message to Roll20 content script to toggle GM panel
71
+ sendToRoll20({
72
+ action: 'toggleGMMode',
73
+ enabled: !isActive
74
+ });
75
+ debug.log(`👑 GM Mode ${!isActive ? 'enabled' : 'disabled'}`);
76
+
77
+ // Toggle active state
78
+ gmModeToggle.classList.toggle('active');
79
+ showNotification(isActive ? '👑 GM Mode disabled' : '👑 GM Mode enabled!');
80
+ });
81
+
82
+ debug.log('✅ GM Mode toggle initialized');
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Initialize Show to GM button
88
+ * Broadcasts complete character data to GM via Roll20 chat
89
+ * The data is base64-encoded and wrapped in special markers for GM panel to detect
90
+ */
91
+ function initShowToGM() {
92
+ const showToGMBtn = document.getElementById('show-to-gm-btn');
93
+
94
+ if (showToGMBtn) {
95
+ showToGMBtn.addEventListener('click', () => {
96
+ if (!characterData) {
97
+ showNotification('⚠️ No character data to share', 'warning');
98
+ return;
99
+ }
100
+
101
+ try {
102
+ debug.log('👑 Preparing character data for GM share...', {
103
+ hasCharacterData: !!characterData,
104
+ characterName: characterData?.name,
105
+ characterKeys: characterData ? Object.keys(characterData) : []
106
+ });
107
+
108
+ // Create character broadcast message with ENTIRE sheet data
109
+ const broadcastData = {
110
+ type: 'OWLCLOUD_CHARACTER_BROADCAST',
111
+ character: characterData,
112
+ // Include ALL character data for complete sheet
113
+ fullSheet: {
114
+ ...characterData,
115
+ // Ensure all sections are included
116
+ attributes: characterData.attributes || {},
117
+ skills: characterData.skills || [],
118
+ savingThrows: characterData.savingThrows || {},
119
+ actions: characterData.actions || [],
120
+ spells: characterData.spells || [],
121
+ features: characterData.features || [],
122
+ equipment: characterData.equipment || [],
123
+ inventory: characterData.inventory || {},
124
+ resources: characterData.resources || {},
125
+ spellSlots: characterData.spellSlots || {},
126
+ companions: characterData.companions || [],
127
+ conditions: characterData.conditions || [],
128
+ notes: characterData.notes || '',
129
+ background: characterData.background || '',
130
+ personality: characterData.personality || {},
131
+ proficiencies: characterData.proficiencies || [],
132
+ languages: characterData.languages || [],
133
+ // Add simplified properties for popout compatibility
134
+ hp: characterData.hitPoints?.current || characterData.hp || 0,
135
+ maxHp: characterData.hitPoints?.max || characterData.maxHp || 0,
136
+ ac: characterData.armorClass || characterData.ac || 10,
137
+ initiative: characterData.initiative || 0,
138
+ passivePerception: characterData.passivePerception || 10,
139
+ proficiency: characterData.proficiencyBonus || characterData.proficiency || 0,
140
+ speed: characterData.speed || '30 ft'
141
+ },
142
+ timestamp: new Date().toISOString()
143
+ };
144
+
145
+ // Encode the data for safe transmission (handle UTF-8 properly)
146
+ const jsonString = JSON.stringify(broadcastData);
147
+ const encodedData = btoa(unescape(encodeURIComponent(jsonString)));
148
+ const broadcastMessage = `👑[OWLCLOUD:CHARACTER:${encodedData}]👑`;
149
+
150
+ // Send to Roll20 chat
151
+ sendToRoll20({
152
+ action: 'postChatMessageFromPopup',
153
+ message: broadcastMessage
154
+ });
155
+
156
+ showNotification(`👑 ${characterData.name} shared with GM!`, 'success');
157
+ debug.log('👑 Character broadcast sent to GM:', characterData.name);
158
+ } catch (error) {
159
+ debug.error('❌ Error creating character broadcast:', error);
160
+ debug.error('❌ Error details:', {
161
+ message: error.message,
162
+ stack: error.stack,
163
+ characterDataKeys: characterData ? Object.keys(characterData) : 'no data'
164
+ });
165
+ showNotification(`❌ Failed to prepare character data: ${error.message}`, 'error');
166
+ }
167
+ });
168
+
169
+ debug.log('✅ Show to GM button initialized in settings');
170
+ } else {
171
+ debug.warn('⚠️ Show to GM button not found in settings');
172
+ }
173
+ }
174
+
175
+ // ===== EXPORTS =====
176
+
177
+ // Export functions to globalThis
178
+ globalThis.hideGMControls = hideGMControls;
179
+ globalThis.initGMMode = initGMMode;
180
+ globalThis.initShowToGM = initShowToGM;
181
+
182
+ debug.log('✅ GM Mode module loaded');
183
+
184
+ })();