@iamjameslennon/ddb-mcp 2.7.1 → 2.9.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 (101) hide show
  1. package/README.md +2 -0
  2. package/dist/browser.d.ts.map +1 -1
  3. package/dist/browser.js +19 -4
  4. package/dist/browser.js.map +1 -1
  5. package/dist/index.js +58 -38
  6. package/dist/index.js.map +1 -1
  7. package/dist/open5e.d.ts +2 -0
  8. package/dist/open5e.d.ts.map +1 -1
  9. package/dist/open5e.js +4 -0
  10. package/dist/open5e.js.map +1 -1
  11. package/dist/tools/campaign.js +1 -1
  12. package/dist/tools/campaign.js.map +1 -1
  13. package/dist/tools/character/ac.d.ts +20 -0
  14. package/dist/tools/character/ac.d.ts.map +1 -0
  15. package/dist/tools/character/ac.js +83 -0
  16. package/dist/tools/character/ac.js.map +1 -0
  17. package/dist/tools/character/actions.d.ts +22 -0
  18. package/dist/tools/character/actions.d.ts.map +1 -0
  19. package/dist/tools/character/actions.js +110 -0
  20. package/dist/tools/character/actions.js.map +1 -0
  21. package/dist/tools/character/core.d.ts +15 -0
  22. package/dist/tools/character/core.d.ts.map +1 -0
  23. package/dist/tools/character/core.js +129 -0
  24. package/dist/tools/character/core.js.map +1 -0
  25. package/dist/tools/character/defenses.d.ts +16 -0
  26. package/dist/tools/character/defenses.d.ts.map +1 -0
  27. package/dist/tools/character/defenses.js +27 -0
  28. package/dist/tools/character/defenses.js.map +1 -0
  29. package/dist/tools/character/definition.d.ts +14 -0
  30. package/dist/tools/character/definition.d.ts.map +1 -0
  31. package/dist/tools/character/definition.js +205 -0
  32. package/dist/tools/character/definition.js.map +1 -0
  33. package/dist/tools/character/features.d.ts +26 -0
  34. package/dist/tools/character/features.d.ts.map +1 -0
  35. package/dist/tools/character/features.js +107 -0
  36. package/dist/tools/character/features.js.map +1 -0
  37. package/dist/tools/character/helpers.d.ts +57 -0
  38. package/dist/tools/character/helpers.d.ts.map +1 -0
  39. package/dist/tools/character/helpers.js +80 -0
  40. package/dist/tools/character/helpers.js.map +1 -0
  41. package/dist/tools/character/identity.d.ts +19 -0
  42. package/dist/tools/character/identity.d.ts.map +1 -0
  43. package/dist/tools/character/identity.js +84 -0
  44. package/dist/tools/character/identity.js.map +1 -0
  45. package/dist/tools/character/inventory.d.ts +21 -0
  46. package/dist/tools/character/inventory.d.ts.map +1 -0
  47. package/dist/tools/character/inventory.js +61 -0
  48. package/dist/tools/character/inventory.js.map +1 -0
  49. package/dist/tools/character/notes.d.ts +20 -0
  50. package/dist/tools/character/notes.d.ts.map +1 -0
  51. package/dist/tools/character/notes.js +63 -0
  52. package/dist/tools/character/notes.js.map +1 -0
  53. package/dist/tools/character/parse.d.ts +13 -0
  54. package/dist/tools/character/parse.d.ts.map +1 -0
  55. package/dist/tools/character/parse.js +82 -0
  56. package/dist/tools/character/parse.js.map +1 -0
  57. package/dist/tools/character/spells.d.ts +26 -0
  58. package/dist/tools/character/spells.d.ts.map +1 -0
  59. package/dist/tools/character/spells.js +237 -0
  60. package/dist/tools/character/spells.js.map +1 -0
  61. package/dist/tools/character/stats.d.ts +33 -0
  62. package/dist/tools/character/stats.d.ts.map +1 -0
  63. package/dist/tools/character/stats.js +372 -0
  64. package/dist/tools/character/stats.js.map +1 -0
  65. package/dist/tools/character/templates.d.ts +22 -0
  66. package/dist/tools/character/templates.d.ts.map +1 -0
  67. package/dist/tools/character/templates.js +61 -0
  68. package/dist/tools/character/templates.js.map +1 -0
  69. package/dist/tools/character/types.d.ts +133 -0
  70. package/dist/tools/character/types.d.ts.map +1 -0
  71. package/dist/tools/character/types.js +12 -0
  72. package/dist/tools/character/types.js.map +1 -0
  73. package/dist/tools/character/vitals.d.ts +34 -0
  74. package/dist/tools/character/vitals.d.ts.map +1 -0
  75. package/dist/tools/character/vitals.js +198 -0
  76. package/dist/tools/character/vitals.js.map +1 -0
  77. package/dist/tools/character/weapons.d.ts +13 -0
  78. package/dist/tools/character/weapons.d.ts.map +1 -0
  79. package/dist/tools/character/weapons.js +79 -0
  80. package/dist/tools/character/weapons.js.map +1 -0
  81. package/dist/tools/character.d.ts +15 -3
  82. package/dist/tools/character.d.ts.map +1 -1
  83. package/dist/tools/character.js +28 -1182
  84. package/dist/tools/character.js.map +1 -1
  85. package/dist/tools/library.d.ts +32 -4
  86. package/dist/tools/library.d.ts.map +1 -1
  87. package/dist/tools/library.js +181 -86
  88. package/dist/tools/library.js.map +1 -1
  89. package/dist/tools/monster.d.ts +2 -0
  90. package/dist/tools/monster.d.ts.map +1 -1
  91. package/dist/tools/monster.js +4 -0
  92. package/dist/tools/monster.js.map +1 -1
  93. package/dist/tools/reference.d.ts +5 -0
  94. package/dist/tools/reference.d.ts.map +1 -1
  95. package/dist/tools/reference.js +76 -69
  96. package/dist/tools/reference.js.map +1 -1
  97. package/dist/tools/search.d.ts +1 -2
  98. package/dist/tools/search.d.ts.map +1 -1
  99. package/dist/tools/search.js +36 -59
  100. package/dist/tools/search.js.map +1 -1
  101. package/package.json +2 -1
@@ -1,10 +1,25 @@
1
+ /**
2
+ * Public API surface for character tools. The 1000-line `parseCharacterData`
3
+ * and 220-line `getDefinition` that used to live here have been carved into
4
+ * per-domain modules under `./character/` — see docs/character-refactor.md.
5
+ *
6
+ * What stays here: network/IO (getCharacter, downloadCharacter,
7
+ * listCharacters, findCharacterByName), the in-process JSON cache, and
8
+ * thin re-exports of the moved entry points.
9
+ */
1
10
  import { sessionFetch, hasValidSession, getCobaltToken } from "../session-fetch.js";
2
11
  import { TtlCache } from "../cache.js";
3
- import { addCharacterSpellsToCompendium, isConcentrationSpell } from "./reference.js";
4
- import { stripHtml as stripHtmlFull } from "../utils.js";
5
12
  import { writeFileSync, mkdirSync } from "fs";
6
13
  import { join, resolve, relative, basename, dirname, isAbsolute } from "path";
7
14
  import { homedir } from "os";
15
+ import { levenshteinDistance } from "./character/helpers.js";
16
+ import { parseCharacterData } from "./character/parse.js";
17
+ // Re-export so MCP server registrations (src/index.ts) and tests
18
+ // (tests/character-parser.test.ts, party.test.ts, character-snapshot.test.ts)
19
+ // can keep importing from `./tools/character.js`. New code should prefer the
20
+ // per-domain modules under `./character/`.
21
+ export { parseCharacterData } from "./character/parse.js";
22
+ export { getDefinition } from "./character/definition.js";
8
23
  // Cache character JSON to avoid redundant API calls within a session.
9
24
  // TTL is configurable via DDB_CHARACTER_CACHE_TTL (seconds); default 60 s.
10
25
  const CHARACTER_CACHE_TTL_MS = (() => {
@@ -13,20 +28,9 @@ const CHARACTER_CACHE_TTL_MS = (() => {
13
28
  return (Number.isFinite(seconds) && seconds > 0 ? seconds : 60) * 1000;
14
29
  })();
15
30
  const characterCache = new TtlCache(CHARACTER_CACHE_TTL_MS, 50);
16
- // ── Character name resolution ─────────────────────────────────────────────────
17
- function levenshteinDistance(a, b) {
18
- if (a.length === 0)
19
- return b.length;
20
- if (b.length === 0)
21
- return a.length;
22
- const matrix = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
23
- for (let i = 1; i <= a.length; i++) {
24
- for (let j = 1; j <= b.length; j++) {
25
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
26
- matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
27
- }
28
- }
29
- return matrix[a.length][b.length];
31
+ /** Wipe the in-process character JSON cache. */
32
+ export function clearCharacterCache() {
33
+ characterCache.clear();
30
34
  }
31
35
  /**
32
36
  * Resolve a character name to a numeric ID using the character list API.
@@ -35,8 +39,13 @@ function levenshteinDistance(a, b) {
35
39
  * Returns null if no match or multiple ambiguous fuzzy matches are found.
36
40
  */
37
41
  export async function findCharacterByName(name) {
38
- if (!hasValidSession())
39
- return null;
42
+ // Throw rather than return null when there's no session — otherwise callers
43
+ // surface a misleading "No character found matching '<name>'" message that
44
+ // looks like the character doesn't exist, when really the user just hasn't
45
+ // logged in yet.
46
+ if (!hasValidSession()) {
47
+ throw new Error("No session found. Please run ddb_login first to authenticate.");
48
+ }
40
49
  const { token, userId } = await getCobaltToken();
41
50
  const resp = await sessionFetch(`https://character-service.dndbeyond.com/character/v5/characters/list?userId=${userId}`, { headers: { Authorization: `Bearer ${token}` } });
42
51
  if (!resp.ok)
@@ -62,982 +71,6 @@ export async function findCharacterByName(name) {
62
71
  return fuzzy[0];
63
72
  return null;
64
73
  }
65
- export function parseCharacterData(raw, sections = "full") {
66
- const char = (raw?.data ?? raw);
67
- // Supplement spell compendium with this character's chosen spells (cantrips etc.)
68
- addCharacterSpellsToCompendium(char);
69
- // ── Helpers ───────────────────────────────────────────────────────────────
70
- const str = (v) => (v != null ? String(v) : "");
71
- const num = (v) => (typeof v === "number" ? v : 0);
72
- const arr = (v) => (Array.isArray(v) ? v : []);
73
- const obj = (v) => v != null && typeof v === "object" && !Array.isArray(v)
74
- ? v : {};
75
- const signed = (n) => n >= 0 ? `+${n}` : `${n}`;
76
- const modOf = (score) => Math.floor((score - 10) / 2);
77
- const hasTag = (feat, tag) => arr(obj(feat.definition).categories).some(c => c.tagName === tag);
78
- const stripHtml = (s) => s.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
79
- const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
80
- // Flatten every modifier category — Object.values captures subclass and any future categories
81
- // that the old hardcoded list missed (e.g. subclass modifiers like Remarkable Athlete).
82
- const allMods = Object.values(obj(char.modifiers))
83
- .flatMap(src => arr(src));
84
- // ── Identity ──────────────────────────────────────────────────────────────
85
- const charName = str(char.name);
86
- // 2024 races store the sub-selection (Elven Lineage, Fiendish Legacy, Giant
87
- // Ancestry, Gnomish Lineage, …) in char.options.race[] rather than in the
88
- // subRaceShortName field that 2014 characters use. The option name encodes
89
- // the chosen variant — e.g. "Wood Elf Lineage", "Infernal Legacy",
90
- // "Stone's Endurance (Stone Giant)". Pattern-match the suffix so this is
91
- // generic across every 2024 race that follows the same naming convention.
92
- // Note: 2024 Aasimar Celestial Revelation is *not* a creation-time choice —
93
- // the player picks one of Heavenly Wings / Inner Radiance / Necrotic Shroud
94
- // each time they transform, and char.options.race is empty for Aasimar.
95
- // Header correctly shows just "Aasimar" with no parenthetical.
96
- const detectRaceVariant = () => {
97
- for (const opt of arr(obj(char.options).race)) {
98
- const name = str(obj(opt.definition).name).trim();
99
- if (!name)
100
- continue;
101
- // "Wood Elf Lineage" / "High Elf Lineage" / "Drow Lineage"
102
- // / "Forest Gnome Lineage" / "Rock Gnome Lineage" / …
103
- let m = name.match(/^(.+?)\s+Lineage$/);
104
- if (m)
105
- return m[1];
106
- // "Infernal Legacy" / "Abyssal Legacy" / "Chthonic Legacy"
107
- m = name.match(/^(.+?)\s+Legacy$/);
108
- if (m)
109
- return m[1];
110
- // Giant Ancestry: "Stone's Endurance (Stone Giant)" etc.
111
- m = name.match(/\(([A-Za-z]+)\s+Giant\)$/);
112
- if (m)
113
- return m[1];
114
- }
115
- return null;
116
- };
117
- const race = (() => {
118
- const base = str(obj(char.race).fullName || obj(char.race).baseName);
119
- const variant = detectRaceVariant();
120
- if (!variant)
121
- return base;
122
- // Avoid "Wood Elf (Wood Elf)" if the base race name already mentions the
123
- // variant (defensive — happens when fullName has been pre-decorated).
124
- if (base.toLowerCase().includes(variant.toLowerCase()))
125
- return base;
126
- return `${base} (${variant})`;
127
- })();
128
- const classes = arr(char.classes);
129
- const classLine = classes.map(c => {
130
- const def = obj(c.definition);
131
- const sub = obj(c.subclassDefinition);
132
- const lvl = num(c.level);
133
- return sub.name ? `${def.name} (${sub.name}) ${lvl}` : `${def.name} ${lvl}`;
134
- }).join(" / ");
135
- const totalLevel = classes.reduce((s, c) => s + num(c.level), 0);
136
- const background = str(obj(obj(char.background).definition).name);
137
- const xp = num(char.currentXp);
138
- // ── Ability Scores ────────────────────────────────────────────────────────
139
- const statNames = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
140
- const statKeys = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"];
141
- const baseStats = arr(char.stats);
142
- const bonusStats = arr(char.bonusStats);
143
- const overrideStats = arr(char.overrideStats);
144
- const scoreBonuses = {};
145
- for (const m of allMods) {
146
- if (m.type === "bonus" && typeof m.subType === "string" && m.subType.endsWith("-score")) {
147
- const idx = statKeys.indexOf(m.subType.replace("-score", ""));
148
- if (idx >= 0)
149
- scoreBonuses[idx + 1] = (scoreBonuses[idx + 1] ?? 0) + num(m.fixedValue);
150
- }
151
- }
152
- const scoreSetValues = {};
153
- for (const m of allMods) {
154
- if (m.type === "set" && typeof m.subType === "string" && m.subType.endsWith("-score")) {
155
- const idx = statKeys.indexOf(m.subType.replace("-score", ""));
156
- if (idx >= 0) {
157
- const setVal = num(m.fixedValue ?? m.value);
158
- if (setVal > 0)
159
- scoreSetValues[idx + 1] = Math.max(scoreSetValues[idx + 1] ?? 0, setVal);
160
- }
161
- }
162
- }
163
- const statTotals = statNames.map((_, i) => {
164
- const id = i + 1;
165
- const base = baseStats.find(s => num(s.id) === id);
166
- const bonus = bonusStats.find(s => num(s.id) === id);
167
- const override = overrideStats.find(s => num(s.id) === id);
168
- const baseVal = num(base?.value ?? 0);
169
- const bonusVal = num(bonus?.value ?? 0);
170
- const overrideVal = override?.value != null ? num(override.value) : null;
171
- const calculated = overrideVal != null ? overrideVal : baseVal + bonusVal + (scoreBonuses[id] ?? 0);
172
- return scoreSetValues[id] != null ? Math.max(calculated, scoreSetValues[id]) : calculated;
173
- });
174
- const statMods = statTotals.map(modOf);
175
- const abilityScoreDisplay = statNames.map((n, i) => `${n} ${statTotals[i]} (${signed(statMods[i])})`);
176
- // ── Proficiency Bonus ─────────────────────────────────────────────────────
177
- const profBonus = Math.floor((totalLevel - 1) / 4) + 2;
178
- // ── Template resolver ─────────────────────────────────────────────────────
179
- const resolveTemplates = (text, classLevel) => {
180
- const vars = {
181
- proficiency: profBonus,
182
- level: totalLevel,
183
- characterlevel: totalLevel,
184
- classlevel: classLevel ?? totalLevel,
185
- };
186
- return text.replace(/\{\{([^}]+)\}\}/g, (_match, expr) => {
187
- const [rawExpr, modifier] = expr.split("#");
188
- const opMatch = rawExpr.match(/^(\w+)\s*([*+\-/])\s*(\d+(?:\.\d+)?)$/);
189
- let value = null;
190
- if (opMatch) {
191
- const [, varName, op, numStr] = opMatch;
192
- const base = vars[varName] ?? null;
193
- const n = parseFloat(numStr);
194
- if (base !== null) {
195
- if (op === "*")
196
- value = base * n;
197
- else if (op === "+")
198
- value = base + n;
199
- else if (op === "-")
200
- value = base - n;
201
- else if (op === "/")
202
- value = Math.floor(base / n);
203
- }
204
- }
205
- else if (vars[rawExpr] !== undefined) {
206
- value = vars[rawExpr];
207
- }
208
- if (value === null)
209
- return "?";
210
- const rounded = Math.floor(value);
211
- if (modifier === "signed")
212
- return rounded >= 0 ? `+${rounded}` : `${rounded}`;
213
- if (modifier === "unsigned")
214
- return String(Math.max(0, rounded));
215
- return String(rounded);
216
- });
217
- };
218
- // ── Hit Points ────────────────────────────────────────────────────────────
219
- // baseHitPoints does NOT include the CON modifier — must add conMod × level.
220
- const conMod = statMods[2];
221
- // Tough feat and Dwarven Toughness grant type:"bonus" subType:"hit-points-per-level" (value 2 or 1).
222
- const hpPerLevelBonus = allMods
223
- .filter(m => m.type === "bonus" && m.subType === "hit-points-per-level")
224
- .reduce((s, m) => s + num(m.value ?? m.fixedValue), 0);
225
- const maxHp = num(char.baseHitPoints) + num(char.bonusHitPoints) + ((conMod + hpPerLevelBonus) * totalLevel);
226
- const currentHp = maxHp - num(char.removedHitPoints);
227
- const tempHp = num(char.temporaryHitPoints);
228
- // ── Hit Dice ──────────────────────────────────────────────────────────────
229
- const hitDiceLines = classes.map(c => {
230
- const die = num(obj(c.definition).hitDice);
231
- const lvl = num(c.level);
232
- const used = num(c.hitDiceUsed);
233
- return `${lvl}d${die} (${lvl - used} remaining)`;
234
- });
235
- // ── Speed ─────────────────────────────────────────────────────────────────
236
- const weightSpeeds = obj(obj(obj(char.race).weightSpeeds).normal);
237
- // "set" modifiers override the base race speed; "bonus" modifiers add to it (e.g. Longstrider)
238
- const speedCalc = (subType, base, fallback = 0) => {
239
- const override = allMods
240
- .filter(m => m.type === "set" && m.subType === subType && num(m.value ?? m.fixedValue) > 0)
241
- .reduce((max, m) => Math.max(max, num(m.value ?? m.fixedValue)), 0);
242
- const bonus = allMods
243
- .filter(m => m.type === "bonus" && m.subType === subType)
244
- .reduce((s, m) => s + num(m.value ?? m.fixedValue), 0);
245
- return (override || base || fallback) + bonus;
246
- };
247
- // Monk Unarmored Movement uses "unarmored-movement" rather than "innate-speed-walking".
248
- // Technically only applies when unarmored and unshielded; we add it unconditionally here.
249
- // TODO: gate on absence of equipped armor/shield for strict correctness.
250
- const unarmoredMoveBonus = allMods
251
- .filter(m => m.type === "bonus" && m.subType === "unarmored-movement")
252
- .reduce((s, m) => s + num(m.value ?? m.fixedValue), 0);
253
- const walkSpeed = speedCalc("innate-speed-walking", num(weightSpeeds.walk), 30) + unarmoredMoveBonus;
254
- const flySpeed = speedCalc("innate-speed-flying", num(weightSpeeds.fly));
255
- const swimSpeed = speedCalc("innate-speed-swimming", num(weightSpeeds.swim));
256
- const climbSpeed = speedCalc("innate-speed-climbing", num(weightSpeeds.climb));
257
- const burrowSpeed = speedCalc("innate-speed-burrowing", num(weightSpeeds.burrow));
258
- const speedParts = [];
259
- speedParts.push(`${walkSpeed} ft.`);
260
- if (flySpeed > 0)
261
- speedParts.push(`fly ${flySpeed} ft.`);
262
- if (swimSpeed > 0)
263
- speedParts.push(`swim ${swimSpeed} ft.`);
264
- if (climbSpeed > 0)
265
- speedParts.push(`climb ${climbSpeed} ft.`);
266
- if (burrowSpeed > 0)
267
- speedParts.push(`burrow ${burrowSpeed} ft.`);
268
- // ── Initiative ────────────────────────────────────────────────────────────
269
- // Remarkable Athlete (Champion 7+) emits type:"half-proficiency" subType:"ability-checks",
270
- // the same modifier as JoAT, but it DOES apply to initiative — JoAT does not.
271
- // Detect it by subclass name + level so we can treat the two features differently.
272
- const hasRemarkableAthlete = classes.some(cls => {
273
- const sub = obj(cls.subclassDefinition);
274
- const subName = str(obj(sub.definition ?? cls.subclassDefinition).name || sub.name);
275
- return /champion/i.test(subName) && num(cls.level) >= 7;
276
- });
277
- const dexMod = statMods[1];
278
- const initiativeBonus = allMods
279
- .filter(m => m.subType === "initiative" && m.type === "bonus")
280
- .reduce((s, m) => {
281
- // bonusTypes [1] means the bonus value is the proficiency bonus, not a fixed number
282
- const usesProfBonus = arr(m.bonusTypes).includes(1) && (m.fixedValue == null && m.value == null);
283
- if (usesProfBonus)
284
- return s + profBonus;
285
- if (m.value != null)
286
- return s + num(m.value);
287
- if (m.fixedValue != null)
288
- return s + num(m.fixedValue);
289
- // statId-based bonus (e.g. Gloom Stalker Dread Ambusher adds WIS mod to initiative)
290
- const sid = num(m.statId);
291
- return s + (sid > 0 ? statMods[sid - 1] : 0);
292
- }, 0);
293
- // JoAT does not apply to initiative on the DDB website; Remarkable Athlete does.
294
- const initiative = dexMod + initiativeBonus + (hasRemarkableAthlete ? Math.floor(profBonus / 2) : 0);
295
- // ── Armor Class ───────────────────────────────────────────────────────────
296
- // armorTypeId: 1=light, 2=medium, 3=heavy, 4=shield
297
- const inventory = arr(char.inventory);
298
- const equippedArmorPieces = inventory.filter(i => i.equipped === true && str(obj(i.definition).filterType) === "Armor");
299
- const shield = equippedArmorPieces.find(i => num(obj(i.definition).armorTypeId) === 4);
300
- // If multiple body armors are equipped (e.g. party loot), pick whichever yields the best effective AC.
301
- const bodyArmorCandidates = equippedArmorPieces.filter(i => num(obj(i.definition).armorTypeId) !== 4);
302
- const effectiveBodyAc = (i) => {
303
- const def = obj(i.definition);
304
- const baseAc = num(def.armorClass);
305
- const typeId = num(def.armorTypeId);
306
- if (typeId === 1)
307
- return baseAc + dexMod;
308
- if (typeId === 2)
309
- return baseAc + Math.min(dexMod, 2);
310
- return baseAc;
311
- };
312
- const bodyArmor = bodyArmorCandidates.reduce((best, i) => best === null || effectiveBodyAc(i) > effectiveBodyAc(best) ? i : best, null);
313
- let ac;
314
- if (bodyArmor) {
315
- ac = effectiveBodyAc(bodyArmor);
316
- }
317
- else {
318
- // Unarmored Defense (Barbarian = 10+DEX+CON, Monk = 10+DEX+WIS)
319
- // Draconic Resilience uses type:"set" with a numeric value to lift the base (e.g. value:3 → base 13).
320
- const unarmoredMod = allMods.find(m => m.subType === "unarmored-armor-class");
321
- if (unarmoredMod) {
322
- const extraStatId = num(unarmoredMod.statId); // 3=CON (Barbarian), 5=WIS (Monk)
323
- const extraMod = extraStatId > 0 ? statMods[extraStatId - 1] : 0;
324
- const baseBonus = unarmoredMod.type === "set" ? num(unarmoredMod.fixedValue ?? unarmoredMod.value) : 0;
325
- ac = 10 + baseBonus + dexMod + extraMod;
326
- }
327
- else {
328
- ac = 10 + dexMod;
329
- }
330
- }
331
- if (shield)
332
- ac += num(obj(shield.definition).armorClass);
333
- // Add any AC bonus modifiers (e.g. shield of faith, ring of protection, Defense fighting style).
334
- // Defense fighting style uses "armored-armor-class" instead of "armor-class"; it should only apply
335
- // when wearing armor, but we include it unconditionally — most characters with it are armored.
336
- // TODO: gate "armored-armor-class" on equipped armor to avoid inflating AC for unarmored characters.
337
- const acBonus = allMods
338
- .filter(m => (m.subType === "armor-class" || m.subType === "armored-armor-class") && m.type === "bonus")
339
- .reduce((s, m) => s + num(m.fixedValue ?? m.value), 0);
340
- ac += acBonus;
341
- // ── Saving Throws ─────────────────────────────────────────────────────────
342
- const saveProfSubTypes = new Set(allMods.filter(m => m.type === "proficiency" && str(m.subType).includes("saving-throws"))
343
- .map(m => str(m.subType)));
344
- const savingThrows = statKeys.map((key, i) => {
345
- const isProficient = saveProfSubTypes.has(`${key}-saving-throws`);
346
- const total = statMods[i] + (isProficient ? profBonus : 0);
347
- return `${statNames[i]} ${signed(total)}${isProficient ? "*" : ""}`;
348
- });
349
- // ── Skills ────────────────────────────────────────────────────────────────
350
- const SKILLS = [
351
- ["Acrobatics", 1], ["Animal Handling", 4], ["Arcana", 3], ["Athletics", 0],
352
- ["Deception", 5], ["History", 3], ["Insight", 4], ["Intimidation", 5],
353
- ["Investigation", 3], ["Medicine", 4], ["Nature", 3], ["Perception", 4],
354
- ["Performance", 5], ["Persuasion", 5], ["Religion", 3], ["Sleight of Hand", 1],
355
- ["Stealth", 1], ["Survival", 4],
356
- ];
357
- const skillProfSubTypes = new Set(allMods.filter(m => m.type === "proficiency").map(m => str(m.subType)));
358
- const skillExpertiseSubTypes = new Set(allMods.filter(m => m.type === "expertise").map(m => str(m.subType)));
359
- // half-proficiency (e.g. Bard's Jack of All Trades applies to all ability checks)
360
- const hasJackOfAllTrades = allMods.some(m => m.type === "half-proficiency" && m.subType === "ability-checks");
361
- const skillHalfProfSubTypes = new Set(allMods.filter(m => m.type === "half-proficiency" && m.subType !== "ability-checks")
362
- .map(m => str(m.subType)));
363
- // Compute skill bonuses once; reuse for both the display lines and passive senses.
364
- const skillBonuses = SKILLS.map(([skillName, statIdx]) => {
365
- const slug = skillName.toLowerCase().replace(/ /g, "-").replace(/'/g, "");
366
- const isProficient = skillProfSubTypes.has(slug);
367
- const isExpertise = skillExpertiseSubTypes.has(slug);
368
- const isHalf = !isProficient && (skillHalfProfSubTypes.has(slug) || hasJackOfAllTrades);
369
- let bonus = statMods[statIdx];
370
- if (isExpertise)
371
- bonus += profBonus * 2;
372
- else if (isProficient)
373
- bonus += profBonus;
374
- else if (isHalf)
375
- bonus += Math.floor(profBonus / 2);
376
- // Flat bonus modifiers on the skill subType (e.g. Divine Order: Scholar adds WIS to Arcana/Religion).
377
- // When value/fixedValue are null, statId identifies which ability modifier to add instead.
378
- const flatBonus = allMods
379
- .filter(m => m.type === "bonus" && m.subType === slug)
380
- .reduce((s, m) => {
381
- if (m.value != null)
382
- return s + num(m.value);
383
- if (m.fixedValue != null)
384
- return s + num(m.fixedValue);
385
- const sid = num(m.statId);
386
- return s + (sid > 0 ? statMods[sid - 1] : 0);
387
- }, 0);
388
- bonus += flatBonus;
389
- return { bonus, isProficient, isExpertise };
390
- });
391
- const skillLines = SKILLS.map(([skillName, statIdx], i) => {
392
- const { bonus, isProficient, isExpertise } = skillBonuses[i];
393
- const marker = isExpertise ? " **" : isProficient ? " *" : "";
394
- const statLabel = statNames[statIdx];
395
- return ` ${(skillName + ` (${statLabel})`).padEnd(22)} ${signed(bonus)}${marker}`;
396
- });
397
- // ── Senses ────────────────────────────────────────────────────────────────
398
- const perceptionIdx = SKILLS.findIndex(([n]) => n === "Perception");
399
- const investigationIdx = SKILLS.findIndex(([n]) => n === "Investigation");
400
- const insightIdx = SKILLS.findIndex(([n]) => n === "Insight");
401
- const passivePerception = 10 + skillBonuses[perceptionIdx].bonus;
402
- const passiveInvestigation = 10 + skillBonuses[investigationIdx].bonus;
403
- const passiveInsight = 10 + skillBonuses[insightIdx].bonus;
404
- // Collect senses from all sources; keep highest value per sense name.
405
- const SENSE_ID_NAMES = {
406
- 1: "Blindsight", 2: "Darkvision", 3: "Tremorsense", 4: "Truesight",
407
- };
408
- const SENSE_SLUGS = new Set(["darkvision", "blindsight", "tremorsense", "truesight"]);
409
- const senseMap = new Map();
410
- const mergeSense = (name, val) => {
411
- if (val > 0)
412
- senseMap.set(name, Math.max(senseMap.get(name) ?? 0, val));
413
- };
414
- // 2024: type:"sense" modifiers
415
- for (const m of allMods)
416
- if (m.type === "sense")
417
- mergeSense(capitalize(str(m.subType)), num(m.value));
418
- // 2014: type:"set" / type:"set-base" modifiers with sense subType slugs
419
- for (const m of allMods)
420
- if ((m.type === "set" || m.type === "set-base") && SENSE_SLUGS.has(str(m.subType)))
421
- mergeSense(capitalize(str(m.subType)), num(m.value));
422
- // 2014: customSenses array (explicit overrides / grants)
423
- for (const cs of arr(char.customSenses)) {
424
- const name = SENSE_ID_NAMES[num(cs.senseId)];
425
- if (name)
426
- mergeSense(name, num(cs.value));
427
- }
428
- // 2014: racial trait senses (range parsed from notes, e.g. "60 feet")
429
- for (const trait of arr(obj(char.race).racialTraits)) {
430
- for (const sense of arr(obj(trait.definition).senses)) {
431
- const name = SENSE_ID_NAMES[num(sense.senseId)];
432
- const match = str(sense.notes).match(/\d+/);
433
- if (name && match)
434
- mergeSense(name, parseInt(match[0], 10));
435
- }
436
- }
437
- const specialSenses = Array.from(senseMap.entries()).map(([n, v]) => `${n} ${v} ft.`);
438
- // ── Proficiencies ─────────────────────────────────────────────────────────
439
- const armorProfMap = {
440
- "light-armor": "Light Armor", "medium-armor": "Medium Armor",
441
- "heavy-armor": "Heavy Armor", "shields": "Shields",
442
- };
443
- const weaponProfMap = {
444
- "simple-weapons": "Simple Weapons", "martial-weapons": "Martial Weapons",
445
- };
446
- // Placeholder subType values that are unresolved character-builder selections — discard them.
447
- const isProfPlaceholder = (sub) => sub.toLowerCase().startsWith("choose") || sub.toLowerCase() === "self";
448
- // Specific weapon type slugs — route to Weapons, not Tools.
449
- 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);
450
- const armorProfs = [];
451
- const weaponProfs = [];
452
- const toolProfs = [];
453
- const languages = [];
454
- for (const m of allMods) {
455
- const sub = str(m.subType);
456
- if (isProfPlaceholder(sub))
457
- continue;
458
- if (m.type === "proficiency") {
459
- if (armorProfMap[sub])
460
- armorProfs.push(armorProfMap[sub]);
461
- else if (weaponProfMap[sub])
462
- weaponProfs.push(weaponProfMap[sub]);
463
- else if (sub.includes("saving-throws") || sub.includes("-skill") ||
464
- SKILLS.some(([n]) => n.toLowerCase().replace(/ /g, "-").replace(/'/g, "") === sub)) {
465
- // skill/save prof — handled elsewhere
466
- }
467
- else if (!sub.includes("-score") && sub.length > 0 &&
468
- !statKeys.some(k => sub.startsWith(k))) {
469
- if (isWeaponSlug(sub)) {
470
- weaponProfs.push(capitalize(sub.replace(/-/g, " ")));
471
- }
472
- else {
473
- toolProfs.push(capitalize(sub.replace(/-/g, " ")));
474
- }
475
- }
476
- }
477
- else if (m.type === "language") {
478
- languages.push(capitalize(sub.replace(/-/g, " ")));
479
- }
480
- }
481
- // ── Defenses & Conditions ─────────────────────────────────────────────────
482
- const resistances = [...new Set(allMods.filter(m => m.type === "resistance").map(m => capitalize(str(m.subType))))];
483
- const immunities = [...new Set(allMods.filter(m => m.type === "immunity").map(m => capitalize(str(m.subType))))];
484
- const vulnerabilities = [...new Set(allMods.filter(m => m.type === "vulnerability").map(m => capitalize(str(m.subType))))];
485
- const conditions = arr(char.conditions).map(c => str(c.id));
486
- // ── Feats ─────────────────────────────────────────────────────────────────
487
- // DDB stores some non-feat entries in the feats array.
488
- // __DISGUISE_FEAT = class features surfaced as feats (shown in OTHER FEATURES).
489
- // __INITIAL_ASI = 2024 background Ability Score Improvements (already in ABILITY SCORES; drop entirely).
490
- const allFeats = arr(char.feats);
491
- const realFeats = allFeats.filter(f => !hasTag(f, "__DISGUISE_FEAT") && !hasTag(f, "__INITIAL_ASI"));
492
- const disguisedFeats = allFeats.filter(f => hasTag(f, "__DISGUISE_FEAT"));
493
- const featLines = realFeats.map(f => {
494
- const def = obj(f.definition);
495
- const snippet = resolveTemplates(stripHtml(str(def.snippet || def.description))).slice(0, 120);
496
- return `• ${str(def.name)}${snippet ? `: ${snippet}${snippet.length >= 120 ? "…" : ""}` : ""}`;
497
- });
498
- // ── Class Features ────────────────────────────────────────────────────────
499
- const classFeatureLines = [];
500
- const seenClassFeatures = new Set();
501
- for (const c of classes) {
502
- const charLevel = num(c.level);
503
- for (const cf of arr(c.classFeatures)) {
504
- const def = obj(cf.definition);
505
- const line = `• ${str(def.name)} (${str(obj(c.definition).name)} ${num(def.requiredLevel || 1)})`;
506
- if (num(def.requiredLevel || 0) <= charLevel && !seenClassFeatures.has(line)) {
507
- seenClassFeatures.add(line);
508
- classFeatureLines.push(line);
509
- }
510
- }
511
- }
512
- // ── Racial Traits ─────────────────────────────────────────────────────────
513
- const racialTraitLines = arr(obj(char.race).racialTraits).map(t => `• ${str(obj(t.definition).name)}`);
514
- // ── Background Feature ────────────────────────────────────────────────────
515
- // For custom backgrounds, featureName may reflect a feat name rather than
516
- // the actual background feature. Check customBackground first if present.
517
- const bgObj = obj(char.background);
518
- const customBg = obj(bgObj.customBackground);
519
- const featuresBackgroundDef = obj(obj(customBg.featuresBackground).definition);
520
- const customBgDef = Object.keys(featuresBackgroundDef).length > 0
521
- ? featuresBackgroundDef
522
- : obj(customBg.definition);
523
- const bgDef = Object.keys(customBgDef).length > 0 ? customBgDef : obj(bgObj.definition);
524
- const bgFeatureName = str(bgDef.featureName);
525
- const bgFeatureIsFeat = bgDef.featureIsFeat === true;
526
- const bgFeatureDesc = bgDef.featureDescription
527
- ? resolveTemplates(stripHtml(str(bgDef.featureDescription))).slice(0, 300)
528
- : "";
529
- // ── Actions / Bonus Actions / Reactions / Limited Use ─────────────────────
530
- // activation.activationType: 1=action, 3=bonus action, 4=reaction, 8=special (skip)
531
- // Filter Circle Spell entries — these leak from the Dark Bargain campaign feature
532
- // and don't represent real character abilities on the website.
533
- const allActions = Object.values(obj(char.actions))
534
- .flatMap(v => arr(v))
535
- .filter(a => a != null && !str(a.name).startsWith("Circle Spell") && str(a.name) !== "Initiate a Circle Spell");
536
- const activationType = (a) => num(obj(a.activation).activationType);
537
- // Bonus-action and reaction spells — activationType 3=bonus action, 4=reaction.
538
- // Apply the same prepared/ritual filter used in the main SPELLS section for spellbook
539
- // classes (Wizards) so unprepared non-ritual spells don't bleed into these sections.
540
- const allCharSpells = [
541
- ...arr(char.classSpells).flatMap(cs => {
542
- const classEntry = classes.find(c => c.id === cs.characterClassId);
543
- const isSpellbook = str(obj(classEntry?.definition ?? {}).name) === "Wizard";
544
- return arr(cs.spells).filter(s => !isSpellbook || s.prepared === true || obj(s.definition).ritual === true);
545
- }),
546
- ...Object.values(obj(char.spells)).flatMap(v => arr(v)),
547
- ].filter(Boolean);
548
- const spellActivationType = (s) => num(obj(obj(s.definition).activation).activationType);
549
- const formatSpell = (s) => {
550
- const def = obj(s.definition);
551
- const lvl = num(def.level);
552
- const slotStr = lvl === 0 ? "cantrip" : `${lvl === 1 ? "1st" : lvl === 2 ? "2nd" : lvl === 3 ? "3rd" : `${lvl}th`}-level slot`;
553
- return `• ${str(def.name)} (spell, ${slotStr})`;
554
- };
555
- // Spell activationTypes (from rule-data): 1=Action, 2=No Action, 3=Bonus Action, 4=Reaction, 8=Special
556
- const bonusActionSpells = allCharSpells.filter(s => spellActivationType(s) === 3).map(formatSpell);
557
- const reactionSpells = allCharSpells.filter(s => spellActivationType(s) === 4).map(formatSpell);
558
- // activationType 3 = bonus action in class actions, 4 = reaction
559
- // activationType 1 = action (weapon masteries — skip, shown in ACTIONS already)
560
- // activationType 8 = special/passive — skip
561
- const bonusActions = [
562
- ...allActions.filter(a => activationType(a) === 3).map(a => `• ${str(a.name)}`),
563
- ...bonusActionSpells,
564
- ];
565
- // Reactions: Opportunity Attack is universal, then class reactions, then reaction spells
566
- const reactions = [
567
- "• Opportunity Attack",
568
- ...allActions.filter(a => activationType(a) === 4).map(a => `• ${str(a.name)}`),
569
- ...reactionSpells,
570
- ];
571
- const limitedUseFeatures = allActions
572
- .filter(a => {
573
- const lu = obj(a.limitedUse);
574
- // maxUses=0 with statModifierUsesId means uses = that stat modifier (e.g. CHA for Bardic Inspiration)
575
- return lu.maxUses !== undefined && (num(lu.maxUses) > 0 || lu.statModifierUsesId != null);
576
- })
577
- .map(a => {
578
- const lu = obj(a.limitedUse);
579
- const resetLabels = { 1: "Short Rest", 2: "Long Rest" };
580
- const reset = resetLabels[num(lu.resetType)] ?? "Rest";
581
- let maxStr = num(lu.maxUses) > 0
582
- ? String(num(lu.maxUses))
583
- : lu.statModifierUsesId != null
584
- ? `${signed(statMods[num(lu.statModifierUsesId) - 1])} (stat)`
585
- : "?";
586
- const used = num(lu.numberUsed);
587
- return `• ${str(a.name)} ${used} used / ${maxStr} max (${reset})`;
588
- });
589
- // ── Weapon Attacks ────────────────────────────────────────────────────────
590
- const weaponProfSlugs = new Set(allMods.filter(m => m.type === "proficiency").map(m => str(m.subType)));
591
- const isWeaponProficient = (def) => {
592
- const catId = num(def.categoryId); // 1=simple, 2=martial
593
- const typeName = str(def.type).toLowerCase().replace(/[,\s]+/g, "-");
594
- return (catId === 1 && weaponProfSlugs.has("simple-weapons")) ||
595
- (catId === 2 && weaponProfSlugs.has("martial-weapons")) ||
596
- weaponProfSlugs.has(typeName);
597
- };
598
- // Martial Arts: allows DEX for monk weapons (simple melee + shortsword, no Two-Handed/Heavy)
599
- const hasMartialArts = allMods.some(m => str(m.subType) === "martial-arts") ||
600
- classes.some(c => arr(c.classFeatures).some(cf => str(obj(cf.definition).name) === "Martial Arts" &&
601
- num(obj(cf.definition).requiredLevel || 1) <= num(c.level)));
602
- const weaponAttacks = [];
603
- const weaponInventoryMap = new Map();
604
- for (const i of inventory) {
605
- const def = obj(i.definition);
606
- if (str(def.filterType) !== "Weapon")
607
- continue;
608
- if (i.equipped !== true)
609
- continue; // only show equipped weapons in ACTIONS
610
- const wName = str(def.name);
611
- const dmg = obj(def.damage);
612
- const dmgDice = str(dmg.diceString);
613
- const dmgType = str(def.damageType).toLowerCase();
614
- const attackType = num(def.attackType); // 1=melee, 2=ranged
615
- const props = arr(def.properties).map(p => str(p.name));
616
- const isFinesse = props.includes("Finesse");
617
- const isRanged = attackType === 2;
618
- const range = num(def.range);
619
- const longRange = num(def.longRange);
620
- const mastery = str(def.mastery);
621
- // Magic enhancement bonus from grantedModifiers (e.g. +1 weapon)
622
- const magicBonus = arr(def.grantedModifiers)
623
- .filter(gm => gm.type === "bonus" && gm.subType === "magic")
624
- .reduce((s, gm) => s + num(gm.value ?? gm.fixedValue), 0);
625
- // Monk weapons: simple melee or shortsword, no Two-Handed/Heavy
626
- const isMonkWeapon = hasMartialArts &&
627
- !props.includes("Two-Handed") && !props.includes("Heavy") &&
628
- ((num(def.categoryId) === 1 && attackType === 1) || str(def.name) === "Shortsword");
629
- // Ability modifier for attack/damage
630
- const usesDex = isRanged || ((isFinesse || isMonkWeapon) && dexMod > statMods[0]);
631
- const abilityMod = usesDex ? dexMod : statMods[0];
632
- const profMod = isWeaponProficient(def) ? profBonus : 0;
633
- const hitBonus = abilityMod + profMod + magicBonus;
634
- const dmgBonus = abilityMod + magicBonus;
635
- const dmgStr = dmgBonus !== 0 ? `${dmgDice}${signed(dmgBonus)}` : dmgDice;
636
- const rangeStr = isRanged ? `range ${range}/${longRange} ft.` : `reach 5 ft.`;
637
- const propsStr = [...props, ...(mastery ? [mastery] : [])].join(", ");
638
- const line = `• ${wName.padEnd(16)} ${signed(hitBonus)} to hit ${dmgStr} ${dmgType} ${rangeStr}${propsStr ? ` ${propsStr}` : ""}`;
639
- // Consolidate duplicates by key
640
- const key = wName + dmgDice;
641
- const existing = weaponInventoryMap.get(key);
642
- if (existing) {
643
- existing.qty += num(i.quantity) || 1;
644
- }
645
- else {
646
- weaponInventoryMap.set(key, { lines: [line], qty: num(i.quantity) || 1 });
647
- }
648
- }
649
- for (const { lines, qty } of weaponInventoryMap.values()) {
650
- weaponAttacks.push(qty > 1 ? lines[0].replace("•", `• ×${qty}`) : lines[0]);
651
- }
652
- // ── Spellcasting ──────────────────────────────────────────────────────────
653
- // spellCastingAbilityId: 1=STR 2=DEX 3=CON 4=INT 5=WIS 6=CHA
654
- const spellcastingLines = [];
655
- for (const c of classes) {
656
- const def = obj(c.definition);
657
- const subDef = obj(c.subclassDefinition);
658
- const classCasts = def.canCastSpells === true;
659
- const subclassCasts = subDef.canCastSpells === true;
660
- if (!classCasts && !subclassCasts)
661
- continue;
662
- const abilityId = num(classCasts ? def.spellCastingAbilityId : subDef.spellCastingAbilityId);
663
- if (!abilityId)
664
- continue;
665
- const className = classCasts ? str(def.name) : `${str(def.name)} (${str(subDef.name)})`;
666
- const abilityMod = statMods[abilityId - 1];
667
- const spellAttack = abilityMod + profBonus;
668
- const saveDc = 8 + abilityMod + profBonus;
669
- spellcastingLines.push(` ${className}: ${statNames[abilityId - 1]} Spell Attack: ${signed(spellAttack)} Save DC: ${saveDc}`);
670
- }
671
- // ── Spell Slots ───────────────────────────────────────────────────────────
672
- // char.spellSlots only tracks used counts; max slots come from the class's
673
- // levelSpellSlots progression table: levelSpellSlots[classLevel][slotLevel-1]
674
- const spellSlotUsed = {};
675
- for (const s of arr(char.spellSlots)) {
676
- spellSlotUsed[num(s.level)] = num(s.used);
677
- }
678
- const slotMax = {};
679
- for (const c of classes) {
680
- // Only compute slots for classes/subclasses that actually grant spellcasting.
681
- // Non-spellcasting base classes (Barbarian, Rogue, Monk, etc.) have canCastSpells: false
682
- // but still carry non-empty levelSpellSlots tables — skip those.
683
- // Spellcasting subclasses (Arcane Trickster, Eldritch Knight) set canCastSpells on
684
- // the subclassDefinition instead, so check both.
685
- const classCasts = obj(c.definition).canCastSpells === true;
686
- const subclassCasts = obj(c.subclassDefinition).canCastSpells === true;
687
- if (!classCasts && !subclassCasts)
688
- continue;
689
- const spellRules = obj(obj(c.definition).spellRules);
690
- const rawTable = spellRules.levelSpellSlots;
691
- const table = Array.isArray(rawTable) ? rawTable : [];
692
- const lvl = num(c.level);
693
- const row = table[lvl] ?? [];
694
- for (let i = 0; i < row.length; i++) {
695
- if (row[i] > 0)
696
- slotMax[i + 1] = (slotMax[i + 1] ?? 0) + row[i];
697
- }
698
- }
699
- const slotLines = Object.entries(slotMax)
700
- .sort(([a], [b]) => Number(a) - Number(b))
701
- .map(([lvl, max]) => {
702
- const used = spellSlotUsed[Number(lvl)] ?? 0;
703
- return ` Level ${lvl}: ${max - used}/${max}`;
704
- });
705
- // ── Spells ────────────────────────────────────────────────────────────────
706
- const spellSections = [];
707
- const classSpells = arr(char.classSpells);
708
- // seenSpellIds is pre-seeded here so cross-source duplicate detection also
709
- // catches class-feature auto-grants of spells the player already has prepared.
710
- const seenSpellIds = new Map(); // spellId → first source label
711
- for (const cs of classSpells) {
712
- // Try characterClassId first; fall back to id/classId for 2024-rules format
713
- const classEntry = classes.find(c => c.id === cs.characterClassId ||
714
- c.id === cs.id ||
715
- c.id === cs.classId);
716
- const className = str(obj(classEntry?.definition ?? {}).name);
717
- const isSpellbook = className === "Wizard";
718
- // spells may be under cs.spells or cs.classSpells (2024 format variation)
719
- const allSpells = arr(cs.spells).length > 0
720
- ? arr(cs.spells)
721
- : arr(cs.classSpells);
722
- const cantrips = allSpells
723
- .filter(s => num(obj(s.definition).level) === 0)
724
- .map(s => str(obj(s.definition).name));
725
- const leveled = allSpells
726
- .filter(s => {
727
- if (num(obj(s.definition).level) === 0)
728
- return false;
729
- if (isSpellbook)
730
- return s.prepared === true || obj(s.definition).ritual === true;
731
- return true;
732
- })
733
- .map(s => {
734
- const def = obj(s.definition);
735
- const ritual = isSpellbook && def.ritual ? " [ritual]" : "";
736
- return `${str(def.name)} (L${num(def.level)}${ritual})`;
737
- });
738
- if (cantrips.length)
739
- spellSections.push(` Cantrips: ${cantrips.join(", ")}`);
740
- if (leveled.length)
741
- spellSections.push(` Spells: ${leveled.join(", ")}`);
742
- // Pre-seed duplicate detection in the same pass
743
- for (const s of allSpells) {
744
- const spellId = num(obj(s.definition).id);
745
- if (spellId && !seenSpellIds.has(spellId))
746
- seenSpellIds.set(spellId, "Spells");
747
- }
748
- }
749
- const spellsObj = obj(char.spells);
750
- const sourceLabels = {
751
- race: "Racial Trait", class: "Class Feature", background: "Background", feat: "Feat", item: "Item",
752
- };
753
- const duplicateWarnings = [];
754
- for (const [key, label] of Object.entries(sourceLabels)) {
755
- const spellList = arr(spellsObj[key]);
756
- if (!spellList.length)
757
- continue;
758
- const names = [...new Set(spellList
759
- .filter(s => {
760
- const def = obj(s.definition);
761
- const spellId = num(def.id);
762
- if (!spellId)
763
- return true;
764
- if (seenSpellIds.has(spellId)) {
765
- const firstLabel = seenSpellIds.get(spellId);
766
- const spellName = str(def.name);
767
- const lvl = num(def.level);
768
- const spellStr = lvl === 0 ? spellName : `${spellName} (L${lvl})`;
769
- duplicateWarnings.push(` • ${spellStr} — already granted by ${firstLabel}, also in ${label}`);
770
- return false;
771
- }
772
- seenSpellIds.set(spellId, label);
773
- return true;
774
- })
775
- .map(s => {
776
- const def = obj(s.definition);
777
- const n = str(def.name);
778
- return n ? (num(def.level) === 0 ? n : `${n} (L${num(def.level)})`) : "";
779
- })
780
- .filter(n => n.length > 0))];
781
- if (names.length)
782
- spellSections.push(` From ${label}: ${names.join(", ")}`);
783
- }
784
- if (duplicateWarnings.length) {
785
- 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);
786
- }
787
- // ── Full Inventory ────────────────────────────────────────────────────────
788
- const equippedNonWeapons = [];
789
- const carriedItems = new Map();
790
- let attuned = 0;
791
- for (const i of inventory) {
792
- const def = obj(i.definition);
793
- const iName = str(def.name);
794
- const filterType = str(def.filterType);
795
- const qty = num(i.quantity) || 1;
796
- if (i.isAttuned)
797
- attuned++;
798
- if (i.equipped && filterType === "Armor") {
799
- const ac2 = num(def.armorClass);
800
- equippedNonWeapons.push(`${iName}${ac2 ? ` (AC ${ac2})` : ""}`);
801
- }
802
- else if (filterType !== "Weapon") {
803
- carriedItems.set(iName, (carriedItems.get(iName) ?? 0) + qty);
804
- }
805
- }
806
- const inventoryLine = [...carriedItems.entries()]
807
- .map(([n, q]) => q > 1 ? `${n} ×${q}` : n)
808
- .join(", ");
809
- // ── Currency ──────────────────────────────────────────────────────────────
810
- const currencies = obj(char.currencies);
811
- const currencyLine = ["pp", "gp", "ep", "sp", "cp"]
812
- .map(c => `${num(currencies[c])}${c}`)
813
- .filter(c => !c.startsWith("0"))
814
- .join(", ") || "none";
815
- // ── Death Saves ───────────────────────────────────────────────────────────
816
- const deathSaves = obj(char.deathSaves);
817
- const dsSucc = num(deathSaves.successCount);
818
- const dsFail = num(deathSaves.failCount);
819
- // ── Assemble named blocks ─────────────────────────────────────────────────
820
- const headerBlock = [
821
- `═══════════════════════════════════════`,
822
- ` ${charName}`,
823
- ` ${race} | ${classLine} | Level ${totalLevel}`,
824
- ` Background: ${background || "—"} | XP: ${xp}`,
825
- ` Inspiration: ${char.inspiration ? "Yes" : "No"}`,
826
- `═══════════════════════════════════════`,
827
- ``,
828
- ];
829
- const vitalsBlock = [
830
- `HP: ${currentHp}/${maxHp} Temp HP: ${tempHp || "—"} Prof Bonus: ${signed(profBonus)}`,
831
- `Hit Dice: ${hitDiceLines.join(" / ")}`,
832
- `AC: ${ac} Initiative: ${signed(initiative)} Speed: ${speedParts.join(", ")}`,
833
- `Death Saves: Successes ${dsSucc}/3 Failures ${dsFail}/3`,
834
- ``,
835
- ];
836
- const statsBlock = [
837
- `ABILITY SCORES`,
838
- ` ${abilityScoreDisplay.join(" ")}`,
839
- ``,
840
- `SAVING THROWS`,
841
- ` ${savingThrows.join(" ")}`,
842
- ` (* proficient)`,
843
- ``,
844
- `SKILLS`,
845
- ...skillLines,
846
- ` (* proficient, ** expertise)`,
847
- ``,
848
- `SENSES`,
849
- ` Passive Perception: ${passivePerception} Passive Investigation: ${passiveInvestigation} Passive Insight: ${passiveInsight}`,
850
- ...(specialSenses.length ? [` ${specialSenses.join(", ")}`] : []),
851
- ``,
852
- `PROFICIENCIES & TRAINING`,
853
- ` Armor: ${armorProfs.length ? [...new Set(armorProfs)].join(", ") : "None"}`,
854
- ` Weapons: ${weaponProfs.length ? [...new Set(weaponProfs)].join(", ") : "None"}`,
855
- ` Tools: ${toolProfs.length ? [...new Set(toolProfs)].join(", ") : "None"}`,
856
- ` Languages: ${languages.length ? [...new Set(languages)].join(", ") : "None"}`,
857
- ``,
858
- ];
859
- const defensesBlock = [
860
- `DEFENSES`,
861
- ` Resistances: ${resistances.length ? resistances.join(", ") : "(none)"}`,
862
- ` Immunities: ${immunities.length ? immunities.join(", ") : "(none)"}`,
863
- ` Vulnerabilities: ${vulnerabilities.length ? vulnerabilities.join(", ") : "(none)"}`,
864
- `CONDITIONS: ${conditions.length ? conditions.join(", ") : "(none)"}`,
865
- ``,
866
- ];
867
- const featuresBlock = [
868
- `FEATS (${realFeats.length})`,
869
- ...(featLines.length ? featLines : [" (none)"]),
870
- ``,
871
- ...(disguisedFeats.length ? [
872
- `OTHER FEATURES (stored as feats in API but NOT player-chosen feats)`,
873
- ...disguisedFeats.map(f => `• ${str(obj(f.definition).name)}`),
874
- ``,
875
- ] : []),
876
- `CLASS FEATURES`,
877
- ...classFeatureLines,
878
- ``,
879
- `RACIAL TRAITS`,
880
- ...racialTraitLines,
881
- ``,
882
- ...(!bgFeatureName || bgFeatureIsFeat ? [] : (() => {
883
- const descSnippet = bgFeatureDesc
884
- ? `${bgFeatureDesc}${bgFeatureDesc.length >= 300 ? "…" : ""}` : "";
885
- return [
886
- `BACKGROUND FEATURE`,
887
- ` ${bgFeatureName}${descSnippet ? `: ${descSnippet}` : ""}`,
888
- ``,
889
- ];
890
- })()),
891
- ];
892
- const combatBlock = [
893
- `ACTIONS`,
894
- ...(weaponAttacks.length ? weaponAttacks : [" (none)"]),
895
- ``,
896
- `BONUS ACTIONS`,
897
- ...(bonusActions.length ? bonusActions : [" (none)"]),
898
- ``,
899
- `REACTIONS`,
900
- ...(reactions.length ? reactions : [" (none)"]),
901
- ``,
902
- ...(limitedUseFeatures.length ? [`LIMITED USE`, ...limitedUseFeatures, ``] : []),
903
- ];
904
- const spellsBlock = [
905
- ...(spellcastingLines.length ? [`SPELLCASTING`, ...spellcastingLines, ``] : []),
906
- ...(slotLines.length ? [`SPELL SLOTS`, ...slotLines, ``] : []),
907
- ...(spellSections.length ? [`SPELLS`, ...spellSections, ``] : []),
908
- ];
909
- // ── Concentration Spells ──────────────────────────────────────────────────────
910
- // Collect all available/prepared spells, filter to concentration:true, group by level.
911
- const concByLevel = new Map();
912
- const addConcSpell = (s) => {
913
- const def = obj(s.definition);
914
- const name = str(def.name);
915
- if (!name)
916
- return;
917
- const level = num(def.level);
918
- const fromCompendium = isConcentrationSpell(name);
919
- const isConc = fromCompendium !== null ? fromCompendium : def.concentration === true;
920
- if (!isConc)
921
- return;
922
- const bucket = concByLevel.get(level) ?? [];
923
- if (!bucket.includes(name)) {
924
- bucket.push(name);
925
- concByLevel.set(level, bucket);
926
- }
927
- };
928
- for (const cs of classSpells) {
929
- const classEntry = classes.find(c => c.id === cs.characterClassId || c.id === cs.id || c.id === cs.classId);
930
- const isSpellbook2 = str(obj(classEntry?.definition ?? {}).name) === "Wizard";
931
- const allSp = arr(cs.spells).length > 0
932
- ? arr(cs.spells)
933
- : arr(cs.classSpells);
934
- for (const s of allSp) {
935
- const def = obj(s.definition);
936
- if (num(def.level) > 0 && isSpellbook2 && !(s.prepared === true || def.ritual === true))
937
- continue;
938
- addConcSpell(s);
939
- }
940
- }
941
- for (const spellList of Object.values(spellsObj)) {
942
- for (const s of arr(spellList))
943
- addConcSpell(s);
944
- }
945
- const slotOrdinal = (lvl) => {
946
- const o = ["", "1st", "2nd", "3rd", "4th", "5th", "6th", "7th", "8th", "9th"];
947
- return o[lvl] ?? `${lvl}th`;
948
- };
949
- const concentrationBlock = [];
950
- if (concByLevel.size === 0) {
951
- concentrationBlock.push("This character has no concentration spells prepared.");
952
- }
953
- else {
954
- concentrationBlock.push("CONCENTRATION SPELLS");
955
- for (const lvl of [...concByLevel.keys()].sort((a, b) => a - b)) {
956
- concentrationBlock.push(lvl === 0 ? " Cantrips (no slot required):" : ` Level ${lvl}:`);
957
- for (const name of concByLevel.get(lvl)) {
958
- concentrationBlock.push(` • ${name}${lvl > 0 ? ` [${slotOrdinal(lvl)}-level slot]` : ""}`);
959
- }
960
- }
961
- concentrationBlock.push("");
962
- if (slotLines.length)
963
- concentrationBlock.push("SPELL SLOTS", ...slotLines);
964
- }
965
- // ── Notes & Backstory ────────────────────────────────────────────────────────
966
- const traits = obj(char.traits);
967
- const notes = obj(char.notes);
968
- const field = (v) => { const s = stripHtmlFull(str(v)).trim(); return s || null; };
969
- const traitLines = [];
970
- const addTrait = (label, v) => { const t = field(v); if (t)
971
- traitLines.push(` ${label.padEnd(13)}${t}`); };
972
- addTrait("Traits:", traits.personalityTraits);
973
- addTrait("Ideals:", traits.ideals);
974
- addTrait("Bonds:", traits.bonds);
975
- addTrait("Flaws:", traits.flaws);
976
- addTrait("Appearance:", traits.appearance);
977
- const backstoryText = field(notes.backstory);
978
- const allyLines = [];
979
- const addAlly = (label, v) => { const t = field(v); if (t)
980
- allyLines.push(` ${label.padEnd(15)}${t}`); };
981
- addAlly("Allies:", notes.allies);
982
- addAlly("Organisations:", notes.organizations);
983
- const extraLines = [];
984
- const addExtra = (label, v) => { const t = field(v); if (t)
985
- extraLines.push(` ${label.padEnd(13)}${t}`); };
986
- addExtra("Possessions:", notes.personalPossessions);
987
- addExtra("Other:", notes.otherNotes);
988
- const notesBlock = [];
989
- const hasAny = traitLines.length || backstoryText || allyLines.length || extraLines.length;
990
- if (!hasAny) {
991
- notesBlock.push("No notes or backstory have been recorded for this character.");
992
- }
993
- else {
994
- if (traitLines.length)
995
- notesBlock.push("PERSONALITY", ...traitLines, "");
996
- if (backstoryText)
997
- notesBlock.push("BACKSTORY", ` ${backstoryText}`, "");
998
- if (allyLines.length)
999
- notesBlock.push("ALLIES & ORGANISATIONS", ...allyLines, "");
1000
- if (extraLines.length)
1001
- notesBlock.push("ADDITIONAL NOTES", ...extraLines, "");
1002
- }
1003
- const inventoryBlock = [
1004
- ...(equippedNonWeapons.length ? [`EQUIPPED`, ...equippedNonWeapons.map(e => ` ${e}`), ``] : []),
1005
- ...(inventoryLine ? [`INVENTORY`, ` ${inventoryLine}`, ``] : []),
1006
- `ATTUNEMENT: ${attuned}/3 slots used`,
1007
- ``,
1008
- `CURRENCY: ${currencyLine}`,
1009
- ];
1010
- // ── Select blocks by section ──────────────────────────────────────────────
1011
- const out = [...headerBlock];
1012
- switch (sections) {
1013
- case "summary":
1014
- out.push(...vitalsBlock, ...statsBlock);
1015
- break;
1016
- case "combat":
1017
- out.push(...vitalsBlock, ...statsBlock, ...defensesBlock, ...combatBlock);
1018
- break;
1019
- case "spells":
1020
- out.push(...(spellsBlock.length ? spellsBlock : ["No spellcasting on this character."]));
1021
- break;
1022
- case "inventory":
1023
- out.push(...inventoryBlock);
1024
- break;
1025
- case "features":
1026
- out.push(...featuresBlock);
1027
- break;
1028
- case "concentration":
1029
- out.push(...concentrationBlock);
1030
- break;
1031
- case "notes":
1032
- out.push(...notesBlock);
1033
- break;
1034
- case "full":
1035
- default:
1036
- out.push(...vitalsBlock, ...statsBlock, ...defensesBlock, ...featuresBlock, ...combatBlock, ...spellsBlock, ...inventoryBlock, ...notesBlock);
1037
- break;
1038
- }
1039
- return out.join("\n");
1040
- }
1041
74
  export async function parseCharacter(characterId, sections = "full") {
1042
75
  const jsonData = await getCharacter(characterId);
1043
76
  const raw = JSON.parse(jsonData);
@@ -1052,7 +85,7 @@ export async function getCharacter(characterId) {
1052
85
  const cached = characterCache.get(cacheKey);
1053
86
  if (cached !== undefined)
1054
87
  return cached;
1055
- const url = `https://character-service.dndbeyond.com/character/v5/character/${characterId}?includeCustomItems=true`;
88
+ const url = `https://character-service.dndbeyond.com/character/v5/character/${encodeURIComponent(characterId)}?includeCustomItems=true`;
1056
89
  // Public characters work without auth. Use session cookies if available so
1057
90
  // private/campaign-only characters owned by the logged-in user also work.
1058
91
  const resp = hasValidSession()
@@ -1143,191 +176,4 @@ export async function listCharacters() {
1143
176
  }));
1144
177
  return JSON.stringify(characters);
1145
178
  }
1146
- // ── Definition Lookup ─────────────────────────────────────────────────────────
1147
- function matchesDefinitionQuery(name, query) {
1148
- const n = name.toLowerCase();
1149
- const q = query.toLowerCase();
1150
- if (n.includes(q))
1151
- return true;
1152
- return name.split(/\s+/).some(w => levenshteinDistance(q, w.toLowerCase()) <= 2);
1153
- }
1154
- function formatSpellResult(spell) {
1155
- const d = (spell.definition ?? spell);
1156
- const name = String(d.name ?? "Unknown");
1157
- const level = Number(d.level ?? 0);
1158
- const school = String(d.school ?? "");
1159
- const levelLabel = level === 0 ? "Cantrip" : `Level ${level}`;
1160
- const ACTIVATION_TYPES = { 1: "Action", 3: "Bonus Action", 6: "Reaction" };
1161
- const act = d.activation;
1162
- const castingTime = act
1163
- ? `${act.activationTime} ${ACTIVATION_TYPES[Number(act.activationType)] ?? "Action"}`
1164
- : "1 Action";
1165
- const rng = d.range;
1166
- let range = "Self";
1167
- if (rng) {
1168
- if (rng.rangeValue && rng.origin !== "Self")
1169
- range = `${rng.rangeValue} ft`;
1170
- else
1171
- range = String(rng.origin ?? "Self");
1172
- if (rng.aoeType && rng.aoeValue)
1173
- range += ` (${rng.aoeValue}-ft ${rng.aoeType})`;
1174
- }
1175
- const dur = d.duration;
1176
- let duration = "Instantaneous";
1177
- if (dur) {
1178
- const isConc = dur.durationType === "Concentration";
1179
- if (dur.durationInterval && dur.durationUnit) {
1180
- duration = `${isConc ? "Concentration, up to " : ""}${dur.durationInterval} ${dur.durationUnit}${Number(dur.durationInterval) > 1 ? "s" : ""}`;
1181
- }
1182
- else if (isConc) {
1183
- duration = "Concentration";
1184
- }
1185
- }
1186
- const components = (Array.isArray(d.components) ? d.components : [])
1187
- .map((c) => ({ 1: "V", 2: "S", 3: "M" })[c])
1188
- .filter(Boolean)
1189
- .join(", ");
1190
- const matNote = d.componentsDescription ? ` (${d.componentsDescription})` : "";
1191
- const lines = [
1192
- `${name} (${levelLabel} ${school})`,
1193
- `Casting Time: ${castingTime}`,
1194
- `Range: ${range}`,
1195
- `Components: ${components || "None"}${matNote}`,
1196
- `Duration: ${duration}`,
1197
- ];
1198
- if (d.ritual)
1199
- lines.push("Ritual: Yes");
1200
- lines.push("", stripHtmlFull(String(d.description ?? "")));
1201
- return lines.join("\n");
1202
- }
1203
- function formatFeatResult(feat) {
1204
- const d = (feat.definition ?? feat);
1205
- const lines = [String(d.name ?? "Unknown")];
1206
- if (d.prerequisite)
1207
- lines.push(`Prerequisite: ${d.prerequisite}`);
1208
- lines.push("", stripHtmlFull(String(d.description ?? d.snippet ?? "")));
1209
- return lines.join("\n");
1210
- }
1211
- function formatClassFeatureResult(feature, className, level) {
1212
- const d = (feature.definition ?? feature);
1213
- const name = String(d.name ?? feature.name ?? "Unknown");
1214
- const desc = stripHtmlFull(String(d.description ?? d.snippet ?? ""));
1215
- return `${name} (${className}, Level ${level})\n\n${desc}`;
1216
- }
1217
- function formatRacialTraitResult(trait, raceName) {
1218
- const d = (trait.definition ?? trait);
1219
- const name = String(d.name ?? "Unknown");
1220
- const desc = stripHtmlFull(String(d.description ?? d.snippet ?? ""));
1221
- return `${name} (${raceName})\n\n${desc}`;
1222
- }
1223
- function formatItemResult(item) {
1224
- const d = (item.definition ?? item);
1225
- const name = String(d.name ?? "Unknown");
1226
- const type = String(d.type ?? "Item");
1227
- const rarity = String(d.rarity ?? "Common");
1228
- const weight = d.weight != null ? `Weight: ${d.weight} lb\n` : "";
1229
- const desc = stripHtmlFull(String(d.description ?? ""));
1230
- return `${name} (${type}, ${rarity})\n${weight}\n${desc}`;
1231
- }
1232
- function searchDefinitions(char, query) {
1233
- const results = [];
1234
- const arr = (v) => (Array.isArray(v) ? v : []);
1235
- const obj = (v) => v != null && typeof v === "object" && !Array.isArray(v) ? v : {};
1236
- const str = (v) => (typeof v === "string" ? v : "");
1237
- const num = (v) => (typeof v === "number" ? v : 0);
1238
- // ── Spells ────────────────────────────────────────────────────────────────
1239
- const allSpells = [
1240
- ...arr(char.classSpells).flatMap(cs => arr(cs.spells)),
1241
- ...Object.values(obj(char.spells)).flatMap(v => arr(v)),
1242
- ];
1243
- for (const spell of allSpells) {
1244
- const name = str(obj(spell.definition).name || spell.name);
1245
- if (name && matchesDefinitionQuery(name, query)) {
1246
- results.push({ type: "Spell", text: formatSpellResult(spell) });
1247
- }
1248
- }
1249
- // ── Feats ─────────────────────────────────────────────────────────────────
1250
- for (const feat of arr(char.feats)) {
1251
- const name = str(obj(feat.definition).name);
1252
- if (name && matchesDefinitionQuery(name, query)) {
1253
- results.push({ type: "Feat", text: formatFeatResult(feat) });
1254
- }
1255
- }
1256
- // ── Class & Subclass Features ─────────────────────────────────────────────
1257
- const seen = new Set();
1258
- for (const cls of arr(char.classes)) {
1259
- const charLevel = num(cls.level);
1260
- const className = str(obj(cls.definition).name);
1261
- for (const cf of arr(cls.classFeatures)) {
1262
- const d = obj(cf.definition);
1263
- const name = str(d.name);
1264
- const requiredLevel = num(d.requiredLevel || 1);
1265
- if (requiredLevel <= charLevel && name && matchesDefinitionQuery(name, query) && !seen.has(name)) {
1266
- seen.add(name);
1267
- results.push({
1268
- type: "Class Feature",
1269
- text: formatClassFeatureResult(cf, className, requiredLevel),
1270
- });
1271
- }
1272
- }
1273
- const subDef = obj(cls.subclassDefinition);
1274
- const subName = str(subDef.name);
1275
- for (const cf of arr(subDef.classFeatures)) {
1276
- const d = obj(cf.definition);
1277
- const name = str(d.name);
1278
- const requiredLevel = num(d.requiredLevel || 1);
1279
- const label = subName ? `${className} / ${subName}` : className;
1280
- if (requiredLevel <= charLevel && name && matchesDefinitionQuery(name, query) && !seen.has(name)) {
1281
- seen.add(name);
1282
- results.push({
1283
- type: "Subclass Feature",
1284
- text: formatClassFeatureResult(cf, label, requiredLevel),
1285
- });
1286
- }
1287
- }
1288
- }
1289
- // ── Racial Traits ─────────────────────────────────────────────────────────
1290
- const raceName = str(obj(char.race).fullName || obj(char.race).baseName);
1291
- for (const trait of arr(obj(char.race).racialTraits)) {
1292
- const name = str(obj(trait.definition).name);
1293
- if (name && matchesDefinitionQuery(name, query)) {
1294
- results.push({ type: "Racial Trait", text: formatRacialTraitResult(trait, raceName) });
1295
- }
1296
- }
1297
- // ── Background Feature ────────────────────────────────────────────────────
1298
- const bgDef = obj(obj(char.background).definition);
1299
- const bgFeatureName = str(bgDef.featureName);
1300
- if (bgFeatureName && matchesDefinitionQuery(bgFeatureName, query)) {
1301
- const bgName = str(bgDef.name);
1302
- const bgDesc = stripHtmlFull(str(bgDef.featureDescription));
1303
- results.push({
1304
- type: "Background Feature",
1305
- text: `${bgFeatureName} (${bgName})\n\n${bgDesc}`,
1306
- });
1307
- }
1308
- // ── Equipped Items ────────────────────────────────────────────────────────
1309
- for (const item of arr(char.inventory)) {
1310
- if (!item.equipped)
1311
- continue;
1312
- const name = str(obj(item.definition).name);
1313
- if (name && matchesDefinitionQuery(name, query)) {
1314
- results.push({ type: "Item", text: formatItemResult(item) });
1315
- }
1316
- }
1317
- return results;
1318
- }
1319
- export async function getDefinition(characterId, query) {
1320
- const jsonData = await getCharacter(characterId);
1321
- const raw = JSON.parse(jsonData);
1322
- const char = (raw?.data ?? raw);
1323
- const hits = searchDefinitions(char, query);
1324
- if (hits.length === 0) {
1325
- return `No definition found matching "${query}" on this character. Try a partial name like "hunter" for Hunter's Mark.`;
1326
- }
1327
- if (hits.length > 3) {
1328
- const list = hits.map((h, i) => `${i + 1}. [${h.type}] ${h.text.split("\n")[0]}`).join("\n");
1329
- return `Found ${hits.length} matches for "${query}". Be more specific, or here are the matches:\n\n${list}`;
1330
- }
1331
- return hits.map(h => `[${h.type}]\n${h.text}`).join("\n\n===\n\n");
1332
- }
1333
179
  //# sourceMappingURL=character.js.map