@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.
- package/README.md +520 -0
- package/dist/auth.d.ts +3 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +69 -0
- package/dist/auth.js.map +1 -0
- package/dist/browser.d.ts +9 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +68 -0
- package/dist/browser.js.map +1 -0
- package/dist/cache.d.ts +18 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +45 -0
- package/dist/cache.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +654 -0
- package/dist/index.js.map +1 -0
- package/dist/open5e.d.ts +74 -0
- package/dist/open5e.d.ts.map +1 -0
- package/dist/open5e.js +455 -0
- package/dist/open5e.js.map +1 -0
- package/dist/session-fetch.d.ts +35 -0
- package/dist/session-fetch.d.ts.map +1 -0
- package/dist/session-fetch.js +155 -0
- package/dist/session-fetch.js.map +1 -0
- package/dist/tools/campaign.d.ts +4 -0
- package/dist/tools/campaign.d.ts.map +1 -0
- package/dist/tools/campaign.js +72 -0
- package/dist/tools/campaign.js.map +1 -0
- package/dist/tools/character.d.ts +21 -0
- package/dist/tools/character.d.ts.map +1 -0
- package/dist/tools/character.js +1128 -0
- package/dist/tools/character.js.map +1 -0
- package/dist/tools/encounter.d.ts +22 -0
- package/dist/tools/encounter.d.ts.map +1 -0
- package/dist/tools/encounter.js +453 -0
- package/dist/tools/encounter.js.map +1 -0
- package/dist/tools/library.d.ts +4 -0
- package/dist/tools/library.d.ts.map +1 -0
- package/dist/tools/library.js +112 -0
- package/dist/tools/library.js.map +1 -0
- package/dist/tools/monster.d.ts +27 -0
- package/dist/tools/monster.d.ts.map +1 -0
- package/dist/tools/monster.js +378 -0
- package/dist/tools/monster.js.map +1 -0
- package/dist/tools/navigate.d.ts +5 -0
- package/dist/tools/navigate.d.ts.map +1 -0
- package/dist/tools/navigate.js +67 -0
- package/dist/tools/navigate.js.map +1 -0
- package/dist/tools/reference.d.ts +58 -0
- package/dist/tools/reference.d.ts.map +1 -0
- package/dist/tools/reference.js +850 -0
- package/dist/tools/reference.js.map +1 -0
- package/dist/tools/search.d.ts +4 -0
- package/dist/tools/search.d.ts.map +1 -0
- package/dist/tools/search.js +64 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/treasure.d.ts +12 -0
- package/dist/tools/treasure.d.ts.map +1 -0
- package/dist/tools/treasure.js +522 -0
- package/dist/tools/treasure.js.map +1 -0
- package/dist/utils.d.ts +5 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +21 -0
- package/dist/utils.js.map +1 -0
- 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
|