@iamjameslennon/ddb-mcp 2.3.0

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 (66) hide show
  1. package/README.md +520 -0
  2. package/dist/auth.d.ts +3 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +69 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/browser.d.ts +9 -0
  7. package/dist/browser.d.ts.map +1 -0
  8. package/dist/browser.js +68 -0
  9. package/dist/browser.js.map +1 -0
  10. package/dist/cache.d.ts +18 -0
  11. package/dist/cache.d.ts.map +1 -0
  12. package/dist/cache.js +45 -0
  13. package/dist/cache.js.map +1 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +654 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/open5e.d.ts +74 -0
  19. package/dist/open5e.d.ts.map +1 -0
  20. package/dist/open5e.js +455 -0
  21. package/dist/open5e.js.map +1 -0
  22. package/dist/session-fetch.d.ts +35 -0
  23. package/dist/session-fetch.d.ts.map +1 -0
  24. package/dist/session-fetch.js +155 -0
  25. package/dist/session-fetch.js.map +1 -0
  26. package/dist/tools/campaign.d.ts +4 -0
  27. package/dist/tools/campaign.d.ts.map +1 -0
  28. package/dist/tools/campaign.js +72 -0
  29. package/dist/tools/campaign.js.map +1 -0
  30. package/dist/tools/character.d.ts +21 -0
  31. package/dist/tools/character.d.ts.map +1 -0
  32. package/dist/tools/character.js +1128 -0
  33. package/dist/tools/character.js.map +1 -0
  34. package/dist/tools/encounter.d.ts +22 -0
  35. package/dist/tools/encounter.d.ts.map +1 -0
  36. package/dist/tools/encounter.js +453 -0
  37. package/dist/tools/encounter.js.map +1 -0
  38. package/dist/tools/library.d.ts +4 -0
  39. package/dist/tools/library.d.ts.map +1 -0
  40. package/dist/tools/library.js +112 -0
  41. package/dist/tools/library.js.map +1 -0
  42. package/dist/tools/monster.d.ts +27 -0
  43. package/dist/tools/monster.d.ts.map +1 -0
  44. package/dist/tools/monster.js +378 -0
  45. package/dist/tools/monster.js.map +1 -0
  46. package/dist/tools/navigate.d.ts +5 -0
  47. package/dist/tools/navigate.d.ts.map +1 -0
  48. package/dist/tools/navigate.js +67 -0
  49. package/dist/tools/navigate.js.map +1 -0
  50. package/dist/tools/reference.d.ts +58 -0
  51. package/dist/tools/reference.d.ts.map +1 -0
  52. package/dist/tools/reference.js +850 -0
  53. package/dist/tools/reference.js.map +1 -0
  54. package/dist/tools/search.d.ts +4 -0
  55. package/dist/tools/search.d.ts.map +1 -0
  56. package/dist/tools/search.js +64 -0
  57. package/dist/tools/search.js.map +1 -0
  58. package/dist/tools/treasure.d.ts +12 -0
  59. package/dist/tools/treasure.d.ts.map +1 -0
  60. package/dist/tools/treasure.js +522 -0
  61. package/dist/tools/treasure.js.map +1 -0
  62. package/dist/utils.d.ts +5 -0
  63. package/dist/utils.d.ts.map +1 -0
  64. package/dist/utils.js +21 -0
  65. package/dist/utils.js.map +1 -0
  66. package/package.json +36 -0
@@ -0,0 +1,1128 @@
1
+ import { sessionFetch, hasValidSession, getCobaltToken } from "../session-fetch.js";
2
+ import { TtlCache } from "../cache.js";
3
+ import { addCharacterSpellsToCompendium } from "./reference.js";
4
+ import { stripHtml as stripHtmlFull } from "../utils.js";
5
+ import { writeFileSync } from "fs";
6
+ import { join, resolve, relative, basename } from "path";
7
+ import { homedir } from "os";
8
+ // Cache character JSON for 60 s to avoid redundant API calls within a session.
9
+ const characterCache = new TtlCache(60_000, 50);
10
+ // ── Character name resolution ─────────────────────────────────────────────────
11
+ function levenshteinDistance(a, b) {
12
+ if (a.length === 0)
13
+ return b.length;
14
+ if (b.length === 0)
15
+ return a.length;
16
+ const matrix = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
17
+ for (let i = 1; i <= a.length; i++) {
18
+ for (let j = 1; j <= b.length; j++) {
19
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
20
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
21
+ }
22
+ }
23
+ return matrix[a.length][b.length];
24
+ }
25
+ /**
26
+ * Resolve a character name to a numeric ID using the character list API.
27
+ * Resolution order: exact match → substring match → Levenshtein ≤3 on full
28
+ * name and individual words (e.g. "Throin" matches "Thorin Ironforge").
29
+ * Returns null if no match or multiple ambiguous fuzzy matches are found.
30
+ */
31
+ export async function findCharacterByName(name) {
32
+ if (!hasValidSession())
33
+ return null;
34
+ const { token, userId } = await getCobaltToken();
35
+ const resp = await sessionFetch(`https://character-service.dndbeyond.com/character/v5/characters/list?userId=${userId}`, { headers: { Authorization: `Bearer ${token}` } });
36
+ if (!resp.ok)
37
+ return null;
38
+ const result = await resp.json();
39
+ const chars = (result.data?.characters ?? []).map(c => ({ id: String(c.id), name: c.name }));
40
+ const lower = name.toLowerCase();
41
+ // 1. Exact match
42
+ const exact = chars.find(c => c.name.toLowerCase() === lower);
43
+ if (exact)
44
+ return exact;
45
+ // 2. Substring match (only if unambiguous)
46
+ const sub = chars.filter(c => c.name.toLowerCase().includes(lower));
47
+ if (sub.length === 1)
48
+ return sub[0];
49
+ // 3. Levenshtein fuzzy match on full name and individual words
50
+ const fuzzy = chars.filter(c => {
51
+ if (levenshteinDistance(lower, c.name.toLowerCase()) <= 3)
52
+ return true;
53
+ return c.name.split(/\s+/).some(w => levenshteinDistance(lower, w.toLowerCase()) <= 3);
54
+ });
55
+ if (fuzzy.length === 1)
56
+ return fuzzy[0];
57
+ return null;
58
+ }
59
+ export function parseCharacterData(raw, sections = "full") {
60
+ const char = (raw?.data ?? raw);
61
+ // Supplement spell compendium with this character's chosen spells (cantrips etc.)
62
+ addCharacterSpellsToCompendium(char);
63
+ // ── Helpers ───────────────────────────────────────────────────────────────
64
+ const str = (v) => (v != null ? String(v) : "");
65
+ const num = (v) => (typeof v === "number" ? v : 0);
66
+ const arr = (v) => (Array.isArray(v) ? v : []);
67
+ const obj = (v) => v != null && typeof v === "object" && !Array.isArray(v)
68
+ ? v : {};
69
+ const signed = (n) => n >= 0 ? `+${n}` : `${n}`;
70
+ const modOf = (score) => Math.floor((score - 10) / 2);
71
+ const hasTag = (feat, tag) => arr(obj(feat.definition).categories).some(c => c.tagName === tag);
72
+ const stripHtml = (s) => s.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
73
+ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
74
+ // Flatten all modifier sources into one array, keeping the source tag
75
+ const modSources = ["race", "class", "background", "feat", "item", "condition"];
76
+ const allMods = modSources.flatMap(src => arr(obj(char.modifiers)[src]));
77
+ // ── Identity ──────────────────────────────────────────────────────────────
78
+ const charName = str(char.name);
79
+ const race = str(obj(char.race).fullName || obj(char.race).baseName);
80
+ const classes = arr(char.classes);
81
+ const classLine = classes.map(c => {
82
+ const def = obj(c.definition);
83
+ const sub = obj(c.subclassDefinition);
84
+ const lvl = num(c.level);
85
+ return sub.name ? `${def.name} (${sub.name}) ${lvl}` : `${def.name} ${lvl}`;
86
+ }).join(" / ");
87
+ const totalLevel = classes.reduce((s, c) => s + num(c.level), 0);
88
+ const background = str(obj(obj(char.background).definition).name);
89
+ const xp = num(char.currentXp);
90
+ // ── Ability Scores ────────────────────────────────────────────────────────
91
+ const statNames = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
92
+ const statKeys = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"];
93
+ const baseStats = arr(char.stats);
94
+ const bonusStats = arr(char.bonusStats);
95
+ const overrideStats = arr(char.overrideStats);
96
+ const scoreBonuses = {};
97
+ for (const m of allMods) {
98
+ if (m.type === "bonus" && typeof m.subType === "string" && m.subType.endsWith("-score")) {
99
+ const idx = statKeys.indexOf(m.subType.replace("-score", ""));
100
+ if (idx >= 0)
101
+ scoreBonuses[idx + 1] = (scoreBonuses[idx + 1] ?? 0) + num(m.fixedValue);
102
+ }
103
+ }
104
+ const scoreSetValues = {};
105
+ for (const m of allMods) {
106
+ if (m.type === "set" && typeof m.subType === "string" && m.subType.endsWith("-score")) {
107
+ const idx = statKeys.indexOf(m.subType.replace("-score", ""));
108
+ if (idx >= 0) {
109
+ const setVal = num(m.fixedValue ?? m.value);
110
+ if (setVal > 0)
111
+ scoreSetValues[idx + 1] = Math.max(scoreSetValues[idx + 1] ?? 0, setVal);
112
+ }
113
+ }
114
+ }
115
+ const statTotals = statNames.map((_, i) => {
116
+ const id = i + 1;
117
+ const base = baseStats.find(s => num(s.id) === id);
118
+ const bonus = bonusStats.find(s => num(s.id) === id);
119
+ const override = overrideStats.find(s => num(s.id) === id);
120
+ const baseVal = num(base?.value ?? 0);
121
+ const bonusVal = num(bonus?.value ?? 0);
122
+ const overrideVal = override?.value != null ? num(override.value) : null;
123
+ const calculated = overrideVal != null ? overrideVal : baseVal + bonusVal + (scoreBonuses[id] ?? 0);
124
+ return scoreSetValues[id] != null ? Math.max(calculated, scoreSetValues[id]) : calculated;
125
+ });
126
+ const statMods = statTotals.map(modOf);
127
+ const abilityScoreDisplay = statNames.map((n, i) => `${n} ${statTotals[i]} (${signed(statMods[i])})`);
128
+ // ── Proficiency Bonus ─────────────────────────────────────────────────────
129
+ const profBonus = Math.floor((totalLevel - 1) / 4) + 2;
130
+ // ── Template resolver ─────────────────────────────────────────────────────
131
+ const resolveTemplates = (text, classLevel) => {
132
+ const vars = {
133
+ proficiency: profBonus,
134
+ level: totalLevel,
135
+ characterlevel: totalLevel,
136
+ classlevel: classLevel ?? totalLevel,
137
+ };
138
+ return text.replace(/\{\{([^}]+)\}\}/g, (_match, expr) => {
139
+ const [rawExpr, modifier] = expr.split("#");
140
+ const opMatch = rawExpr.match(/^(\w+)\s*([*+\-/])\s*(\d+(?:\.\d+)?)$/);
141
+ let value = null;
142
+ if (opMatch) {
143
+ const [, varName, op, numStr] = opMatch;
144
+ const base = vars[varName] ?? null;
145
+ const n = parseFloat(numStr);
146
+ if (base !== null) {
147
+ if (op === "*")
148
+ value = base * n;
149
+ else if (op === "+")
150
+ value = base + n;
151
+ else if (op === "-")
152
+ value = base - n;
153
+ else if (op === "/")
154
+ value = Math.floor(base / n);
155
+ }
156
+ }
157
+ else if (vars[rawExpr] !== undefined) {
158
+ value = vars[rawExpr];
159
+ }
160
+ if (value === null)
161
+ return "?";
162
+ const rounded = Math.floor(value);
163
+ if (modifier === "signed")
164
+ return rounded >= 0 ? `+${rounded}` : `${rounded}`;
165
+ if (modifier === "unsigned")
166
+ return String(Math.max(0, rounded));
167
+ return String(rounded);
168
+ });
169
+ };
170
+ // ── Hit Points ────────────────────────────────────────────────────────────
171
+ // baseHitPoints does NOT include the CON modifier — must add conMod × level.
172
+ const conMod = statMods[2];
173
+ const maxHp = num(char.baseHitPoints) + num(char.bonusHitPoints) + (conMod * totalLevel);
174
+ const currentHp = maxHp - num(char.removedHitPoints);
175
+ const tempHp = num(char.temporaryHitPoints);
176
+ // ── Hit Dice ──────────────────────────────────────────────────────────────
177
+ const hitDiceLines = classes.map(c => {
178
+ const die = num(obj(c.definition).hitDice);
179
+ const lvl = num(c.level);
180
+ const used = num(c.hitDiceUsed);
181
+ return `${lvl}d${die} (${lvl - used} remaining)`;
182
+ });
183
+ // ── Speed ─────────────────────────────────────────────────────────────────
184
+ const weightSpeeds = obj(obj(obj(char.race).weightSpeeds).normal);
185
+ // "set" modifiers override the base race speed; "bonus" modifiers add to it (e.g. Longstrider)
186
+ const speedCalc = (subType, base, fallback = 0) => {
187
+ const override = allMods
188
+ .filter(m => m.type === "set" && m.subType === subType && num(m.value ?? m.fixedValue) > 0)
189
+ .reduce((max, m) => Math.max(max, num(m.value ?? m.fixedValue)), 0);
190
+ const bonus = allMods
191
+ .filter(m => m.type === "bonus" && m.subType === subType)
192
+ .reduce((s, m) => s + num(m.value ?? m.fixedValue), 0);
193
+ return (override || base || fallback) + bonus;
194
+ };
195
+ const walkSpeed = speedCalc("innate-speed-walking", num(weightSpeeds.walk), 30);
196
+ const flySpeed = speedCalc("innate-speed-flying", num(weightSpeeds.fly));
197
+ const swimSpeed = speedCalc("innate-speed-swimming", num(weightSpeeds.swim));
198
+ const climbSpeed = speedCalc("innate-speed-climbing", num(weightSpeeds.climb));
199
+ const burrowSpeed = speedCalc("innate-speed-burrowing", num(weightSpeeds.burrow));
200
+ const speedParts = [];
201
+ speedParts.push(`${walkSpeed} ft.`);
202
+ if (flySpeed > 0)
203
+ speedParts.push(`fly ${flySpeed} ft.`);
204
+ if (swimSpeed > 0)
205
+ speedParts.push(`swim ${swimSpeed} ft.`);
206
+ if (climbSpeed > 0)
207
+ speedParts.push(`climb ${climbSpeed} ft.`);
208
+ if (burrowSpeed > 0)
209
+ speedParts.push(`burrow ${burrowSpeed} ft.`);
210
+ // ── Initiative ────────────────────────────────────────────────────────────
211
+ const dexMod = statMods[1];
212
+ // Jack of All Trades grants half-proficiency to initiative (an ability check).
213
+ // 2014 API emits both a specific "initiative" modifier AND a general "ability-checks" modifier.
214
+ // 2024 API only emits "ability-checks". Check both so either API version works.
215
+ const joatInitiative = allMods.some(m => m.type === "half-proficiency" &&
216
+ (m.subType === "initiative" || m.subType === "ability-checks"));
217
+ const initiativeBonus = allMods
218
+ .filter(m => m.subType === "initiative" && m.type === "bonus")
219
+ .reduce((s, m) => {
220
+ // bonusTypes [1] means the bonus value is the proficiency bonus, not a fixed number
221
+ const usesProfBonus = arr(m.bonusTypes).includes(1) && (m.fixedValue == null && m.value == null);
222
+ return s + (usesProfBonus ? profBonus : num(m.fixedValue ?? m.value));
223
+ }, 0);
224
+ const initiative = dexMod + initiativeBonus + (joatInitiative ? Math.floor(profBonus / 2) : 0);
225
+ // ── Armor Class ───────────────────────────────────────────────────────────
226
+ // armorTypeId: 1=light, 2=medium, 3=heavy, 4=shield
227
+ const inventory = arr(char.inventory);
228
+ const equippedArmorPieces = inventory.filter(i => i.equipped === true && str(obj(i.definition).filterType) === "Armor");
229
+ const shield = equippedArmorPieces.find(i => num(obj(i.definition).armorTypeId) === 4);
230
+ // If multiple body armors are equipped (e.g. party loot), pick whichever yields the best effective AC.
231
+ const bodyArmorCandidates = equippedArmorPieces.filter(i => num(obj(i.definition).armorTypeId) !== 4);
232
+ const effectiveBodyAc = (i) => {
233
+ const def = obj(i.definition);
234
+ const baseAc = num(def.armorClass);
235
+ const typeId = num(def.armorTypeId);
236
+ if (typeId === 1)
237
+ return baseAc + dexMod;
238
+ if (typeId === 2)
239
+ return baseAc + Math.min(dexMod, 2);
240
+ return baseAc;
241
+ };
242
+ const bodyArmor = bodyArmorCandidates.reduce((best, i) => best === null || effectiveBodyAc(i) > effectiveBodyAc(best) ? i : best, null);
243
+ let ac;
244
+ if (bodyArmor) {
245
+ ac = effectiveBodyAc(bodyArmor);
246
+ }
247
+ else {
248
+ // Unarmored Defense (Barbarian = 10+DEX+CON, Monk = 10+DEX+WIS)
249
+ const unarmoredMod = allMods.find(m => m.subType === "unarmored-armor-class");
250
+ if (unarmoredMod) {
251
+ const extraStatId = num(unarmoredMod.statId); // 3=CON, 5=WIS
252
+ const extraMod = extraStatId > 0 ? statMods[extraStatId - 1] : 0;
253
+ ac = 10 + dexMod + extraMod;
254
+ }
255
+ else {
256
+ ac = 10 + dexMod;
257
+ }
258
+ }
259
+ if (shield)
260
+ ac += num(obj(shield.definition).armorClass);
261
+ // Add any AC bonus modifiers (e.g. shield of faith, ring of protection)
262
+ const acBonus = allMods
263
+ .filter(m => m.subType === "armor-class" && m.type === "bonus")
264
+ .reduce((s, m) => s + num(m.fixedValue ?? m.value), 0);
265
+ ac += acBonus;
266
+ // ── Saving Throws ─────────────────────────────────────────────────────────
267
+ const saveProfSubTypes = new Set(allMods.filter(m => m.type === "proficiency" && str(m.subType).includes("saving-throws"))
268
+ .map(m => str(m.subType)));
269
+ const savingThrows = statKeys.map((key, i) => {
270
+ const isProficient = saveProfSubTypes.has(`${key}-saving-throws`);
271
+ const total = statMods[i] + (isProficient ? profBonus : 0);
272
+ return `${statNames[i]} ${signed(total)}${isProficient ? "*" : ""}`;
273
+ });
274
+ // ── Skills ────────────────────────────────────────────────────────────────
275
+ const SKILLS = [
276
+ ["Acrobatics", 1], ["Animal Handling", 4], ["Arcana", 3], ["Athletics", 0],
277
+ ["Deception", 5], ["History", 3], ["Insight", 4], ["Intimidation", 5],
278
+ ["Investigation", 3], ["Medicine", 4], ["Nature", 3], ["Perception", 4],
279
+ ["Performance", 5], ["Persuasion", 5], ["Religion", 3], ["Sleight of Hand", 1],
280
+ ["Stealth", 1], ["Survival", 4],
281
+ ];
282
+ const skillProfSubTypes = new Set(allMods.filter(m => m.type === "proficiency").map(m => str(m.subType)));
283
+ const skillExpertiseSubTypes = new Set(allMods.filter(m => m.type === "expertise").map(m => str(m.subType)));
284
+ // half-proficiency (e.g. Bard's Jack of All Trades applies to all ability checks)
285
+ const hasJackOfAllTrades = allMods.some(m => m.type === "half-proficiency" && m.subType === "ability-checks");
286
+ const skillHalfProfSubTypes = new Set(allMods.filter(m => m.type === "half-proficiency" && m.subType !== "ability-checks")
287
+ .map(m => str(m.subType)));
288
+ // Compute skill bonuses once; reuse for both the display lines and passive senses.
289
+ const skillBonuses = SKILLS.map(([skillName, statIdx]) => {
290
+ const slug = skillName.toLowerCase().replace(/ /g, "-").replace(/'/g, "");
291
+ const isProficient = skillProfSubTypes.has(slug);
292
+ const isExpertise = skillExpertiseSubTypes.has(slug);
293
+ const isHalf = !isProficient && (skillHalfProfSubTypes.has(slug) || hasJackOfAllTrades);
294
+ let bonus = statMods[statIdx];
295
+ if (isExpertise)
296
+ bonus += profBonus * 2;
297
+ else if (isProficient)
298
+ bonus += profBonus;
299
+ else if (isHalf)
300
+ bonus += Math.floor(profBonus / 2);
301
+ return { bonus, isProficient, isExpertise };
302
+ });
303
+ const skillLines = SKILLS.map(([skillName, statIdx], i) => {
304
+ const { bonus, isProficient, isExpertise } = skillBonuses[i];
305
+ const marker = isExpertise ? " **" : isProficient ? " *" : "";
306
+ const statLabel = statNames[statIdx];
307
+ return ` ${(skillName + ` (${statLabel})`).padEnd(22)} ${signed(bonus)}${marker}`;
308
+ });
309
+ // ── Senses ────────────────────────────────────────────────────────────────
310
+ const perceptionIdx = SKILLS.findIndex(([n]) => n === "Perception");
311
+ const investigationIdx = SKILLS.findIndex(([n]) => n === "Investigation");
312
+ const insightIdx = SKILLS.findIndex(([n]) => n === "Insight");
313
+ const passivePerception = 10 + skillBonuses[perceptionIdx].bonus;
314
+ const passiveInvestigation = 10 + skillBonuses[investigationIdx].bonus;
315
+ const passiveInsight = 10 + skillBonuses[insightIdx].bonus;
316
+ // Collect senses from all sources; keep highest value per sense name.
317
+ const SENSE_ID_NAMES = {
318
+ 1: "Blindsight", 2: "Darkvision", 3: "Tremorsense", 4: "Truesight",
319
+ };
320
+ const SENSE_SLUGS = new Set(["darkvision", "blindsight", "tremorsense", "truesight"]);
321
+ const senseMap = new Map();
322
+ const mergeSense = (name, val) => {
323
+ if (val > 0)
324
+ senseMap.set(name, Math.max(senseMap.get(name) ?? 0, val));
325
+ };
326
+ // 2024: type:"sense" modifiers
327
+ for (const m of allMods)
328
+ if (m.type === "sense")
329
+ mergeSense(capitalize(str(m.subType)), num(m.value));
330
+ // 2014: type:"set" / type:"set-base" modifiers with sense subType slugs
331
+ for (const m of allMods)
332
+ if ((m.type === "set" || m.type === "set-base") && SENSE_SLUGS.has(str(m.subType)))
333
+ mergeSense(capitalize(str(m.subType)), num(m.value));
334
+ // 2014: customSenses array (explicit overrides / grants)
335
+ for (const cs of arr(char.customSenses)) {
336
+ const name = SENSE_ID_NAMES[num(cs.senseId)];
337
+ if (name)
338
+ mergeSense(name, num(cs.value));
339
+ }
340
+ // 2014: racial trait senses (range parsed from notes, e.g. "60 feet")
341
+ for (const trait of arr(obj(char.race).racialTraits)) {
342
+ for (const sense of arr(obj(trait.definition).senses)) {
343
+ const name = SENSE_ID_NAMES[num(sense.senseId)];
344
+ const match = str(sense.notes).match(/\d+/);
345
+ if (name && match)
346
+ mergeSense(name, parseInt(match[0], 10));
347
+ }
348
+ }
349
+ const specialSenses = Array.from(senseMap.entries()).map(([n, v]) => `${n} ${v} ft.`);
350
+ // ── Proficiencies ─────────────────────────────────────────────────────────
351
+ const armorProfMap = {
352
+ "light-armor": "Light Armor", "medium-armor": "Medium Armor",
353
+ "heavy-armor": "Heavy Armor", "shields": "Shields",
354
+ };
355
+ const weaponProfMap = {
356
+ "simple-weapons": "Simple Weapons", "martial-weapons": "Martial Weapons",
357
+ };
358
+ // Placeholder subType values that are unresolved character-builder selections — discard them.
359
+ const isProfPlaceholder = (sub) => sub.toLowerCase().startsWith("choose") || sub.toLowerCase() === "self";
360
+ // Specific weapon type slugs — route to Weapons, not Tools.
361
+ const isWeaponSlug = (sub) => /sword|axe|bow|crossbow|dagger|dart|sling|blowgun|staff|spear|club|mace|hammer|flail|lance|pike|rapier|scimitar|sickle|whip|maul|halberd|glaive|javelin|trident|handaxe|net|morningstar/.test(sub);
362
+ const armorProfs = [];
363
+ const weaponProfs = [];
364
+ const toolProfs = [];
365
+ const languages = [];
366
+ for (const m of allMods) {
367
+ const sub = str(m.subType);
368
+ if (isProfPlaceholder(sub))
369
+ continue;
370
+ if (m.type === "proficiency") {
371
+ if (armorProfMap[sub])
372
+ armorProfs.push(armorProfMap[sub]);
373
+ else if (weaponProfMap[sub])
374
+ weaponProfs.push(weaponProfMap[sub]);
375
+ else if (sub.includes("saving-throws") || sub.includes("-skill") ||
376
+ SKILLS.some(([n]) => n.toLowerCase().replace(/ /g, "-").replace(/'/g, "") === sub)) {
377
+ // skill/save prof — handled elsewhere
378
+ }
379
+ else if (!sub.includes("-score") && sub.length > 0 &&
380
+ !statKeys.some(k => sub.startsWith(k))) {
381
+ if (isWeaponSlug(sub)) {
382
+ weaponProfs.push(capitalize(sub.replace(/-/g, " ")));
383
+ }
384
+ else {
385
+ toolProfs.push(capitalize(sub.replace(/-/g, " ")));
386
+ }
387
+ }
388
+ }
389
+ else if (m.type === "language") {
390
+ languages.push(capitalize(sub.replace(/-/g, " ")));
391
+ }
392
+ }
393
+ // ── Defenses & Conditions ─────────────────────────────────────────────────
394
+ const resistances = [...new Set(allMods.filter(m => m.type === "resistance").map(m => capitalize(str(m.subType))))];
395
+ const immunities = [...new Set(allMods.filter(m => m.type === "immunity").map(m => capitalize(str(m.subType))))];
396
+ const vulnerabilities = [...new Set(allMods.filter(m => m.type === "vulnerability").map(m => capitalize(str(m.subType))))];
397
+ const conditions = arr(char.conditions).map(c => str(c.id));
398
+ // ── Feats ─────────────────────────────────────────────────────────────────
399
+ // DDB stores some non-feat entries in the feats array.
400
+ // __DISGUISE_FEAT = class features surfaced as feats (shown in OTHER FEATURES).
401
+ // __INITIAL_ASI = 2024 background Ability Score Improvements (already in ABILITY SCORES; drop entirely).
402
+ const allFeats = arr(char.feats);
403
+ const realFeats = allFeats.filter(f => !hasTag(f, "__DISGUISE_FEAT") && !hasTag(f, "__INITIAL_ASI"));
404
+ const disguisedFeats = allFeats.filter(f => hasTag(f, "__DISGUISE_FEAT"));
405
+ const featLines = realFeats.map(f => {
406
+ const def = obj(f.definition);
407
+ const snippet = resolveTemplates(stripHtml(str(def.snippet || def.description))).slice(0, 120);
408
+ return `• ${str(def.name)}${snippet ? `: ${snippet}${snippet.length >= 120 ? "…" : ""}` : ""}`;
409
+ });
410
+ // ── Class Features ────────────────────────────────────────────────────────
411
+ const classFeatureLines = [];
412
+ const seenClassFeatures = new Set();
413
+ for (const c of classes) {
414
+ const charLevel = num(c.level);
415
+ for (const cf of arr(c.classFeatures)) {
416
+ const def = obj(cf.definition);
417
+ const line = `• ${str(def.name)} (${str(obj(c.definition).name)} ${num(def.requiredLevel || 1)})`;
418
+ if (num(def.requiredLevel || 0) <= charLevel && !seenClassFeatures.has(line)) {
419
+ seenClassFeatures.add(line);
420
+ classFeatureLines.push(line);
421
+ }
422
+ }
423
+ }
424
+ // ── Racial Traits ─────────────────────────────────────────────────────────
425
+ const racialTraitLines = arr(obj(char.race).racialTraits).map(t => `• ${str(obj(t.definition).name)}`);
426
+ // ── Background Feature ────────────────────────────────────────────────────
427
+ // For custom backgrounds, featureName may reflect a feat name rather than
428
+ // the actual background feature. Check customBackground first if present.
429
+ const bgObj = obj(char.background);
430
+ const customBg = obj(bgObj.customBackground);
431
+ const featuresBackgroundDef = obj(obj(customBg.featuresBackground).definition);
432
+ const customBgDef = Object.keys(featuresBackgroundDef).length > 0
433
+ ? featuresBackgroundDef
434
+ : obj(customBg.definition);
435
+ const bgDef = Object.keys(customBgDef).length > 0 ? customBgDef : obj(bgObj.definition);
436
+ const bgFeatureName = str(bgDef.featureName);
437
+ const bgFeatureIsFeat = bgDef.featureIsFeat === true;
438
+ const bgFeatureDesc = bgDef.featureDescription
439
+ ? resolveTemplates(stripHtml(str(bgDef.featureDescription))).slice(0, 300)
440
+ : "";
441
+ // ── Actions / Bonus Actions / Reactions / Limited Use ─────────────────────
442
+ // activation.activationType: 1=action, 3=bonus action, 4=reaction, 8=special (skip)
443
+ // Filter Circle Spell entries — these leak from the Dark Bargain campaign feature
444
+ // and don't represent real character abilities on the website.
445
+ const allActions = Object.values(obj(char.actions))
446
+ .flatMap(v => arr(v))
447
+ .filter(a => a != null && !str(a.name).startsWith("Circle Spell") && str(a.name) !== "Initiate a Circle Spell");
448
+ const activationType = (a) => num(obj(a.activation).activationType);
449
+ // Bonus-action and reaction spells — activationType 3=bonus action, 4=reaction.
450
+ // Apply the same prepared/ritual filter used in the main SPELLS section for spellbook
451
+ // classes (Wizards) so unprepared non-ritual spells don't bleed into these sections.
452
+ const allCharSpells = [
453
+ ...arr(char.classSpells).flatMap(cs => {
454
+ const classEntry = classes.find(c => c.id === cs.characterClassId);
455
+ const isSpellbook = str(obj(classEntry?.definition ?? {}).name) === "Wizard";
456
+ return arr(cs.spells).filter(s => !isSpellbook || s.prepared === true || obj(s.definition).ritual === true);
457
+ }),
458
+ ...Object.values(obj(char.spells)).flatMap(v => arr(v)),
459
+ ].filter(Boolean);
460
+ const spellActivationType = (s) => num(obj(obj(s.definition).activation).activationType);
461
+ const formatSpell = (s) => {
462
+ const def = obj(s.definition);
463
+ const lvl = num(def.level);
464
+ const slotStr = lvl === 0 ? "cantrip" : `${lvl === 1 ? "1st" : lvl === 2 ? "2nd" : lvl === 3 ? "3rd" : `${lvl}th`}-level slot`;
465
+ return `• ${str(def.name)} (spell, ${slotStr})`;
466
+ };
467
+ // Spell activationTypes (from rule-data): 1=Action, 2=No Action, 3=Bonus Action, 4=Reaction, 8=Special
468
+ const bonusActionSpells = allCharSpells.filter(s => spellActivationType(s) === 3).map(formatSpell);
469
+ const reactionSpells = allCharSpells.filter(s => spellActivationType(s) === 4).map(formatSpell);
470
+ // activationType 3 = bonus action in class actions, 4 = reaction
471
+ // activationType 1 = action (weapon masteries — skip, shown in ACTIONS already)
472
+ // activationType 8 = special/passive — skip
473
+ const bonusActions = [
474
+ ...allActions.filter(a => activationType(a) === 3).map(a => `• ${str(a.name)}`),
475
+ ...bonusActionSpells,
476
+ ];
477
+ // Reactions: Opportunity Attack is universal, then class reactions, then reaction spells
478
+ const reactions = [
479
+ "• Opportunity Attack",
480
+ ...allActions.filter(a => activationType(a) === 4).map(a => `• ${str(a.name)}`),
481
+ ...reactionSpells,
482
+ ];
483
+ const limitedUseFeatures = allActions
484
+ .filter(a => {
485
+ const lu = obj(a.limitedUse);
486
+ // maxUses=0 with statModifierUsesId means uses = that stat modifier (e.g. CHA for Bardic Inspiration)
487
+ return lu.maxUses !== undefined && (num(lu.maxUses) > 0 || lu.statModifierUsesId != null);
488
+ })
489
+ .map(a => {
490
+ const lu = obj(a.limitedUse);
491
+ const resetLabels = { 1: "Short Rest", 2: "Long Rest" };
492
+ const reset = resetLabels[num(lu.resetType)] ?? "Rest";
493
+ let maxStr = num(lu.maxUses) > 0
494
+ ? String(num(lu.maxUses))
495
+ : lu.statModifierUsesId != null
496
+ ? `${signed(statMods[num(lu.statModifierUsesId) - 1])} (stat)`
497
+ : "?";
498
+ const used = num(lu.numberUsed);
499
+ return `• ${str(a.name)} ${used} used / ${maxStr} max (${reset})`;
500
+ });
501
+ // ── Weapon Attacks ────────────────────────────────────────────────────────
502
+ const weaponProfSlugs = new Set(allMods.filter(m => m.type === "proficiency").map(m => str(m.subType)));
503
+ const isWeaponProficient = (def) => {
504
+ const catId = num(def.categoryId); // 1=simple, 2=martial
505
+ const typeName = str(def.type).toLowerCase().replace(/ /g, "-");
506
+ return (catId === 1 && weaponProfSlugs.has("simple-weapons")) ||
507
+ (catId === 2 && weaponProfSlugs.has("martial-weapons")) ||
508
+ weaponProfSlugs.has(typeName);
509
+ };
510
+ // Martial Arts: allows DEX for monk weapons (simple melee + shortsword, no Two-Handed/Heavy)
511
+ const hasMartialArts = allMods.some(m => str(m.subType) === "martial-arts") ||
512
+ classes.some(c => arr(c.classFeatures).some(cf => str(obj(cf.definition).name) === "Martial Arts" &&
513
+ num(obj(cf.definition).requiredLevel || 1) <= num(c.level)));
514
+ const weaponAttacks = [];
515
+ const weaponInventoryMap = new Map();
516
+ for (const i of inventory) {
517
+ const def = obj(i.definition);
518
+ if (str(def.filterType) !== "Weapon")
519
+ continue;
520
+ if (i.equipped !== true)
521
+ continue; // only show equipped weapons in ACTIONS
522
+ const wName = str(def.name);
523
+ const dmg = obj(def.damage);
524
+ const dmgDice = str(dmg.diceString);
525
+ const dmgType = str(def.damageType).toLowerCase();
526
+ const attackType = num(def.attackType); // 1=melee, 2=ranged
527
+ const props = arr(def.properties).map(p => str(p.name));
528
+ const isFinesse = props.includes("Finesse");
529
+ const isRanged = attackType === 2;
530
+ const range = num(def.range);
531
+ const longRange = num(def.longRange);
532
+ const mastery = str(def.mastery);
533
+ // Magic enhancement bonus from grantedModifiers (e.g. +1 weapon)
534
+ const magicBonus = arr(def.grantedModifiers)
535
+ .filter(gm => gm.type === "bonus" && gm.subType === "magic")
536
+ .reduce((s, gm) => s + num(gm.value ?? gm.fixedValue), 0);
537
+ // Monk weapons: simple melee or shortsword, no Two-Handed/Heavy
538
+ const isMonkWeapon = hasMartialArts &&
539
+ !props.includes("Two-Handed") && !props.includes("Heavy") &&
540
+ ((num(def.categoryId) === 1 && attackType === 1) || str(def.name) === "Shortsword");
541
+ // Ability modifier for attack/damage
542
+ const usesDex = isRanged || ((isFinesse || isMonkWeapon) && dexMod > statMods[0]);
543
+ const abilityMod = usesDex ? dexMod : statMods[0];
544
+ const profMod = isWeaponProficient(def) ? profBonus : 0;
545
+ const hitBonus = abilityMod + profMod + magicBonus;
546
+ const dmgBonus = abilityMod + magicBonus;
547
+ const dmgStr = dmgBonus !== 0 ? `${dmgDice}${signed(dmgBonus)}` : dmgDice;
548
+ const rangeStr = isRanged ? `range ${range}/${longRange} ft.` : `reach 5 ft.`;
549
+ const propsStr = [...props, ...(mastery ? [mastery] : [])].join(", ");
550
+ const line = `• ${wName.padEnd(16)} ${signed(hitBonus)} to hit ${dmgStr} ${dmgType} ${rangeStr}${propsStr ? ` ${propsStr}` : ""}`;
551
+ // Consolidate duplicates by key
552
+ const key = wName + dmgDice;
553
+ const existing = weaponInventoryMap.get(key);
554
+ if (existing) {
555
+ existing.qty += num(i.quantity) || 1;
556
+ }
557
+ else {
558
+ weaponInventoryMap.set(key, { lines: [line], qty: num(i.quantity) || 1 });
559
+ }
560
+ }
561
+ for (const { lines, qty } of weaponInventoryMap.values()) {
562
+ weaponAttacks.push(qty > 1 ? lines[0].replace("•", `• ×${qty}`) : lines[0]);
563
+ }
564
+ // ── Spellcasting ──────────────────────────────────────────────────────────
565
+ // spellCastingAbilityId: 1=STR 2=DEX 3=CON 4=INT 5=WIS 6=CHA
566
+ const spellcastingLines = [];
567
+ for (const c of classes) {
568
+ const def = obj(c.definition);
569
+ const subDef = obj(c.subclassDefinition);
570
+ const classCasts = def.canCastSpells === true;
571
+ const subclassCasts = subDef.canCastSpells === true;
572
+ if (!classCasts && !subclassCasts)
573
+ continue;
574
+ const abilityId = num(classCasts ? def.spellCastingAbilityId : subDef.spellCastingAbilityId);
575
+ if (!abilityId)
576
+ continue;
577
+ const className = classCasts ? str(def.name) : `${str(def.name)} (${str(subDef.name)})`;
578
+ const abilityMod = statMods[abilityId - 1];
579
+ const spellAttack = abilityMod + profBonus;
580
+ const saveDc = 8 + abilityMod + profBonus;
581
+ spellcastingLines.push(` ${className}: ${statNames[abilityId - 1]} Spell Attack: ${signed(spellAttack)} Save DC: ${saveDc}`);
582
+ }
583
+ // ── Spell Slots ───────────────────────────────────────────────────────────
584
+ // char.spellSlots only tracks used counts; max slots come from the class's
585
+ // levelSpellSlots progression table: levelSpellSlots[classLevel][slotLevel-1]
586
+ const spellSlotUsed = {};
587
+ for (const s of arr(char.spellSlots)) {
588
+ spellSlotUsed[num(s.level)] = num(s.used);
589
+ }
590
+ const slotMax = {};
591
+ for (const c of classes) {
592
+ // Only compute slots for classes/subclasses that actually grant spellcasting.
593
+ // Non-spellcasting base classes (Barbarian, Rogue, Monk, etc.) have canCastSpells: false
594
+ // but still carry non-empty levelSpellSlots tables — skip those.
595
+ // Spellcasting subclasses (Arcane Trickster, Eldritch Knight) set canCastSpells on
596
+ // the subclassDefinition instead, so check both.
597
+ const classCasts = obj(c.definition).canCastSpells === true;
598
+ const subclassCasts = obj(c.subclassDefinition).canCastSpells === true;
599
+ if (!classCasts && !subclassCasts)
600
+ continue;
601
+ const spellRules = obj(obj(c.definition).spellRules);
602
+ const rawTable = spellRules.levelSpellSlots;
603
+ const table = Array.isArray(rawTable) ? rawTable : [];
604
+ const lvl = num(c.level);
605
+ const row = table[lvl] ?? [];
606
+ for (let i = 0; i < row.length; i++) {
607
+ if (row[i] > 0)
608
+ slotMax[i + 1] = (slotMax[i + 1] ?? 0) + row[i];
609
+ }
610
+ }
611
+ const slotLines = Object.entries(slotMax)
612
+ .sort(([a], [b]) => Number(a) - Number(b))
613
+ .map(([lvl, max]) => {
614
+ const used = spellSlotUsed[Number(lvl)] ?? 0;
615
+ return ` Level ${lvl}: ${max - used}/${max}`;
616
+ });
617
+ // ── Spells ────────────────────────────────────────────────────────────────
618
+ const spellSections = [];
619
+ const classSpells = arr(char.classSpells);
620
+ // seenSpellIds is pre-seeded here so cross-source duplicate detection also
621
+ // catches class-feature auto-grants of spells the player already has prepared.
622
+ const seenSpellIds = new Map(); // spellId → first source label
623
+ for (const cs of classSpells) {
624
+ // Try characterClassId first; fall back to id/classId for 2024-rules format
625
+ const classEntry = classes.find(c => c.id === cs.characterClassId ||
626
+ c.id === cs.id ||
627
+ c.id === cs.classId);
628
+ const className = str(obj(classEntry?.definition ?? {}).name);
629
+ const isSpellbook = className === "Wizard";
630
+ // spells may be under cs.spells or cs.classSpells (2024 format variation)
631
+ const allSpells = arr(cs.spells).length > 0
632
+ ? arr(cs.spells)
633
+ : arr(cs.classSpells);
634
+ const cantrips = allSpells
635
+ .filter(s => num(obj(s.definition).level) === 0)
636
+ .map(s => str(obj(s.definition).name));
637
+ const leveled = allSpells
638
+ .filter(s => {
639
+ if (num(obj(s.definition).level) === 0)
640
+ return false;
641
+ if (isSpellbook)
642
+ return s.prepared === true || obj(s.definition).ritual === true;
643
+ return true;
644
+ })
645
+ .map(s => {
646
+ const def = obj(s.definition);
647
+ const ritual = isSpellbook && def.ritual ? " [ritual]" : "";
648
+ return `${str(def.name)} (L${num(def.level)}${ritual})`;
649
+ });
650
+ if (cantrips.length)
651
+ spellSections.push(` Cantrips: ${cantrips.join(", ")}`);
652
+ if (leveled.length)
653
+ spellSections.push(` Spells: ${leveled.join(", ")}`);
654
+ // Pre-seed duplicate detection in the same pass
655
+ for (const s of allSpells) {
656
+ const spellId = num(obj(s.definition).id);
657
+ if (spellId && !seenSpellIds.has(spellId))
658
+ seenSpellIds.set(spellId, "Spells");
659
+ }
660
+ }
661
+ const spellsObj = obj(char.spells);
662
+ const sourceLabels = {
663
+ race: "Racial Trait", class: "Class Feature", background: "Background", feat: "Feat", item: "Item",
664
+ };
665
+ const duplicateWarnings = [];
666
+ for (const [key, label] of Object.entries(sourceLabels)) {
667
+ const spellList = arr(spellsObj[key]);
668
+ if (!spellList.length)
669
+ continue;
670
+ const names = [...new Set(spellList
671
+ .filter(s => {
672
+ const def = obj(s.definition);
673
+ const spellId = num(def.id);
674
+ if (!spellId)
675
+ return true;
676
+ if (seenSpellIds.has(spellId)) {
677
+ const firstLabel = seenSpellIds.get(spellId);
678
+ const spellName = str(def.name);
679
+ const lvl = num(def.level);
680
+ const spellStr = lvl === 0 ? spellName : `${spellName} (L${lvl})`;
681
+ duplicateWarnings.push(` • ${spellStr} — already granted by ${firstLabel}, also in ${label}`);
682
+ return false;
683
+ }
684
+ seenSpellIds.set(spellId, label);
685
+ return true;
686
+ })
687
+ .map(s => {
688
+ const def = obj(s.definition);
689
+ const n = str(def.name);
690
+ return n ? (num(def.level) === 0 ? n : `${n} (L${num(def.level)})`) : "";
691
+ })
692
+ .filter(n => n.length > 0))];
693
+ if (names.length)
694
+ spellSections.push(` From ${label}: ${names.join(", ")}`);
695
+ }
696
+ if (duplicateWarnings.length) {
697
+ spellSections.push(` ⚠ Duplicate spell grants detected — the following spells are already`, ` provided by an earlier source; the extra grant may be a wasted choice:`, ...duplicateWarnings);
698
+ }
699
+ // ── Full Inventory ────────────────────────────────────────────────────────
700
+ const equippedNonWeapons = [];
701
+ const carriedItems = new Map();
702
+ let attuned = 0;
703
+ for (const i of inventory) {
704
+ const def = obj(i.definition);
705
+ const iName = str(def.name);
706
+ const filterType = str(def.filterType);
707
+ const qty = num(i.quantity) || 1;
708
+ if (i.isAttuned)
709
+ attuned++;
710
+ if (i.equipped && filterType === "Armor") {
711
+ const ac2 = num(def.armorClass);
712
+ equippedNonWeapons.push(`${iName}${ac2 ? ` (AC ${ac2})` : ""}`);
713
+ }
714
+ else if (filterType !== "Weapon") {
715
+ carriedItems.set(iName, (carriedItems.get(iName) ?? 0) + qty);
716
+ }
717
+ }
718
+ const inventoryLine = [...carriedItems.entries()]
719
+ .map(([n, q]) => q > 1 ? `${n} ×${q}` : n)
720
+ .join(", ");
721
+ // ── Currency ──────────────────────────────────────────────────────────────
722
+ const currencies = obj(char.currencies);
723
+ const currencyLine = ["pp", "gp", "ep", "sp", "cp"]
724
+ .map(c => `${num(currencies[c])}${c}`)
725
+ .filter(c => !c.startsWith("0"))
726
+ .join(", ") || "none";
727
+ // ── Death Saves ───────────────────────────────────────────────────────────
728
+ const deathSaves = obj(char.deathSaves);
729
+ const dsSucc = num(deathSaves.successCount);
730
+ const dsFail = num(deathSaves.failCount);
731
+ // ── Assemble named blocks ─────────────────────────────────────────────────
732
+ const headerBlock = [
733
+ `═══════════════════════════════════════`,
734
+ ` ${charName}`,
735
+ ` ${race} | ${classLine} | Level ${totalLevel}`,
736
+ ` Background: ${background || "—"} | XP: ${xp}`,
737
+ ` Inspiration: ${char.inspiration ? "Yes" : "No"}`,
738
+ `═══════════════════════════════════════`,
739
+ ``,
740
+ ];
741
+ const vitalsBlock = [
742
+ `HP: ${currentHp}/${maxHp} Temp HP: ${tempHp || "—"} Prof Bonus: ${signed(profBonus)}`,
743
+ `Hit Dice: ${hitDiceLines.join(" / ")}`,
744
+ `AC: ${ac} Initiative: ${signed(initiative)} Speed: ${speedParts.join(", ")}`,
745
+ `Death Saves: Successes ${dsSucc}/3 Failures ${dsFail}/3`,
746
+ ``,
747
+ ];
748
+ const statsBlock = [
749
+ `ABILITY SCORES`,
750
+ ` ${abilityScoreDisplay.join(" ")}`,
751
+ ``,
752
+ `SAVING THROWS`,
753
+ ` ${savingThrows.join(" ")}`,
754
+ ` (* proficient)`,
755
+ ``,
756
+ `SKILLS`,
757
+ ...skillLines,
758
+ ` (* proficient, ** expertise)`,
759
+ ``,
760
+ `SENSES`,
761
+ ` Passive Perception: ${passivePerception} Passive Investigation: ${passiveInvestigation} Passive Insight: ${passiveInsight}`,
762
+ ...(specialSenses.length ? [` ${specialSenses.join(", ")}`] : []),
763
+ ``,
764
+ `PROFICIENCIES & TRAINING`,
765
+ ` Armor: ${armorProfs.length ? [...new Set(armorProfs)].join(", ") : "None"}`,
766
+ ` Weapons: ${weaponProfs.length ? [...new Set(weaponProfs)].join(", ") : "None"}`,
767
+ ` Tools: ${toolProfs.length ? [...new Set(toolProfs)].join(", ") : "None"}`,
768
+ ` Languages: ${languages.length ? [...new Set(languages)].join(", ") : "None"}`,
769
+ ``,
770
+ ];
771
+ const defensesBlock = [
772
+ `DEFENSES`,
773
+ ` Resistances: ${resistances.length ? resistances.join(", ") : "(none)"}`,
774
+ ` Immunities: ${immunities.length ? immunities.join(", ") : "(none)"}`,
775
+ ` Vulnerabilities: ${vulnerabilities.length ? vulnerabilities.join(", ") : "(none)"}`,
776
+ `CONDITIONS: ${conditions.length ? conditions.join(", ") : "(none)"}`,
777
+ ``,
778
+ ];
779
+ const featuresBlock = [
780
+ `FEATS (${realFeats.length})`,
781
+ ...(featLines.length ? featLines : [" (none)"]),
782
+ ``,
783
+ ...(disguisedFeats.length ? [
784
+ `OTHER FEATURES (stored as feats in API but NOT player-chosen feats)`,
785
+ ...disguisedFeats.map(f => `• ${str(obj(f.definition).name)}`),
786
+ ``,
787
+ ] : []),
788
+ `CLASS FEATURES`,
789
+ ...classFeatureLines,
790
+ ``,
791
+ `RACIAL TRAITS`,
792
+ ...racialTraitLines,
793
+ ``,
794
+ ...(!bgFeatureName || bgFeatureIsFeat ? [] : (() => {
795
+ const descSnippet = bgFeatureDesc
796
+ ? `${bgFeatureDesc}${bgFeatureDesc.length >= 300 ? "…" : ""}` : "";
797
+ return [
798
+ `BACKGROUND FEATURE`,
799
+ ` ${bgFeatureName}${descSnippet ? `: ${descSnippet}` : ""}`,
800
+ ``,
801
+ ];
802
+ })()),
803
+ ];
804
+ const combatBlock = [
805
+ `ACTIONS`,
806
+ ...(weaponAttacks.length ? weaponAttacks : [" (none)"]),
807
+ ``,
808
+ `BONUS ACTIONS`,
809
+ ...(bonusActions.length ? bonusActions : [" (none)"]),
810
+ ``,
811
+ `REACTIONS`,
812
+ ...(reactions.length ? reactions : [" (none)"]),
813
+ ``,
814
+ ...(limitedUseFeatures.length ? [`LIMITED USE`, ...limitedUseFeatures, ``] : []),
815
+ ];
816
+ const spellsBlock = [
817
+ ...(spellcastingLines.length ? [`SPELLCASTING`, ...spellcastingLines, ``] : []),
818
+ ...(slotLines.length ? [`SPELL SLOTS`, ...slotLines, ``] : []),
819
+ ...(spellSections.length ? [`SPELLS`, ...spellSections, ``] : []),
820
+ ];
821
+ const inventoryBlock = [
822
+ ...(equippedNonWeapons.length ? [`EQUIPPED`, ...equippedNonWeapons.map(e => ` ${e}`), ``] : []),
823
+ ...(inventoryLine ? [`INVENTORY`, ` ${inventoryLine}`, ``] : []),
824
+ `ATTUNEMENT: ${attuned}/3 slots used`,
825
+ ``,
826
+ `CURRENCY: ${currencyLine}`,
827
+ ];
828
+ // ── Select blocks by section ──────────────────────────────────────────────
829
+ const out = [...headerBlock];
830
+ switch (sections) {
831
+ case "summary":
832
+ out.push(...vitalsBlock, ...statsBlock);
833
+ break;
834
+ case "combat":
835
+ out.push(...vitalsBlock, ...statsBlock, ...defensesBlock, ...combatBlock);
836
+ break;
837
+ case "spells":
838
+ out.push(...(spellsBlock.length ? spellsBlock : ["No spellcasting on this character."]));
839
+ break;
840
+ case "inventory":
841
+ out.push(...inventoryBlock);
842
+ break;
843
+ case "features":
844
+ out.push(...featuresBlock);
845
+ break;
846
+ case "full":
847
+ default:
848
+ out.push(...vitalsBlock, ...statsBlock, ...defensesBlock, ...featuresBlock, ...combatBlock, ...spellsBlock, ...inventoryBlock);
849
+ break;
850
+ }
851
+ return out.join("\n");
852
+ }
853
+ export async function parseCharacter(characterId, sections = "full") {
854
+ const jsonData = await getCharacter(characterId);
855
+ const raw = JSON.parse(jsonData);
856
+ return parseCharacterData(raw, sections);
857
+ }
858
+ /**
859
+ * Fetch raw character JSON from the DnD Beyond API.
860
+ * Uses saved session cookies — no browser needed after initial login.
861
+ */
862
+ export async function getCharacter(characterId) {
863
+ const cacheKey = `character:${characterId}`;
864
+ const cached = characterCache.get(cacheKey);
865
+ if (cached !== undefined)
866
+ return cached;
867
+ const url = `https://character-service.dndbeyond.com/character/v5/character/${characterId}?includeCustomItems=true`;
868
+ // Public characters work without auth. Use session cookies if available so
869
+ // private/campaign-only characters owned by the logged-in user also work.
870
+ const resp = hasValidSession()
871
+ ? await sessionFetch(url)
872
+ : await fetch(url, { headers: { Accept: "application/json" } });
873
+ if (resp.ok) {
874
+ const result = await resp.json();
875
+ const json = JSON.stringify(result);
876
+ characterCache.set(cacheKey, json);
877
+ return json;
878
+ }
879
+ // 404 = character doesn't exist; 403 = private
880
+ if (resp.status === 403) {
881
+ throw new Error(`Character ${characterId} is private and cannot be accessed.`);
882
+ }
883
+ throw new Error(`DnD Beyond API returned ${resp.status}: ${resp.statusText}`);
884
+ }
885
+ export async function downloadCharacter(characterId, outputPath) {
886
+ const jsonData = await getCharacter(characterId);
887
+ const parsed = JSON.parse(jsonData);
888
+ const charName = parsed?.data?.name ?? `character-${characterId}`;
889
+ // Sanitize the character name: keep only alphanumeric, spaces, hyphens, apostrophes.
890
+ // basename ensures no path separators survive; the allowlist strips anything else.
891
+ const safeName = basename(charName)
892
+ .replace(/[^a-zA-Z0-9 '\-]/g, "")
893
+ .replace(/\s+/g, "-")
894
+ .toLowerCase()
895
+ .slice(0, 64) || `character-${characterId}`;
896
+ const filename = `${safeName}-${characterId}.json`;
897
+ const downloadsDir = join(homedir(), "Downloads");
898
+ const defaultPath = join(downloadsDir, filename);
899
+ let savePath;
900
+ if (outputPath) {
901
+ const resolved = resolve(outputPath);
902
+ if (resolved.includes("\0"))
903
+ throw new Error("Output path contains invalid characters.");
904
+ const allowedDirs = [
905
+ join(homedir(), "Downloads"),
906
+ join(homedir(), "Documents"),
907
+ ];
908
+ const isAllowed = allowedDirs.some(dir => !relative(dir, resolved).startsWith(".."));
909
+ if (!isAllowed) {
910
+ throw new Error("Output path must be under ~/Downloads or ~/Documents.");
911
+ }
912
+ savePath = resolved;
913
+ }
914
+ else {
915
+ savePath = defaultPath;
916
+ }
917
+ writeFileSync(savePath, JSON.stringify(parsed, null, 2), "utf8");
918
+ return `Character data for '${charName}' saved to: ${savePath}`;
919
+ }
920
+ export async function listCharacters() {
921
+ if (!hasValidSession()) {
922
+ throw new Error("No session found. Please run ddb_login first.");
923
+ }
924
+ const { token, userId } = await getCobaltToken();
925
+ const resp = await sessionFetch(`https://character-service.dndbeyond.com/character/v5/characters/list?userId=${userId}`, { headers: { Authorization: `Bearer ${token}` } });
926
+ if (!resp.ok)
927
+ throw new Error(`Character list API returned ${resp.status}: ${resp.statusText}`);
928
+ const result = await resp.json();
929
+ const characters = (result.data?.characters ?? []).map((c) => ({
930
+ id: String(c.id),
931
+ name: c.name,
932
+ level: c.level,
933
+ race: c.raceName,
934
+ class: c.classDescription,
935
+ status: c.statusSlug,
936
+ campaignId: c.campaignId ? String(c.campaignId) : null,
937
+ campaignName: c.campaignName ?? null,
938
+ }));
939
+ return JSON.stringify(characters);
940
+ }
941
+ // ── Definition Lookup ─────────────────────────────────────────────────────────
942
+ function matchesDefinitionQuery(name, query) {
943
+ const n = name.toLowerCase();
944
+ const q = query.toLowerCase();
945
+ if (n.includes(q))
946
+ return true;
947
+ return name.split(/\s+/).some(w => levenshteinDistance(q, w.toLowerCase()) <= 2);
948
+ }
949
+ function formatSpellResult(spell) {
950
+ const d = (spell.definition ?? spell);
951
+ const name = String(d.name ?? "Unknown");
952
+ const level = Number(d.level ?? 0);
953
+ const school = String(d.school ?? "");
954
+ const levelLabel = level === 0 ? "Cantrip" : `Level ${level}`;
955
+ const ACTIVATION_TYPES = { 1: "Action", 3: "Bonus Action", 6: "Reaction" };
956
+ const act = d.activation;
957
+ const castingTime = act
958
+ ? `${act.activationTime} ${ACTIVATION_TYPES[Number(act.activationType)] ?? "Action"}`
959
+ : "1 Action";
960
+ const rng = d.range;
961
+ let range = "Self";
962
+ if (rng) {
963
+ if (rng.rangeValue && rng.origin !== "Self")
964
+ range = `${rng.rangeValue} ft`;
965
+ else
966
+ range = String(rng.origin ?? "Self");
967
+ if (rng.aoeType && rng.aoeValue)
968
+ range += ` (${rng.aoeValue}-ft ${rng.aoeType})`;
969
+ }
970
+ const dur = d.duration;
971
+ let duration = "Instantaneous";
972
+ if (dur) {
973
+ const isConc = dur.durationType === "Concentration";
974
+ if (dur.durationInterval && dur.durationUnit) {
975
+ duration = `${isConc ? "Concentration, up to " : ""}${dur.durationInterval} ${dur.durationUnit}${Number(dur.durationInterval) > 1 ? "s" : ""}`;
976
+ }
977
+ else if (isConc) {
978
+ duration = "Concentration";
979
+ }
980
+ }
981
+ const components = (Array.isArray(d.components) ? d.components : [])
982
+ .map((c) => ({ 1: "V", 2: "S", 3: "M" })[c])
983
+ .filter(Boolean)
984
+ .join(", ");
985
+ const matNote = d.componentsDescription ? ` (${d.componentsDescription})` : "";
986
+ const lines = [
987
+ `${name} (${levelLabel} ${school})`,
988
+ `Casting Time: ${castingTime}`,
989
+ `Range: ${range}`,
990
+ `Components: ${components || "None"}${matNote}`,
991
+ `Duration: ${duration}`,
992
+ ];
993
+ if (d.ritual)
994
+ lines.push("Ritual: Yes");
995
+ lines.push("", stripHtmlFull(String(d.description ?? "")));
996
+ return lines.join("\n");
997
+ }
998
+ function formatFeatResult(feat) {
999
+ const d = (feat.definition ?? feat);
1000
+ const lines = [String(d.name ?? "Unknown")];
1001
+ if (d.prerequisite)
1002
+ lines.push(`Prerequisite: ${d.prerequisite}`);
1003
+ lines.push("", stripHtmlFull(String(d.description ?? d.snippet ?? "")));
1004
+ return lines.join("\n");
1005
+ }
1006
+ function formatClassFeatureResult(feature, className, level) {
1007
+ const d = (feature.definition ?? feature);
1008
+ const name = String(d.name ?? feature.name ?? "Unknown");
1009
+ const desc = stripHtmlFull(String(d.description ?? d.snippet ?? ""));
1010
+ return `${name} (${className}, Level ${level})\n\n${desc}`;
1011
+ }
1012
+ function formatRacialTraitResult(trait, raceName) {
1013
+ const d = (trait.definition ?? trait);
1014
+ const name = String(d.name ?? "Unknown");
1015
+ const desc = stripHtmlFull(String(d.description ?? d.snippet ?? ""));
1016
+ return `${name} (${raceName})\n\n${desc}`;
1017
+ }
1018
+ function formatItemResult(item) {
1019
+ const d = (item.definition ?? item);
1020
+ const name = String(d.name ?? "Unknown");
1021
+ const type = String(d.type ?? "Item");
1022
+ const rarity = String(d.rarity ?? "Common");
1023
+ const weight = d.weight != null ? `Weight: ${d.weight} lb\n` : "";
1024
+ const desc = stripHtmlFull(String(d.description ?? ""));
1025
+ return `${name} (${type}, ${rarity})\n${weight}\n${desc}`;
1026
+ }
1027
+ function searchDefinitions(char, query) {
1028
+ const results = [];
1029
+ const arr = (v) => (Array.isArray(v) ? v : []);
1030
+ const obj = (v) => v != null && typeof v === "object" && !Array.isArray(v) ? v : {};
1031
+ const str = (v) => (typeof v === "string" ? v : "");
1032
+ const num = (v) => (typeof v === "number" ? v : 0);
1033
+ // ── Spells ────────────────────────────────────────────────────────────────
1034
+ const allSpells = [
1035
+ ...arr(char.classSpells).flatMap(cs => arr(cs.spells)),
1036
+ ...Object.values(obj(char.spells)).flatMap(v => arr(v)),
1037
+ ];
1038
+ for (const spell of allSpells) {
1039
+ const name = str(obj(spell.definition).name || spell.name);
1040
+ if (name && matchesDefinitionQuery(name, query)) {
1041
+ results.push({ type: "Spell", text: formatSpellResult(spell) });
1042
+ }
1043
+ }
1044
+ // ── Feats ─────────────────────────────────────────────────────────────────
1045
+ for (const feat of arr(char.feats)) {
1046
+ const name = str(obj(feat.definition).name);
1047
+ if (name && matchesDefinitionQuery(name, query)) {
1048
+ results.push({ type: "Feat", text: formatFeatResult(feat) });
1049
+ }
1050
+ }
1051
+ // ── Class & Subclass Features ─────────────────────────────────────────────
1052
+ const seen = new Set();
1053
+ for (const cls of arr(char.classes)) {
1054
+ const charLevel = num(cls.level);
1055
+ const className = str(obj(cls.definition).name);
1056
+ for (const cf of arr(cls.classFeatures)) {
1057
+ const d = obj(cf.definition);
1058
+ const name = str(d.name);
1059
+ const requiredLevel = num(d.requiredLevel || 1);
1060
+ if (requiredLevel <= charLevel && name && matchesDefinitionQuery(name, query) && !seen.has(name)) {
1061
+ seen.add(name);
1062
+ results.push({
1063
+ type: "Class Feature",
1064
+ text: formatClassFeatureResult(cf, className, requiredLevel),
1065
+ });
1066
+ }
1067
+ }
1068
+ const subDef = obj(cls.subclassDefinition);
1069
+ const subName = str(subDef.name);
1070
+ for (const cf of arr(subDef.classFeatures)) {
1071
+ const d = obj(cf.definition);
1072
+ const name = str(d.name);
1073
+ const requiredLevel = num(d.requiredLevel || 1);
1074
+ const label = subName ? `${className} / ${subName}` : className;
1075
+ if (requiredLevel <= charLevel && name && matchesDefinitionQuery(name, query) && !seen.has(name)) {
1076
+ seen.add(name);
1077
+ results.push({
1078
+ type: "Subclass Feature",
1079
+ text: formatClassFeatureResult(cf, label, requiredLevel),
1080
+ });
1081
+ }
1082
+ }
1083
+ }
1084
+ // ── Racial Traits ─────────────────────────────────────────────────────────
1085
+ const raceName = str(obj(char.race).fullName || obj(char.race).baseName);
1086
+ for (const trait of arr(obj(char.race).racialTraits)) {
1087
+ const name = str(obj(trait.definition).name);
1088
+ if (name && matchesDefinitionQuery(name, query)) {
1089
+ results.push({ type: "Racial Trait", text: formatRacialTraitResult(trait, raceName) });
1090
+ }
1091
+ }
1092
+ // ── Background Feature ────────────────────────────────────────────────────
1093
+ const bgDef = obj(obj(char.background).definition);
1094
+ const bgFeatureName = str(bgDef.featureName);
1095
+ if (bgFeatureName && matchesDefinitionQuery(bgFeatureName, query)) {
1096
+ const bgName = str(bgDef.name);
1097
+ const bgDesc = stripHtmlFull(str(bgDef.featureDescription));
1098
+ results.push({
1099
+ type: "Background Feature",
1100
+ text: `${bgFeatureName} (${bgName})\n\n${bgDesc}`,
1101
+ });
1102
+ }
1103
+ // ── Equipped Items ────────────────────────────────────────────────────────
1104
+ for (const item of arr(char.inventory)) {
1105
+ if (!item.equipped)
1106
+ continue;
1107
+ const name = str(obj(item.definition).name);
1108
+ if (name && matchesDefinitionQuery(name, query)) {
1109
+ results.push({ type: "Item", text: formatItemResult(item) });
1110
+ }
1111
+ }
1112
+ return results;
1113
+ }
1114
+ export async function getDefinition(characterId, query) {
1115
+ const jsonData = await getCharacter(characterId);
1116
+ const raw = JSON.parse(jsonData);
1117
+ const char = (raw?.data ?? raw);
1118
+ const hits = searchDefinitions(char, query);
1119
+ if (hits.length === 0) {
1120
+ return `No definition found matching "${query}" on this character. Try a partial name like "hunter" for Hunter's Mark.`;
1121
+ }
1122
+ if (hits.length > 3) {
1123
+ const list = hits.map((h, i) => `${i + 1}. [${h.type}] ${h.text.split("\n")[0]}`).join("\n");
1124
+ return `Found ${hits.length} matches for "${query}". Be more specific, or here are the matches:\n\n${list}`;
1125
+ }
1126
+ return hits.map(h => `[${h.type}]\n${h.text}`).join("\n\n===\n\n");
1127
+ }
1128
+ //# sourceMappingURL=character.js.map